MVP checks / mvp (push) Waiting to run
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.
257 lines
8.3 KiB
JavaScript
257 lines
8.3 KiB
JavaScript
/** The browser and authoritative server execute the same 20 Hz Rust solver. */
|
|
export const PHYSICS_TICK_MS = 50;
|
|
export const EYE_HEIGHT = { standing: 1.62, crouching: 1.27, swimming: 0.4 };
|
|
|
|
const copyBody = (body) => ({
|
|
...body,
|
|
position: [...body.position],
|
|
velocity: [...body.velocity],
|
|
});
|
|
const validBody = (body) =>
|
|
[body?.position, body?.velocity].every(
|
|
(v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite),
|
|
);
|
|
|
|
export async function loadPhysics(url = "/physics.wasm?v=movement-1") {
|
|
const response = await fetch(url, { cache: "no-cache" });
|
|
if (!response.ok) throw Error(`Physics module: HTTP ${response.status}`);
|
|
let result;
|
|
try {
|
|
result = await WebAssembly.instantiateStreaming(response.clone(), {});
|
|
} catch {
|
|
// Some local servers do not supply application/wasm.
|
|
result = await WebAssembly.instantiate(await response.arrayBuffer(), {});
|
|
}
|
|
const api = result.instance.exports;
|
|
if (
|
|
api.physics_version?.() !== 1 ||
|
|
!api.memory ||
|
|
![
|
|
"physics_input",
|
|
"physics_run",
|
|
"physics_output",
|
|
"physics_output_len",
|
|
].every((name) => typeof api[name] === "function")
|
|
)
|
|
throw Error("Incompatible physics module");
|
|
const encoder = new TextEncoder(),
|
|
decoder = new TextDecoder();
|
|
return (request) => {
|
|
const input = encoder.encode(JSON.stringify(request));
|
|
if (input.length > 16 * 1024 * 1024)
|
|
throw Error("Physics input exceeds 16 MiB");
|
|
const pointer = api.physics_input(input.length);
|
|
if (!pointer) throw Error("Physics input allocation failed");
|
|
// The allocator/solver can grow memory: never retain an old typed view.
|
|
new Uint8Array(api.memory.buffer, pointer, input.length).set(input);
|
|
const status = api.physics_run();
|
|
const output = JSON.parse(
|
|
decoder.decode(
|
|
new Uint8Array(
|
|
api.memory.buffer,
|
|
api.physics_output(),
|
|
api.physics_output_len(),
|
|
),
|
|
),
|
|
);
|
|
if (status || !validBody(output))
|
|
throw Error(output.error || "Invalid physics output");
|
|
return output;
|
|
};
|
|
}
|
|
|
|
/** Build only the swept collision neighbourhood, including non-solid fluids. */
|
|
export function samplePhysicsWorld(body, blocks, materials, bounds) {
|
|
if (!bounds || !validBody(body)) return null;
|
|
const min = [],
|
|
max = [];
|
|
for (let axis = 0; axis < 3; axis++) {
|
|
const pos = body.position[axis],
|
|
velocity = body.velocity[axis];
|
|
min[axis] = Math.floor(
|
|
Math.min(pos, pos + velocity) - (axis === 1 ? 3 : 2),
|
|
);
|
|
max[axis] = Math.floor(
|
|
Math.max(pos, pos + velocity) + (axis === 1 ? 4 : 2),
|
|
);
|
|
// Unknown chunks are not empty air. Keep authority until they arrive.
|
|
if (!(axis === 1 && bounds.fullHeight) && (min[axis] < bounds.min[axis] || max[axis] > bounds.max[axis]))
|
|
return null;
|
|
}
|
|
if (bounds.fullHeight) {
|
|
// Above/below the declared world height is known void, not missing chunks.
|
|
min[1] = Math.max(min[1], bounds.min[1]);
|
|
max[1] = Math.min(max[1], bounds.max[1]);
|
|
}
|
|
if (blocks.hasSection) {
|
|
for(let x=Math.floor(min[0]/16);x<=Math.floor(max[0]/16);x++)
|
|
for(let y=Math.floor(min[1]/16);y<=Math.floor(max[1]/16);y++)
|
|
for(let z=Math.floor(min[2]/16);z<=Math.floor(max[2]/16);z++)
|
|
if(!blocks.hasSection(`${x},${y},${z}`) || blocks.readySections && !blocks.readySections.has(`${x},${y},${z}`))return null;
|
|
}
|
|
const records = [];
|
|
for (let x = min[0]; x <= max[0]; x++)
|
|
for (let y = min[1]; y <= max[1]; y++)
|
|
for (let z = min[2]; z <= max[2]; z++) {
|
|
const id = blocks.getAt ? blocks.getAt(x,y,z) : blocks.get(`${x},${y},${z}`);
|
|
if (!id) continue;
|
|
const material = materials.get(id);
|
|
if (!material) return null;
|
|
records.push({
|
|
pos: [x, y, z],
|
|
state: material.state,
|
|
collision: material.collision,
|
|
});
|
|
}
|
|
return { blocks: records };
|
|
}
|
|
|
|
/** Fixed steps are independent of display FPS; long stalls never create a burst. */
|
|
export class PhysicsClock {
|
|
constructor() {
|
|
this.reset();
|
|
}
|
|
reset() {
|
|
this.accumulator = 0;
|
|
}
|
|
advance(elapsed, step) {
|
|
this.accumulator += Math.max(0, Math.min(250, elapsed));
|
|
let count = 0;
|
|
while (this.accumulator + 1e-7 >= PHYSICS_TICK_MS && count < 5) {
|
|
this.accumulator -= PHYSICS_TICK_MS;
|
|
count++;
|
|
step();
|
|
}
|
|
return count;
|
|
}
|
|
get alpha() {
|
|
return Math.max(0, Math.min(1, this.accumulator / PHYSICS_TICK_MS));
|
|
}
|
|
}
|
|
|
|
/** Sequence-based reconciliation; physics state is never replaced by visual lerp. */
|
|
export class LocalPrediction {
|
|
constructor(step, getWorld, historyLimit = 120) {
|
|
this.step = step;
|
|
this.getWorld = getWorld;
|
|
this.historyLimit = historyLimit;
|
|
this.reset();
|
|
}
|
|
reset() {
|
|
this.body = null;
|
|
this.authority = null;
|
|
this.settings = {};
|
|
this.pending = [];
|
|
this.ack = -1;
|
|
this.tick = -1;
|
|
this.epoch = null;
|
|
this.offset = [0, 0, 0];
|
|
this.correction = 0;
|
|
this.waitingThrough = -1;
|
|
this.suspended = false;
|
|
this.preview = null;
|
|
}
|
|
simulate(body, input) {
|
|
const world = this.getWorld(body);
|
|
return world
|
|
? this.step({ body, input, world, settings: this.settings })
|
|
: null;
|
|
}
|
|
receive(motion, reset = false) {
|
|
if (
|
|
!validBody(motion?.body) ||
|
|
!Number.isSafeInteger(motion.ack) ||
|
|
!Number.isSafeInteger(motion.tick)
|
|
)
|
|
return false;
|
|
if (!reset && (motion.tick < this.tick || motion.ack < this.ack))
|
|
return false;
|
|
if (!reset && this.epoch !== null && (motion.epoch ?? 0) < this.epoch)
|
|
return false;
|
|
const epochChanged = this.epoch !== null && motion.epoch !== this.epoch;
|
|
const teleport =
|
|
this.authority &&
|
|
Math.hypot(
|
|
...motion.body.position.map((v, i) => v - this.authority.position[i]),
|
|
) > 8;
|
|
const instant = reset || epochChanged || teleport || !this.body;
|
|
const oldPosition = this.body?.position;
|
|
if (instant) {
|
|
this.pending = [];
|
|
this.offset = [0, 0, 0];
|
|
this.waitingThrough = -1;
|
|
}
|
|
this.ack = motion.ack;
|
|
this.tick = motion.tick;
|
|
this.epoch = motion.epoch ?? 0;
|
|
this.settings = { ...(motion.settings || {}) };
|
|
this.authority = copyBody(motion.body);
|
|
this.body = copyBody(motion.body);
|
|
this.pending = this.pending.filter(({ seq }) => seq > this.ack);
|
|
this.suspended = this.ack < this.waitingThrough;
|
|
if (!this.suspended) {
|
|
for (const { input } of this.pending) {
|
|
const next = this.simulate(this.body, input);
|
|
if (!next) {
|
|
this.suspended = true;
|
|
break;
|
|
}
|
|
this.body = next;
|
|
}
|
|
}
|
|
if (this.suspended) this.body = copyBody(this.authority);
|
|
this.correction = oldPosition
|
|
? Math.hypot(...this.body.position.map((v, i) => v - oldPosition[i]))
|
|
: 0;
|
|
if (!instant && this.correction < 4)
|
|
for (let i = 0; i < 3; i++)
|
|
this.offset[i] += oldPosition[i] - this.body.position[i];
|
|
else this.offset = [0, 0, 0];
|
|
this.preview = null;
|
|
return true;
|
|
}
|
|
push(seq, input) {
|
|
if (
|
|
!this.body ||
|
|
seq <= this.ack ||
|
|
seq <= (this.pending.at(-1)?.seq ?? -1)
|
|
)
|
|
return false;
|
|
this.pending.push({ seq, input: { ...input } });
|
|
if (this.pending.length > this.historyLimit) {
|
|
this.waitingThrough = this.pending.shift().seq;
|
|
this.suspended = true;
|
|
}
|
|
if (!this.suspended) {
|
|
const next = this.simulate(this.body, input);
|
|
if (next) this.body = next;
|
|
else this.suspended = true;
|
|
}
|
|
this.preview = null;
|
|
return !this.suspended;
|
|
}
|
|
sample(alpha, dt, input) {
|
|
if (!this.body) return null;
|
|
const damping = Math.exp(-15 * Math.max(0, dt));
|
|
this.offset = this.offset.map((value) => value * damping);
|
|
let next = this.body;
|
|
if (!this.suspended) {
|
|
// This partial render step is disposable and is never sent or acknowledged.
|
|
const signature = JSON.stringify(input);
|
|
if (this.preview?.signature !== signature)
|
|
this.preview = { signature, body: this.simulate(this.body, input) };
|
|
next = this.preview?.body || this.body;
|
|
}
|
|
return {
|
|
position: this.body.position.map(
|
|
(v, i) => v + (next.position[i] - v) * alpha + this.offset[i],
|
|
),
|
|
eyeHeight: EYE_HEIGHT[this.body.pose] ?? 1.62,
|
|
body: this.body,
|
|
};
|
|
}
|
|
invalidateWorld() {
|
|
this.preview = null;
|
|
}
|
|
}
|