Expand voxel gameplay, lighting, full-height streaming and world imports
MVP checks / mvp (push) Canceled after 0s
MVP checks / mvp (push) Canceled after 0s
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
PhysicsClock,
|
||||
LocalPrediction,
|
||||
samplePhysicsWorld,
|
||||
} from "../player-physics.js";
|
||||
|
||||
const body = (x = 0) => ({
|
||||
position: [x, 1, 0],
|
||||
velocity: [0, 0, 0],
|
||||
pose: "standing",
|
||||
});
|
||||
const motion = (x = 0, ack = 0, tick = 0, epoch = 1) => ({
|
||||
body: body(x),
|
||||
ack,
|
||||
tick,
|
||||
epoch,
|
||||
settings: { movement_speed: 0.1 },
|
||||
});
|
||||
const step = ({ body: current, input }) => ({
|
||||
...current,
|
||||
position: [
|
||||
current.position[0] + (input.forward || 0),
|
||||
...current.position.slice(1),
|
||||
],
|
||||
flying: input.fly_toggle ? !current.flying : current.flying,
|
||||
});
|
||||
const makePrediction = (getWorld = () => ({ blocks: [] }), limit) =>
|
||||
new LocalPrediction(step, getWorld, limit);
|
||||
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-9, `${a} != ${b}`);
|
||||
|
||||
test("20 Hz input timing is the same at 30, 60, and 144 render frames per second", () => {
|
||||
for (const fps of [30, 60, 144]) {
|
||||
const clock = new PhysicsClock();
|
||||
let ticks = 0;
|
||||
for (let frame = 0; frame < fps * 3; frame++)
|
||||
clock.advance(1000 / fps, () => ticks++);
|
||||
assert.equal(ticks, 60);
|
||||
near(clock.alpha, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a long suspended frame cannot create unlimited catch-up inputs", () => {
|
||||
const clock = new PhysicsClock();
|
||||
let ticks = 0;
|
||||
assert.equal(
|
||||
clock.advance(60_000, () => ticks++),
|
||||
5,
|
||||
);
|
||||
assert.equal(
|
||||
clock.advance(0, () => ticks++),
|
||||
0,
|
||||
);
|
||||
assert.equal(ticks, 5);
|
||||
clock.advance(25, () => ticks++);
|
||||
near(clock.alpha, 0.5);
|
||||
clock.reset();
|
||||
near(clock.alpha, 0);
|
||||
});
|
||||
|
||||
test("authority acknowledges one input and replays only the still pending commands", () => {
|
||||
const prediction = makePrediction();
|
||||
prediction.receive(motion());
|
||||
prediction.push(1, { forward: 1 });
|
||||
prediction.push(2, { forward: 1 });
|
||||
prediction.push(3, { forward: -1 });
|
||||
assert.equal(prediction.body.position[0], 1);
|
||||
prediction.receive(motion(0.8, 1, 1));
|
||||
near(prediction.body.position[0], 0.8);
|
||||
assert.deepEqual(
|
||||
prediction.pending.map(({ seq }) => seq),
|
||||
[2, 3],
|
||||
);
|
||||
near(prediction.offset[0], 0.2);
|
||||
near(prediction.sample(0, 0, {}).position[0], 1);
|
||||
near(prediction.sample(0, 1, {}).position[0], 0.8 + 0.2 * Math.exp(-15));
|
||||
});
|
||||
|
||||
test("late ticks, decreasing acknowledgements, and duplicate inputs cannot rewind movement", () => {
|
||||
const prediction = makePrediction();
|
||||
prediction.receive(motion(4, 4, 10));
|
||||
prediction.push(5, { forward: 1 });
|
||||
assert.equal(prediction.receive(motion(-10, 5, 9)), false);
|
||||
assert.equal(prediction.receive(motion(-10, 3, 11)), false);
|
||||
assert.equal(prediction.push(5, { forward: 20 }), false);
|
||||
assert.equal(prediction.push(4, { forward: 20 }), false);
|
||||
assert.equal(prediction.body.position[0], 5);
|
||||
});
|
||||
|
||||
test("world/respawn/input reset epochs drop old commands and visual correction", () => {
|
||||
const prediction = makePrediction();
|
||||
prediction.receive(motion());
|
||||
prediction.push(1, { forward: 1 });
|
||||
prediction.receive(motion(0.7, 1, 1));
|
||||
prediction.push(2, { forward: 1 });
|
||||
prediction.receive(motion(6, 2, 2, 2));
|
||||
assert.deepEqual(prediction.pending, []);
|
||||
assert.deepEqual(prediction.offset, [0, 0, 0]);
|
||||
assert.equal(prediction.body.position[0], 6);
|
||||
assert.equal(prediction.receive(motion(-6, 3, 3, 1)), false);
|
||||
assert.equal(prediction.body.position[0], 6);
|
||||
prediction.reset();
|
||||
assert.equal(prediction.body, null);
|
||||
assert.equal(prediction.tick, -1);
|
||||
prediction.receive(motion(30, 0, 0), true);
|
||||
assert.equal(prediction.body.position[0], 30);
|
||||
});
|
||||
|
||||
test("a teleport without an epoch change does not replay pre-teleport movement", () => {
|
||||
const prediction = makePrediction();
|
||||
prediction.receive(motion());
|
||||
prediction.push(1, { forward: 1 });
|
||||
prediction.receive(motion(100, 0, 1));
|
||||
assert.equal(prediction.body.position[0], 100);
|
||||
assert.equal(prediction.pending.length, 0);
|
||||
});
|
||||
|
||||
test("unavailable chunks suspend prediction until authority and a complete context arrive", () => {
|
||||
let available = false;
|
||||
const prediction = makePrediction(() => (available ? { blocks: [] } : null));
|
||||
prediction.receive(motion());
|
||||
assert.equal(prediction.push(1, { forward: 1 }), false);
|
||||
assert.equal(prediction.suspended, true);
|
||||
assert.equal(prediction.body.position[0], 0);
|
||||
available = true;
|
||||
prediction.receive(motion(0.5, 1, 1));
|
||||
prediction.push(2, { forward: 1 });
|
||||
assert.equal(prediction.suspended, false);
|
||||
assert.equal(prediction.body.position[0], 1.5);
|
||||
});
|
||||
|
||||
test("bounded history suspends until the discarded sequence has been acknowledged", () => {
|
||||
const prediction = makePrediction(undefined, 3);
|
||||
prediction.receive(motion());
|
||||
for (let seq = 1; seq <= 5; seq++) prediction.push(seq, { forward: 1 });
|
||||
assert.equal(prediction.pending.length, 3);
|
||||
assert.equal(prediction.suspended, true);
|
||||
prediction.receive(motion(1, 1, 1));
|
||||
assert.equal(prediction.suspended, true);
|
||||
prediction.receive(motion(2, 2, 2));
|
||||
assert.equal(prediction.suspended, false);
|
||||
assert.equal(prediction.body.position[0], 5);
|
||||
});
|
||||
|
||||
test("render previews do not mutate acknowledged bodies, queue commands, or consume a flight toggle", () => {
|
||||
const prediction = makePrediction();
|
||||
prediction.receive(motion());
|
||||
for (let i = 0; i < 3; i++) {
|
||||
near(
|
||||
prediction.sample(0.5, 0, { forward: 1, fly_toggle: true }).position[0],
|
||||
0.5,
|
||||
);
|
||||
assert.equal(prediction.body.flying, undefined);
|
||||
assert.equal(prediction.pending.length, 0);
|
||||
}
|
||||
prediction.push(1, { forward: 1, fly_toggle: true });
|
||||
assert.equal(prediction.body.flying, true);
|
||||
prediction.receive({ ...motion(0, 0, 1), body: body() });
|
||||
assert.equal(prediction.body.flying, true);
|
||||
});
|
||||
|
||||
test("swept neighbourhood includes fast-fall floors, transparent fluids and local collision boxes", () => {
|
||||
const player = { ...body(), position: [-0.2, 7, 0], velocity: [0, -3.92, 0] };
|
||||
const collision = [{ min: [0, 0, 0], max: [1, 1.5, 1] }];
|
||||
const materials = new Map([
|
||||
[1, { state: "minecraft:water[level=0]", collision: [] }],
|
||||
[2, { state: "minecraft:oak_fence", collision }],
|
||||
]);
|
||||
const blocks = new Map([
|
||||
["-1,6,0", 1],
|
||||
["-1,2,0", 2],
|
||||
["40,0,0", 2],
|
||||
]);
|
||||
const bounds = { min: [-32, -16, -32], max: [31, 31, 31] };
|
||||
const world = samplePhysicsWorld(player, blocks, materials, bounds);
|
||||
assert.equal(world.blocks.length, 2);
|
||||
assert.deepEqual(
|
||||
world.blocks.find((b) => b.state.includes("fence")),
|
||||
{ pos: [-1, 2, 0], state: "minecraft:oak_fence", collision },
|
||||
);
|
||||
assert.deepEqual(
|
||||
world.blocks.find((b) => b.state.includes("water")).collision,
|
||||
[],
|
||||
);
|
||||
assert.equal(samplePhysicsWorld(player, blocks, new Map(), bounds), null);
|
||||
assert.equal(
|
||||
samplePhysicsWorld(player, blocks, materials, {
|
||||
min: [-1, 0, -1],
|
||||
max: [1, 10, 1],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid motion packets leave the valid body and history untouched", () => {
|
||||
const prediction = makePrediction();
|
||||
prediction.receive(motion());
|
||||
assert.equal(
|
||||
prediction.receive({
|
||||
...motion(),
|
||||
body: { position: [NaN, 0, 0], velocity: [0, 0, 0] },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(prediction.receive({ ...motion(), ack: 0.5 }), false);
|
||||
assert.equal(prediction.body.position[0], 0);
|
||||
});
|
||||
Reference in New Issue
Block a user