Mike0021's picture
CLOUDLINE: new renderer and city, remade sounds, social thumbnail
4a0989d verified
Raw History Blame Contribute Delete
6.88 kB
// 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")}`;
}