Download src/game/runtime.js from townbox/edgerunners-cloudline-aaa: direct link, hf CLI and curl.
- Browser
- Download file 6.88 kB
-
https://huggingface.co/spaces/townbox/edgerunners-cloudline-aaa/resolve/main/src/game/runtime.js
- Command line
-
hf download hf://spaces/townbox/edgerunners-cloudline-aaa/src/game/runtime.js
-
curl -L -o runtime.js https://huggingface.co/spaces/townbox/edgerunners-cloudline-aaa/resolve/main/src/game/runtime.js
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")}`; | |
| } | |