clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M10 complete: physics marble live in endless mode + checkpoints/respawn

Dami committed 369c9cb
The endless marble is a real Box3D body by default (?phys=0 = kinematic
fallback): per-segment static proxies stream with the render window, the
shared assist law drives it (client + tests use the same rail-lookahead
slope sizing - a climb must be powered for the ramp AHEAD or the marble
cycles at its base), real rolling via quaternion readback.

Respawn system: checkpoints are segment entries (they move forward with
the stream); falls below the co-live window respawn at the checkpoint;
a stall net catches wedges (no rail progress for 6s); repeated deaths at
one checkpoint ESCALATE to the next segment - forward progress is
guaranteed over any geometry (user rule). Deaths surface in HUD + CUA.

Live-verified: 6 sim-min drive - real falls into drop-stack cups, wedge
zone at cua-seed-1 seg 4-5 escalated past automatically (known scenario;
M11's speed envelope owns the momentum fix). 65 tests green.
commit
369c9cb17b87b05ac8298cc1a29fa1d509bdce4a
parent
b21700e2509a49e989f2201bdf32087992c0b584
author
Dami <dami.etoile@gmail.com>
4 files changed, +262 -25
Msrc/client/endless.ts255++++++++++++++++++++++++++++--
Msrc/client/main.ts17++-
Msrc/client/physics/engine.ts8+-
Mtests/physics_test.ts7+-
M src/client/endless.ts +235 -20
@@ -22,6 +22,13 @@ import { type Cameras, type CamMode, createCameras } from "./camera.ts";
2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";2323 import { TRACK_SCALE } from "../gen/endless/scale.ts";2424 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";25+import {26+ assistForce,27+ PhysicsEngine,28+ type PhysicsModule,29+} from "./physics/engine.ts";30+import { channelProxies, islandProxies } from "../gen/endless/proxies.ts";31+import { segLength, segPoint } from "../gen/geom.ts";2532 import { buildIslandInstanced, type InstancedBuild } from "./islandRender.ts";2633 import { Atmosphere, drawZone } from "./atmosphere.ts";2734 import type { DecorCatalog } from "../gen/decor.ts";
@@ -54,6 +61,8 @@ interface BuiltSegment {
5461 entry: { x: number; y: number; z: number; dir: number };5562 /** Per-build GPU state (island instance buffers + cloned materials). */5663 disposables: InstancedBuild[];64+ /** Lowest channel base (world y) — physics fall detection. */65+ lowY: number;5766 }5867 5968 interface UiRefs {
@@ -68,6 +77,7 @@ export function bootEndless(
6877 assets: Assets,6978 catalog: DecorCatalog,7079 ui: UiRefs,80+ physics: PhysicsModule | null = null,7181 ): void {7282 // The void: stars pinned to the camera; sky/fog/light/weather belong to7383 // the per-seed Atmosphere (a generative family of moods, drawn per zone).
@@ -97,7 +107,16 @@ export function bootEndless(
97107 initSeed((seed) => {98108 session?.dispose();99109 try {100- session = new Session(seed, ctx, assets, catalog, cams, ui, stars);110+ session = new Session(111+ seed,112+ ctx,113+ assets,114+ catalog,115+ cams,116+ ui,117+ stars,118+ physics,119+ );101120 } catch (err) {102121 console.error(err);103122 if (ui.statsEl) {
@@ -148,6 +167,8 @@ export function bootEndless(
148167 exitingSegments: session.exitingCount,149168 atmosphere: session.atmosphere.name,150169 weather: session.atmosphere.weatherKind,170+ physics: session.physicsActive,171+ deaths: session.deaths,151172 // Instancing gate: island pieces share draw calls now.152173 drawCalls: ctx.renderer.info.render.calls,153174 }
@@ -183,6 +204,22 @@ class Session {
183204 private statsClock = 0;184205 185206 readonly atmosphere: Atmosphere;207+ /** Box3D driver (null → kinematic rail rider, ?phys=0). */208+ private engine: PhysicsEngine | null = null;209+ private readonly rails = new Map<210+ number,211+ { pts: number[][]; tans: number[][]; s: number[] }212+ >();213+ private nearest: { seg: number; idx: number } = { seg: 0, idx: 0 };214+ deaths = 0;215+ /** Stall net: no rail progress for a while → respawn. Progress must216+ * move forward NO MATTER WHAT (user rule) — falls and wedges alike. */217+ private stallClock = 0;218+ private lastProgressS = 0;219+ /** Escalation: repeated deaths at one checkpoint skip forward past the220+ * problem geometry (each such spot is a future named scenario). */221+ private respawnsHere = 0;222+ private lastRespawnSeg = -1;186223 private zone = 0;187224 private focus: { center: THREE.Vector3; radius: number } | null = null;188225
@@ -200,8 +237,10 @@ class Session {
200237 private readonly cams: Cameras,201238 private readonly ui: UiRefs,202239 stars: THREE.Points,240+ physics: PhysicsModule | null = null,203241 ) {204242 this.seed = seed;243+ if (physics) this.engine = new PhysicsEngine(physics);205244 this.world = new EndlessWorld(seed, catalog);206245 assets.applyPalette(this.world.style.paletteIndex);207246 this.root.name = `endless:${seed}`;
@@ -230,6 +269,14 @@ class Session {
230269 const first = this.built.get(0)!;231270 first.curve.getPointAt(0, this.point);232271 this.marbleObj.position.copy(this.point);272+ if (this.engine) {273+ const rail = this.rails.get(0)!;274+ const t = rail.tans[0];275+ this.engine.spawnMarble(276+ [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],277+ [t[0] * 2.2, 0, t[2] * 2.2],278+ );279+ }233280 234281 this.cameraProxy = {235282 obj: this.marbleObj,
@@ -259,6 +306,10 @@ class Session {
259306 return this.exiting.length;260307 }261308 309+ get physicsActive(): boolean {310+ return this.engine !== null;311+ }312+262313 /** World point at global arc length s (clamped to the built window). */263314 samplePoint(s: number, out: THREE.Vector3): boolean {264315 const seg = this.segmentAt(s);
@@ -310,6 +361,37 @@ class Session {
310361 311362 const curve = buildOpenCurve(data.path);312363 const length = curve.getLength();364+ if (this.engine) {365+ this.engine.addSegment(i, [366+ ...channelProxies(data.path),367+ ...data.islands.flatMap((isle) => islandProxies(isle.placements)),368+ ]);369+ // Dense rail (points/tangents/global s) for nearest-point progress370+ // and the assist force — same sampling law as the headless tests.371+ const rail = {372+ pts: [] as number[][],373+ tans: [] as number[][],374+ s: [] as number[],375+ };376+ let acc = 0;377+ for (const ls of data.path.segments) {378+ for (const part of ls.curve) {379+ const plen = segLength(part);380+ const steps = Math.max(1, Math.ceil(plen / 0.5));381+ for (let j = 0; j < steps; j++) {382+ const a = segPoint(part, j / steps);383+ const b = segPoint(part, Math.min(1, (j + 1) / steps));384+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];385+ const l = Math.hypot(d[0], d[1], d[2]) || 1;386+ rail.pts.push([a[0], a[1], a[2]]);387+ rail.tans.push([d[0] / l, d[1] / l, d[2] / l]);388+ rail.s.push(acc);389+ acc += l;390+ }391+ }392+ }393+ this.rails.set(i, rail);394+ }313395 const prev = this.sMeta.get(i - 1);314396 const startS = i === 0 ? 0 : (prev ? prev.start + prev.len : 0);315397 if (i > 0 && !prev) {
@@ -332,6 +414,7 @@ class Session {
332414 dir: data.plan.entry.dir,333415 },334416 disposables,417+ lowY: Math.min(...data.plan.cells.map((c) => c.baseY)) * 2,335418 };336419 this.built.set(i, seg);337420 this.islandsSeen += data.islands.length;
@@ -359,6 +442,8 @@ class Session {
359442 private disposeSegment(seg: BuiltSegment): void {360443 this.built.delete(seg.index);361444 this.spawning.delete(seg.index);445+ this.engine?.removeSegment(seg.index);446+ this.rails.delete(seg.index);362447 // Sink away rather than pop out; the group leaves the scene when the363448 // exit wave finishes. (Occupancy claims release much later — the364449 // generator's keep window — so nothing can build into a sinking spot.)
@@ -413,22 +498,8 @@ class Session {
413498 414499 update(dt: number): void {415500 if (dt <= 0) return;416- const seg = this.segmentAt(this.marbleS);417- if (!seg) return;418-419- const u = Math.min(420- 1,421- Math.max(0, (this.marbleS - seg.startS) / seg.length),422- );423- seg.curve.getTangentAt(u, this.tangent);424-425- // Gravity-flavoured speed, same law as the loop marble. The void line426- // trends downhill, so the marble keeps pace without fans.427- this.speed +=428- (-this.tangent.y * GRAVITY - DRAG * (this.speed - BASE_SPEED)) *429- dt;430- this.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, this.speed));431- this.marbleS += this.speed * dt;501+ if (this.engine) this.physicsMove(dt);502+ else this.kinematicMove(dt);432503 433504 const next = this.segmentAt(this.marbleS);434505 if (next && next.index !== this.currentIndex) {
@@ -443,18 +514,159 @@ class Session {
443514 }444515 this.ensure();445516 this.atmosphere.update(dt, this.focus);517+ this.finishFrame(dt);518+ }446519 520+ /** Kinematic rail rider (?phys=0): the pre-M10 driver. */521+ private kinematicMove(dt: number): void {522+ const seg = this.segmentAt(this.marbleS);523+ if (!seg) return;524+ const u = Math.min(525+ 1,526+ Math.max(0, (this.marbleS - seg.startS) / seg.length),527+ );528+ seg.curve.getTangentAt(u, this.tangent);529+ this.speed +=530+ (-this.tangent.y * GRAVITY - DRAG * (this.speed - BASE_SPEED)) *531+ dt;532+ this.speed = Math.min(MAX_SPEED, Math.max(MIN_SPEED, this.speed));533+ this.marbleS += this.speed * dt;447534 if (!this.samplePoint(this.marbleS, this.point)) return;448535 this.marbleObj.position.copy(this.point);449-450- // Roll about the horizontal axis perpendicular to travel.451536 this.axis.copy(this.up).cross(this.tangent);452537 if (this.axis.lengthSq() > 1e-8) {453538 this.axis.normalize();454539 this.spin.setFromAxisAngle(this.axis, (this.speed * dt) / this.radius);455540 this.pivot.quaternion.premultiply(this.spin);456541 }542+ }543+544+ /** Box3D driver: real rolling, real falls, checkpoint respawn. */545+ private physicsMove(dt: number): void {546+ const engine = this.engine!;547+ engine.step(dt, () => {548+ const st = engine.state();549+ this.updateNearest(st.pos);550+ const rail = this.rails.get(this.nearest.seg);551+ if (!rail) return;552+ const t = rail.tans[this.nearest.idx] as [number, number, number];553+ let climbTy = t[1];554+ for (let k = 1; k <= 6; k++) {555+ const look = rail.tans[this.nearest.idx + k];556+ if (look) climbTy = Math.max(climbTy, look[1]);557+ }558+ const f = assistForce(st.vel, t, engine.mass, climbTy);559+ if (f) engine.force(f[0], f[1], f[2]);560+ });561+ const st = engine.state();562+ this.marbleObj.position.set(st.pos[0], st.pos[1], st.pos[2]);563+ this.pivot.quaternion.set(st.quat[0], st.quat[1], st.quat[2], st.quat[3]);564+ this.speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);565+ const seg = this.built.get(this.nearest.seg);566+ const rail = this.rails.get(this.nearest.seg);567+ if (seg && rail) {568+ this.marbleS = Math.max(569+ this.marbleS,570+ seg.startS + rail.s[this.nearest.idx],571+ );572+ }573+574+ // Fall detection: below every co-live channel by a wide margin →575+ // respawn at the current segment's entry (checkpoints move forward576+ // with the stream, so respawn can never send the marble backward past577+ // the render window).578+ let lowest = Infinity;579+ for (const b of this.built.values()) {580+ lowest = Math.min(lowest, b.lowY);581+ }582+ if (st.pos[1] < lowest - 10) {583+ this.respawn();584+ return;585+ }586+ // Stall net: wedged/parked marbles respawn too — progress always587+ // moves forward. (Every stall location is a future named scenario.)588+ if (this.marbleS > this.lastProgressS + 0.4) {589+ this.lastProgressS = this.marbleS;590+ this.stallClock = 0;591+ } else {592+ this.stallClock += dt;593+ if (this.stallClock > 6) this.respawn();594+ }595+ }596+597+ /** Nearest rail station to the marble, advancing across segments. */598+ private updateNearest(pos: readonly [number, number, number]): void {599+ let { seg, idx } = this.nearest;600+ if (!this.rails.has(seg)) {601+ seg = Math.min(...this.rails.keys());602+ idx = 0;603+ }604+ let bestSeg = seg;605+ let bestIdx = idx;606+ let bestD = Infinity;607+ const consider = (sIdx: number, from: number, to: number) => {608+ const rail = this.rails.get(sIdx);609+ if (!rail) return;610+ const lo = Math.max(0, from);611+ const hi = Math.min(rail.pts.length - 1, to);612+ for (let i = lo; i <= hi; i++) {613+ const r = rail.pts[i];614+ const d = (r[0] - pos[0]) ** 2 + (r[1] - pos[1]) ** 2 +615+ (r[2] - pos[2]) ** 2;616+ if (d < bestD) {617+ bestD = d;618+ bestSeg = sIdx;619+ bestIdx = i;620+ }621+ }622+ };623+ consider(seg, idx - 25, idx + 50);624+ consider(seg + 1, 0, 40); // the boundary is always in reach625+ this.nearest = { seg: bestSeg, idx: bestIdx };626+ }627+628+ /** Respawn at the last checkpoint (the current segment's entry), and629+ * after repeated deaths there, at the NEXT segment — forward progress is630+ * guaranteed even over broken geometry. */631+ private respawn(): void {632+ this.deaths++;633+ this.stallClock = 0;634+ this.lastProgressS = 0;635+ let target = this.currentIndex;636+ if (this.lastRespawnSeg === target) {637+ this.respawnsHere++;638+ if (this.respawnsHere >= 2) {639+ target = this.currentIndex + 1;640+ console.warn(641+ `[endless] skipping past segment ${this.currentIndex} after ` +642+ `${this.respawnsHere} failed respawns (seed ${this.seed})`,643+ );644+ }645+ } else {646+ this.respawnsHere = 0;647+ this.lastRespawnSeg = target;648+ }649+ const cp = this.built.get(target) ??650+ this.built.get(this.currentIndex) ??651+ this.built.get(Math.min(...this.built.keys()));652+ if (!cp || !this.engine) return;653+ if (cp.index !== this.currentIndex) {654+ this.currentIndex = cp.index;655+ this.lastRespawnSeg = cp.index;656+ this.respawnsHere = 0;657+ }658+ const rail = this.rails.get(cp.index);659+ if (!rail) return;660+ const t = rail.tans[0];661+ this.engine.spawnMarble(662+ [rail.pts[0][0], rail.pts[0][1] + 0.4, rail.pts[0][2]],663+ [t[0] * 2.2, 0, t[2] * 2.2],664+ );665+ this.nearest = { seg: cp.index, idx: 0 };666+ this.marbleS = cp.startS;667+ }457668 669+ private finishFrame(dt: number): void {458670 for (const built of this.built.values()) {459671 for (const sp of built.spinners) {460672 sp.obj.rotateOnAxis(sp.axis, sp.radPerSec * dt);
@@ -524,10 +736,13 @@ class Session {
524736 if (!this.ui.statsEl) return;525737 const y = this.marbleObj.position.y;526738 const weather = this.atmosphere.weatherKind;739+ const phys = this.engine740+ ? ` • physics${this.deaths > 0 ? ` (${this.deaths} falls)` : ""}`741+ : "";527742 this.ui.statsEl.textContent = `endless • ${this.world.style.theme} • ` +528743 `${this.atmosphere.name}${weather === "none" ? "" : `+${weather}`} • ` +529744 `${this.marbleS.toFixed(0)}u travelled • depth ${y.toFixed(1)} • ` +530- `${this.islandsSeen} islands • seg ${this.currentIndex} • ` +745+ `${this.islandsSeen} islands • seg ${this.currentIndex}${phys} • ` +531746 `${genVersion()}`;532747 }533748 }
M src/client/main.ts +15 -2
M src/client/physics/engine.ts +6 -2
M tests/physics_test.ts +6 -1