Files
Emil c7e86663d8
MVP checks / mvp (push) Waiting to run
Expand voxel gameplay, lighting, full-height streaming and world imports
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.
2026-09-17 02:10:53 +03:00

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;
}
}