// Level runtime: wraps the untouched movement core with everything that makes // a level a level — start line, checkpoints, void respawn, finish, the run // timer, and level-driven dynamic geometry (crumbling tiles, closing shutter). // // Pure and deterministic: no DOM, no three.js, no wall clock. One call to // step(input) advances exactly one 120 Hz tick, so the same code drives the // browser game, the Node verification bot and the in-page ghost recorder. import { createGame, createPlayer, stepGame, CFG, DT } from "../core/sim.js"; import { inZone } from "./levels/kit.js"; export const TICK_HZ = Math.round(1 / DT); export const GHOST_STRIDE = 4; // record one ghost sample every 4 ticks (30 Hz) const GONE_Y = -9000; // where a crumbled tile's collision is parked const onTop = (p, b) => { if (!p.grounded || Math.abs(p.pos.y - b.max.y) > 1e-6) return false; const cx = Math.min(Math.max(p.pos.x, b.min.x), b.max.x); const cz = Math.min(Math.max(p.pos.z, b.min.z), b.max.z); const dx = p.pos.x - cx; const dz = p.pos.z - cz; return dx * dx + dz * dz < CFG.radius * CFG.radius; }; export class Run { constructor(level) { this.level = level; this.world = { boxes: level.boxes, spawn: level.spawn.pos, spawnYaw: level.spawn.yaw }; if (level.ramps && level.ramps.length) this.world.ramps = level.ramps; this.game = createGame(this.world); // parts[0] is the box that triggers/moves; `with` boxes ride along (a beam on a tile) this.dyn = (level.dynamics ?? []).map((def) => ({ def, state: "idle", t: 0, y0: def.box.min.y, y1: def.box.max.y, parts: [def.box, ...(def.with ?? [])].map((box) => ({ box, y0: box.min.y, y1: box.max.y })), })); this.events = []; this.attempts = 0; this.restart(); } get player() { return this.game.player; } /** Run time in seconds (sim time; frame-rate independent). */ get time() { return this.ticks * DT; } get timed() { return !!this.level.finish; } emit(type, data) { this.events.push({ type, ...data }); } drain() { const e = this.events; this.events = []; return e; } restart() { this.state = this.timed ? "ready" : "free"; // ready -> running -> finished this.ticks = 0; this.cp = -1; this.splits = []; this.falls = 0; this.maxSpeed = 0; this.trail = []; this.hintIn = new Set(); this.attempts++; this.placePlayer(this.level.spawn); this.resetDynamics(); this.emit("restart"); } /** * Back to the last checkpoint; the clock keeps running. Before the first * checkpoint there is nothing to keep, so the run simply starts over. */ respawn(reason = "fall") { if (this.state === "finished") return; if (this.cp < 0 && this.timed) { this.restart(); this.emit("respawn", { reason, cp: -1, restarted: true }); return; } this.falls++; this.placePlayer(this.cp >= 0 ? this.level.checkpoints[this.cp].spawn : this.level.spawn); this.resetDynamics(); this.emit("respawn", { reason, cp: this.cp }); } placePlayer(spawn) { const old = this.game.player; const p = createPlayer(spawn.pos, spawn.yaw); if (old) { // carry held-key edges so a key held through the respawn doesn't re-fire p.jumpHeldPrev = old.jumpHeldPrev; p.slideHeldPrev = old.slideHeldPrev; p.boostHeldPrev = old.boostHeldPrev; p.fwdPrev = old.fwdPrev; p.time = old.time; p.tick = old.tick; } this.game.player = p; } resetDynamics() { for (const d of this.dyn) { d.state = "idle"; d.t = 0; for (const part of d.parts) { part.box.min.y = part.y0; part.box.max.y = part.y1; } } } step(input) { this.updateDynamics(); const sim = stepGame(this.game, input); const p = this.game.player; const L = this.level; const speed = Math.hypot(p.vel.x, p.vel.z); if (this.state === "ready" && L.start && !inZone(L.start.zone, p.pos)) { this.state = "running"; this.emit("start"); } if (this.state === "running") { this.ticks++; if (speed > this.maxSpeed) this.maxSpeed = speed; if (this.ticks % GHOST_STRIDE === 0) this.trail.push(p.pos.x, p.pos.y, p.pos.z, p.yaw); const cps = L.checkpoints ?? []; for (let i = cps.length - 1; i > this.cp; i--) { if (!inZone(cps[i].zone, p.pos)) continue; this.cp = i; this.splits[i] = this.ticks; this.emit("checkpoint", { index: i, ticks: this.ticks, name: cps[i].name }); break; } if (L.finish && inZone(L.finish.zone, p.pos)) { this.state = "finished"; this.trail.push(p.pos.x, p.pos.y, p.pos.z, p.yaw); this.emit("finish", { ticks: this.ticks, splits: this.splits.slice(), falls: this.falls, maxSpeed: this.maxSpeed }); } } for (const h of L.hints ?? []) { const inside = inZone(h.zone, p.pos); if (inside && !this.hintIn.has(h)) { this.hintIn.add(h); this.emit("hint", { text: h.text }); } else if (!inside && this.hintIn.delete(h)) this.emit("hintEnd", { text: h.text }); } if (L.killY !== undefined && p.pos.y < L.killY) this.respawn("fall"); return sim; } updateDynamics() { const p = this.game.player; for (const d of this.dyn) { const { def } = d; const b = def.box; if (def.kind === "crumble") { if (d.state === "idle") { if (onTop(p, b)) { d.state = "armed"; d.t = 0; this.emit("crumbleArmed", { dyn: d }); } } else if (d.state === "armed") { d.t += DT; if (d.t >= def.delay) { d.state = "gone"; d.t = 0; for (const part of d.parts) { part.box.min.y = GONE_Y - (part.y1 - part.y0); part.box.max.y = GONE_Y; } this.emit("crumbleFell", { dyn: d }); } } else d.t += DT; } else if (def.kind === "shutter") { if (d.state === "idle") { if (inZone(def.trigger, p.pos)) { d.state = "closing"; d.t = 0; this.emit("shutterStart", { dyn: d }); } } else if (d.state === "closing") { d.t += DT; const k = Math.min(1, d.t / def.duration); b.min.y = d.y0 + (def.closedY - d.y0) * k; if (k >= 1) { d.state = "closed"; this.emit("shutterClosed", { dyn: d }); } } } } } } /** mm:ss.cc for a tick count. */ export function formatTicks(ticks) { const cs = Math.round((ticks * 100) / TICK_HZ); const m = Math.floor(cs / 6000); const s = Math.floor(cs / 100) % 60; const c = cs % 100; return `${m}:${String(s).padStart(2, "0")}.${String(c).padStart(2, "0")}`; }