Keep endless orbit attached to the streamed subject, fade tunnel shells around the lens, and scope camera occlusion to authored track pieces. Make the title menu and gallery usable on small screens, preserve exact seeds and native input behavior, and hide inactive HUD chrome. Add constant-cost boost trails, dispose marble/WASM resources across regeneration, and cover trail and physics teardown regressions. Verified with lint, type-check, build, and 214 passing tests.
- commit
- 7e7a85f65097b1cb7e73fc3b5b18448127938bd1
- parent
- 62919dbc41c478b3801086a082584cd42b8503a4
- author
- Dami <dami.etoile@gmail.com>
| M | .gitignore | 2 | + |
| M | progress.md | 33 | ++++++- |
| M | src/ | 33 | +++++- |
| M | src/ | 5 | + |
| M | src/ | 168 | +++++++++++++++++++----------- |
| M | src/ | 1 | + |
| M | src/ | 137 | +++++------------------- |
| M | src/ | 19 | +++- |
| M | src/ | 33 | ++++++- |
| M | src/ | 123 | ++++++++++++++++------ |
| M | src/ | 21 | ++++ |
| M | src/ | 23 | ++++ |
| M | src/ | 53 | +++++++++- |
| M | src/ | 4 | +- |
| M | src/ | 3 | + |
| M | src/ | 55 | ++++++++-- |
| M | src/ | 5 | +- |
| M | static/ | 11 | ++ |
| M | tests/ | 2 | +- |
| M | tests/ | 27 | +++++ |
| A | tests/ | 51 | +++++++++ |
| M | tools/ | 4 | +- |
@@ -1,3 +1,5 @@
1+Original prompt: Run a roughly 90-minute bug hunt with up to eight concurrent subagents using CUA, covering bugs, visual issues, weak UI text/layout areas, fixes, and retesting.2+13 # Work ahead24 35 This file is exclusively the backlog. Completed work lives in `git log`@@ -23,7 +25,36 @@ Collision is MEASURED against the kit (audit round 2026-08-09:
2325 docs/collision-mesh-audit.md — shared proxy builder, open bores,2426 authored drop cups, additive wide shift, rim-height profile, 4-way2527 cross composite, quantitative metrics CI).26-210 tests green, `deno task check` clean.28+214 tests green, `deno task check` clean.29+30+## Active 2026-08-26 bug hunt31+32+- Verified/fixed: responsive title-menu width/height, native Advanced33+ keyboard handling, accessible menu buttons/labels, disabled loop-only34+ controls, exact whitespace-preserving seed URLs, long-seed HUD ellipsis,35+ and menu-only HUD hiding.36+- Verified/fixed: endless orbit now carries its pivot with the streamed37+ subject instead of watching disposed segments until only stars remain.38+- Verified/fixed: same-page seed changes now destroy the Box3D world, hero,39+ statics, mesh handles, and WASM readback buffers; the native shim source40+ and export were rebuilt locally and repeated disposal has a regression test.41+- Verified/fixed: injected marble-core geometries and shared scar resources now42+ dispose with their session. Alternating the same two seeds repeatedly plateaus43+ at 261/276 endless geometries and 269 finite geometries instead of climbing.44+- Verified/fixed: the motion ribbon no longer twists or keeps a false streak45+ across teleports/leader changes. Boost is now a separate short orange-red46+ additive trail (in loop and endless modes) instead of a marble color filter;47+ it fades after flameout and remains constant-cost at pack scale.48+- Verified/fixed: the asset gallery and title menu fit 320–480px phone widths,49+ short-height Advanced options remain scrollable, focus rings are visible,50+ and keyboard input no longer starts the game or loses a text caret.51+- Browser retests passed: exact whitespace/Unicode seeds, 15 finite regenerations,52+ 12 endless regenerations at 301 racers, finite/endless boost encounters, orbit53+ streaming, responsive menu/gallery, and clean console scans.54+- Residual S2: some generated slabs can still obscure 50–80% of the follow/orbit55+ view at isolated `camera-soak-a` checkpoints. Orbit no longer loses the world,56+ and tunnel shells fade around the lens, but generic occlusion needs a broader57+ camera/composition treatment. A 9999-racer cold start also remains ~21s.2758 2859 ## Presentation polish2960 @@ -9,6 +9,7 @@ import type { SceneCtx } from "./scene.ts";
99 import type { Marble } from "./marble.ts";1010 1111 export type CamMode = "orbit" | "follow";12+type CameraSubject = Pick<Marble, "obj" | "s" | "speed" | "radius" | "update">;1213 1314 export interface Cameras {1415 readonly mode: CamMode;@@ -18,7 +19,7 @@ export interface Cameras {
1819 /** Point at a new curve/marble pair after a regeneration. */1920 retarget(2021 curve: THREE.Curve<THREE.Vector3> | null,21- marble: Marble | null,22+ marble: CameraSubject | null,2223 ): void;2324 /**2425 * Endless mode: follow via an arc-length sampler instead of one closed@@ -27,7 +28,7 @@ export interface Cameras {
2728 */2829 retargetPath(2930 getPointAtS: ((s: number, out: THREE.Vector3) => boolean) | null,30- marble: Marble | null,31+ marble: CameraSubject | null,3132 ): void;3233 /** Re-aim orbit at a new bounding box. */3334 frame(box: THREE.Box3): void;@@ -122,7 +123,7 @@ export interface CameraOptions {
122123 export function createCameras(123124 ctx: SceneCtx,124125 curve: THREE.Curve<THREE.Vector3> | null,125- marble: Marble | null,126+ marble: CameraSubject | null,126127 opts: CameraOptions = {},127128 ): Cameras {128129 const trail = opts.trail ?? TRAIL;@@ -145,6 +146,8 @@ export function createCameras(
145146 146147 const desired = new THREE.Vector3();147148 const lookTarget = new THREE.Vector3();149+ const orbitTarget = new THREE.Vector3();150+ const orbitShift = new THREE.Vector3();148151 // Follow-mode user adjust state (wheel zoom, drag orbit).149152 let zoomUser = 1;150153 let yawUser = 0;@@ -250,7 +253,13 @@ export function createCameras(
250253 const center = box.getCenter(new THREE.Vector3());251254 const size = box.getSize(new THREE.Vector3());252255 const radius = Math.max(size.x, size.y, size.z) * 0.5 || 8;253- const fov = THREE.MathUtils.degToRad(camera.fov);256+ const verticalFov = THREE.MathUtils.degToRad(camera.fov);257+ const horizontalFov = 2 * Math.atan(258+ Math.tan(verticalFov / 2) * camera.aspect,259+ );260+ // Portrait windows have a much narrower horizontal frustum. Framing261+ // against vertical FOV alone clips wide pieces and their labels.262+ const fov = Math.min(verticalFov, horizontalFov);254263 const dist = (radius / Math.sin(fov / 2)) * 1.15;255264 camera.far = Math.max(500, dist * 4);256265 camera.updateProjectionMatrix();@@ -261,7 +270,7 @@ export function createCameras(
261270 // yanked the follow cam to the "default angle" and let the lerp262271 // float it back (the user-reported jump; measured as a ~60-unit263272 // teleport with a 0.39/100ms decay tail at each build event).264- if (mode === "orbit") {273+ if (mode === "orbit" && !pathSampler) {265274 controls.target.copy(center);266275 camera.position.copy(center).add(267276 new THREE.Vector3(0.75, 0.55, 0.75).normalize().multiplyScalar(dist),@@ -292,7 +301,13 @@ export function createCameras(
292301 mode = next;293302 controls.enabled = mode === "orbit";294303 if (mode === "orbit") {295- if (framedBox) {304+ // Endless orbit is a chase-orbit: preserve the current boom, but305+ // move its pivot with the streamed subject. A fixed world pivot is306+ // eventually culled with the old segments, leaving only starfield.307+ if (pathSampler && activeMarble) {308+ controls.target.copy(activeMarble.obj.position);309+ controls.update();310+ } else if (framedBox) {296311 api.frame(framedBox);297312 } else {298313 // No box yet — anchor the orbit pivot ahead of the current view.@@ -312,6 +327,12 @@ export function createCameras(
312327 313328 update(dt: number) {314329 if (mode === "orbit") {330+ if (pathSampler && activeMarble) {331+ orbitTarget.copy(activeMarble.obj.position);332+ orbitShift.copy(orbitTarget).sub(controls.target);333+ camera.position.add(orbitShift);334+ controls.target.copy(orbitTarget);335+ }315336 controls.update();316337 return;317338 }@@ -38,6 +38,11 @@ export function installCua(ctx: SceneCtx, getState: () => CuaState): void {
3838 };3939 4040 addEventListener("keydown", (e) => {41+ const target = e.target as HTMLElement | null;42+ if (43+ target?.isContentEditable ||44+ target?.closest("input, textarea, select, button, summary")45+ ) return;4146 if (e.code === "KeyB") history.back();4247 else if (e.code === "KeyA") history.forward();4348 });@@ -40,8 +40,6 @@ import {
4040 stationForce,4141 stationGravityDelta,4242 stationRestitution,43- stationWorldPoint,44- stationWorldTan,4543 } from "./physics/rail.ts";4644 import {4745 autopilotForce,@@ -66,6 +64,7 @@ import { rngFamily } from "../gen/rng.ts";
6664 import {6765 addMarbleCore,6866 applyTunnelVariants,67+ disposeMarbleOwnedVisuals,6968 fresnelShellMaterial,7069 glassShellMaterial,7170 marbleEnv,@@ -152,6 +151,10 @@ interface BuiltSegment {
152151 frame?: SegmentFrame;153152 /** Exit-framed half of a twist segment (placements past the run). */154153 postGroup?: THREE.Group;154+ /** Authored channel pieces that can swallow the follow camera. */155+ cameraOccluders: THREE.Object3D[];156+ /** Glass/wire tunnel shells that fade only while containing the lens. */157+ cameraFadeMeshes: THREE.Mesh[];155158 }156159 157160 interface UiRefs {@@ -182,7 +185,7 @@ export function bootEndless(
182185 const cams = createCameras(ctx, null, null, {183186 trail: 7,184187 height: 3.1,185- occluders: () => session?.islandMeshes() ?? [],188+ occluders: () => session?.cameraOccluders() ?? [],186189 // Gravity reorientation (M16): the lens rolls with the subject's187190 // local gravity frame (eased inside camera.ts).188191 up: () => session?.cameraUp() ?? [0, 1, 0],@@ -240,6 +243,7 @@ export function bootEndless(
240243 ctx.onFrame.add((dt) => {241244 session?.update(dt);242245 cams.update(dt);246+ session?.fadeCameraTunnels(dt);243247 });244248 245249 initSeed((seed) => {@@ -486,6 +490,7 @@ class Session {
486490 readonly marbleObj: THREE.Group;487491 488492 private readonly root = new THREE.Group();493+ private readonly cameraLocal = new THREE.Vector3();489494 private readonly built = new Map<number, BuiltSegment>();490495 /** Segments assembling in (index → wave) and sinking out. */491496 private readonly spawning = new Map<number, SpawnAnim>();@@ -549,12 +554,6 @@ class Session {
549554 private readonly instPos = new THREE.Vector3();550555 private readonly instQuat = new THREE.Quaternion();551556 private readonly instScale = new THREE.Vector3(1, 1, 1);552- private readonly instGlow = new THREE.Color();553- /** Boost glow runs HOT, not blue: it lifts a racer toward white in its554- * own hue. A fixed blue repainted every boosting marble the same colour555- * (user: "the outer shell gets a blue tint"), erasing identity exactly556- * when the pack is most worth reading. */557- private readonly glowHot = new THREE.Color(0xffffff);558557 private readonly rails = new Map<number, Rail>();559558 /** Braid alt-lane rails (M17): built alongside `rails` for every560559 * segment carrying an alt branch; per-racer lane choice picks between@@ -658,6 +657,14 @@ class Session {
658657 private focus: { center: THREE.Vector3; radius: number } | null = null;659658 660659 private readonly trail = createTrail();660+ private readonly boostTrail = createTrail(0xff5a24, {661+ maxPoints: 28,662+ headAlpha: 1,663+ additive: true,664+ renderOrder: 6,665+ });666+ private boostTrailFade = 0;667+ private boostTrailActive = false;661668 private readonly trailPoint = new THREE.Vector3();662669 private readonly point = new THREE.Vector3();663670 private readonly tangent = new THREE.Vector3();@@ -696,9 +703,9 @@ class Session {
696703 const peersParam = new URLSearchParams(location.search).get("peers");697704 const peerCount = peersParam === null698705 ? undefined699- : Math.max(0, Math.min(9999, Number.parseInt(peersParam, 10) || 0));706+ : Math.max(0, Math.min(9998, Number.parseInt(peersParam, 10) || 0));700707 const realRace = this.engine !== null && this.useAutopilot;701- this.realPeerCount = realRace ? Math.min(9999, peerCount ?? 5) : 0;708+ this.realPeerCount = realRace ? Math.min(9998, peerCount ?? 5) : 0;702709 this.ringPeerCount = realRace ? 0 : peerCount;703710 }704711 @@ -774,9 +781,12 @@ class Session {
774781 this.pivot.add(mesh);775782 }776783 this.trail.setWidth(this.radius * 0.6);784+ this.boostTrail.setWidth(this.radius * 1.5);785+ this.boostTrail.obj.name = "boost-trail";777786 this.followedPivot = this.pivot;778787 ctx.scene.add(this.marbleObj);779788 ctx.scene.add(this.trail.obj);789+ ctx.scene.add(this.boostTrail.obj);780790 // Decorative ring only outside physics-autopilot mode (real racers781791 // replace it there) — sizing computed above, before the hero mesh.782792 this.peerRing = new PeerRing(seed, assets, ctx.scene, this.ringPeerCount);@@ -1175,12 +1185,13 @@ class Session {
11751185 return this.camUp;11761186 }11771187 1178- /** Island MASS meshes of the built window (camera occluders) —1179- * vegetation/decor never pulls the boom in (a crossing tree frond1180- * read as a camera jump). */1181- islandMeshes(): THREE.Object3D[] {1188+ /** Solid channel pieces plus island MASS meshes of the built window.1189+ * Supports/vegetation/decor stay out: a crossing tree frond or thin1190+ * scaffold should not yank the follow boom. */1191+ cameraOccluders(): THREE.Object3D[] {11821192 const out: THREE.Object3D[] = [];11831193 for (const seg of this.built.values()) {1194+ out.push(...seg.cameraOccluders);11841195 for (const d of seg.disposables) {11851196 for (const m of d.meshes) {11861197 if (m.userData.occluder) out.push(m);@@ -1190,6 +1201,29 @@ class Session {
11901201 return out;11911202 }11921203 1204+ /** A tunnel is useful context until its shell contains the lens. Fade1205+ * only that local shell; the track, marbles and next opening stay visible. */1206+ fadeCameraTunnels(dt: number): void {1207+ const a = 1 - Math.exp(-12 * Math.max(dt, 1e-4));1208+ for (const seg of this.built.values()) {1209+ for (const mesh of seg.cameraFadeMeshes) {1210+ const box = mesh.geometry.boundingBox;1211+ if (!box) continue;1212+ this.cameraLocal.copy(this.ctx.camera.position);1213+ mesh.worldToLocal(this.cameraLocal);1214+ const inside = box.containsPoint(this.cameraLocal);1215+ const materials = Array.isArray(mesh.material)1216+ ? mesh.material1217+ : [mesh.material];1218+ for (const material of materials) {1219+ const base = mesh.userData.cameraBaseOpacity as number;1220+ const target = inside ? base * 0.06 : base;1221+ material.opacity += (target - material.opacity) * a;1222+ }1223+ }1224+ }1225+ }1226+11931227 /** Arc-length range currently sampleable (the built window). */11941228 private builtBand(): [number, number] {11951229 let lo = Infinity;@@ -1242,6 +1276,8 @@ class Session {
12421276 : group;12431277 const spinners: Spinner[] = [];12441278 const missing = new Set<string>();1279+ const cameraOccluders: THREE.Object3D[] = [];1280+ const cameraFadeMeshes: THREE.Mesh[] = [];12451281 const pipeColorOf = (p: Placement) =>12461282 // Zone surfaces READ as their own pipe color regardless of12471283 // chapter color (icy = ice-pale, gravity = violet-charged).@@ -1310,9 +1346,13 @@ class Session {
13101346 }13111347 // Tunnel render variants (glass / wire) — position-hashed, visual13121348 // only, deterministic per seed.1313- disposables.push(applyTunnelVariants(group, this.ctx.renderer));1349+ const entryTunnels = applyTunnelVariants(group, this.ctx.renderer);1350+ disposables.push(entryTunnels);1351+ cameraFadeMeshes.push(...entryTunnels.cameraFadeMeshes);13141352 if (postGroup) {1315- disposables.push(applyTunnelVariants(postGroup, this.ctx.renderer));1353+ const exitTunnels = applyTunnelVariants(postGroup, this.ctx.renderer);1354+ disposables.push(exitTunnels);1355+ cameraFadeMeshes.push(...exitTunnels.cameraFadeMeshes);13161356 }13171357 for (const id of missing) {13181358 console.warn(`[endless] unknown pieceId "${id}" — skipped`);@@ -1392,6 +1432,8 @@ class Session {
13921432 chapter !== undefined ? this.chapterColor(chapter) : null,13931433 );13941434 if (mesh) {1435+ mesh.userData.cameraOccluder = true;1436+ cameraOccluders.push(mesh);13951437 this.root.add(mesh);13961438 disposables.push({13971439 meshes: [],@@ -1430,9 +1472,18 @@ class Session {
14301472 disposables,14311473 floats,14321474 lowY: Math.min(...data.plan.cells.map((c) => c.baseY)) * 2,1475+ cameraOccluders,1476+ cameraFadeMeshes,14331477 ...(fr ? { frame: fr } : {}),14341478 ...(postGroup ? { postGroup } : {}),14351479 };1480+ for (const g of postGroup ? [group, postGroup] : [group]) {1481+ for (const child of g.children) {1482+ if (child.userData.placementRole === "track") {1483+ cameraOccluders.push(child);1484+ }1485+ }1486+ }14361487 this.built.set(i, seg);14371488 this.islandsSeen += data.islands.length;14381489 // Streamed-in segments assemble as a wave flowing along the travel@@ -2304,45 +2355,10 @@ class Session {
23042355 r.s = Math.max(r.s, seg.startS + rail.s[r.nearest.idx]);23052356 }23062357 2307- // Boost glow: ignition/flameout toggles the marble's own emissive2308- // (instanced peers brighten their instance color); the audio cue2309- // fires for the camera subject only.2358+ // Boost visual is a separate hot trail on the camera subject. Keep2359+ // the marble's authored materials intact (no full-sphere color filter).23102360 if (r.boost.boosting !== r.wasBoosting) {23112361 r.wasBoosting = r.boost.boosting;2312- if (r.obj) {2313- for (const m of r.materials) {2314- const std = m as THREE.MeshStandardMaterial;2315- if (!std.emissive) continue;2316- // The glass SHELL never emits. A transmission material adds2317- // emissive straight on top of the refracted core instead of2318- // blending with it, so any glow here floods the whole sphere2319- // with one flat colour and hides the marble inside.2320- if ((std as THREE.MeshPhysicalMaterial).transmission > 0) continue;2321- if (r.boost.boosting) {2322- // Glow in the racer's OWN colour, pushed toward white — the2323- // marble lights up from inside the glass and stays itself.2324- std.emissive.copy(std.color).lerp(this.glowHot, 0.3);2325- std.emissiveIntensity = 0.55;2326- } else {2327- if (r.baseEmissive) std.emissive.copy(r.baseEmissive);2328- else std.emissive.setHex(0x000000);2329- std.emissiveIntensity = 1;2330- }2331- }2332- } else if (this.peerMesh) {2333- const base = this.peerColors[r.idx - 1];2334- this.peerMesh.setColorAt(2335- r.idx - 1,2336- // Same law at pack scale: brighten the peer's own colour rather2337- // than lerp it 3/4 of the way to a shared blue.2338- r.boost.boosting2339- ? this.instGlow.copy(base).lerp(this.glowHot, 0.35)2340- : base,2341- );2342- if (this.peerMesh.instanceColor) {2343- this.peerMesh.instanceColor.needsUpdate = true;2344- }2345- }23462362 if (r.boost.boosting && r.idx === this.followedIdx) {23472363 this.audio.cue("boost");23482364 }@@ -2442,6 +2458,7 @@ class Session {
24422458 for (const r of this.racers) if (r.s > top.s) top = r;24432459 followed = top.s > current.s + 0.75 ? top : current;24442460 }2461+ const subjectChanged = this.followedIdx !== followed.idx;24452462 this.followedIdx = followed.idx;24462463 // Follow-cam local up (M16): the lens rolls with the subject's24472464 // gravity frame. Eased HERE as well as in camera.ts, and a missing@@ -2496,9 +2513,12 @@ class Session {
24962513 this.cameraProxy.s = this.camS;24972514 this.cameraProxy.speed = this.speed;24982515 }2499- if (this.followedPivot !== fPivot) {2516+ if (subjectChanged || this.followedPivot !== fPivot) {25002517 this.followedPivot = fPivot;25012518 this.trail.reset();2519+ this.boostTrail.reset();2520+ this.boostTrailFade = 0;2521+ this.boostTrailActive = false;25022522 }25032523 }25042524 @@ -2578,7 +2598,9 @@ class Session {
25782598 r.temperClock = 0;25792599 // Pile-up telemetry (M20a): trailing-window respawn counter.25802600 this.respawnTimes.push(this.windT);2581- while (this.respawnTimes.length > 0 && this.respawnTimes[0] < this.windT - 4) {2601+ while (2602+ this.respawnTimes.length > 0 && this.respawnTimes[0] < this.windT - 42603+ ) {25822604 this.respawnTimes.shift();25832605 }25842606 const seg = this.segmentAt(at);@@ -2981,10 +3003,19 @@ class Session {
29813003 // Trail follows the CAMERA SUBJECT's ball center (the hero by29823004 // default; the leader when leader-cam is elected) — a trail on an29833005 // off-screen marble is a trail nobody sees.2984- this.trail.update(2985- this.followedPivot.getWorldPosition(this.trailPoint),2986- this.ctx.camera,2987- );3006+ this.followedPivot.getWorldPosition(this.trailPoint);3007+ this.trail.update(this.trailPoint, this.ctx.camera);3008+ const followed = this.racers[this.followedIdx];3009+ if (followed?.boost.boosting) {3010+ if (!this.boostTrailActive) this.boostTrail.reset();3011+ this.boostTrailActive = true;3012+ this.boostTrailFade = 1;3013+ this.boostTrail.update(this.trailPoint, this.ctx.camera);3014+ } else {3015+ this.boostTrailActive = false;3016+ this.boostTrailFade = Math.max(0, this.boostTrailFade - dt * 4);3017+ this.boostTrail.fade(this.ctx.camera, this.boostTrailFade);3018+ }29883019 // The marble's rolling voice follows its speed.29893020 this.audio.setSpeed(this.speed);29903021 @@ -3127,14 +3158,17 @@ class Session {
31273158 dispose(): void {31283159 // Racer peers (M15): visuals + bodies go with the session.31293160 for (const r of this.racers) {3130- if (r.idx === 0) continue;3131- if (r.obj) {3161+ if (r.idx !== 0 && r.obj) {3162+ disposeMarbleOwnedVisuals(r.obj);31323163 this.ctx.scene.remove(r.obj);31333164 r.obj.clear();3134- for (const m of r.materials) m.dispose();31353165 }3166+ for (const m of r.materials) m.dispose();31363167 this.engine?.destroyMarble(r.idx);31373168 }3169+ disposeMarbleOwnedVisuals(this.marbleObj);3170+ this.engine?.dispose();3171+ this.engine = null;31383172 if (this.peerMesh) {31393173 this.ctx.scene.remove(this.peerMesh);31403174 this.peerMesh.geometry.dispose();@@ -3171,10 +3205,18 @@ class Session {
31713205 this.peerRing.dispose();31723206 this.toast.dispose();31733207 this.placeHud.dispose();3208+ this.scarGeo?.dispose();3209+ this.scarMat?.dispose();3210+ this.scuffMat?.dispose();3211+ this.scarGeo = null;3212+ this.scarMat = null;3213+ this.scuffMat = null;31743214 this.ctx.scene.remove(this.root);31753215 this.ctx.scene.remove(this.marbleObj);31763216 this.ctx.scene.remove(this.trail.obj);31773217 this.trail.dispose();3218+ this.ctx.scene.remove(this.boostTrail.obj);3219+ this.boostTrail.dispose();31783220 this.cams.retargetPath(null, null);31793221 }31803222 @@ -203,6 +203,7 @@ export function installInspect(
203203 return {204204 reset() {205205 clearSelection();206+ renderList();206207 },207208 get active() {208209 return active;@@ -8,7 +8,7 @@
88 */99 import * as THREE from "three";1010 import { createScene, fitShadowsTo } from "./scene.ts";11-import { type Assets, loadAssets, NATURE_PACK } from "./assets.ts";11+import { type Assets, loadAssets } from "./assets.ts";1212 import { initSeed, newSeed } from "./seed.ts";1313 import { buildTrack, type BuiltTrack } from "./track.ts";1414 import { buildCenterline, type Centerline } from "./centerline.ts";@@ -89,113 +89,6 @@ function fail(err: unknown): void {
8989 setStats(`error: ${err instanceof Error ? err.message : String(err)}`);9090 }9191 92-// ---------------------------------------------------------------------------93-// Gallery mode — every piece in both packs, on a flat grid.94-// ---------------------------------------------------------------------------95-96-function makeLabel(text: string): THREE.Sprite | null {97- const w = 256, h = 40;98- // One canvas per label: CanvasTexture keeps a live reference, so a shared99- // canvas would leave every sprite showing the last string drawn.100- const canvas = document.createElement("canvas");101- canvas.width = w;102- canvas.height = h;103- const g = canvas.getContext("2d");104- if (!g) return null;105- // Shrink to fit: kit ids run from "tree" to "cliff_waterfallTop_stone".106- let size = 24;107- const font = (px: number) => `600 ${px}px ui-monospace, Menlo, monospace`;108- g.font = font(size);109- while (size > 8 && g.measureText(text).width > w - 8) {110- size -= 1;111- g.font = font(size);112- }113- g.textAlign = "center";114- g.textBaseline = "middle";115- g.fillStyle = "#0e1420";116- g.fillText(text, w / 2, h / 2);117- const tex = new THREE.CanvasTexture(canvas);118- tex.colorSpace = THREE.SRGBColorSpace;119- const sprite = new THREE.Sprite(120- new THREE.SpriteMaterial({121- map: tex,122- transparent: true,123- depthWrite: false,124- }),125- );126- sprite.scale.set(1.8, 0.28, 1);127- return sprite;128-}129-130-function buildGallery(assets: Assets): void {131- const ids = [132- ...[...assets.marbleIds].sort(),133- ...[...assets.natureIds].sort().map((id) => `${NATURE_PACK}:${id}`),134- ];135- const group = new THREE.Group();136- group.name = "gallery";137- const spacing = 2;138- const cols = Math.ceil(Math.sqrt(ids.length));139- let missing = 0;140-141- ids.forEach((id, i) => {142- const obj = assets.getPiece(id);143- const x = (i % cols) * spacing;144- const z = Math.floor(i / cols) * spacing;145- if (!obj) {146- missing++;147- console.warn(`[gallery] missing piece "${id}"`);148- return;149- }150- obj.position.set(x, 0, z);151- group.add(obj);152- const label = makeLabel(id.replace(`${NATURE_PACK}:`, ""));153- if (label) {154- label.position.set(x, 0.18, z + 0.85);155- group.add(label);156- }157- });158-159- const ground = new THREE.Mesh(160- new THREE.PlaneGeometry(161- cols * spacing + 12,162- Math.ceil(ids.length / cols) * spacing + 12,163- ),164- new THREE.MeshStandardMaterial({ color: 0x8ba888, roughness: 1 }),165- );166- ground.rotation.x = -Math.PI / 2;167- ground.position.set(168- (cols * spacing) / 2 - spacing / 2,169- -0.01,170- (Math.ceil(ids.length / cols) * spacing) / 2 - spacing / 2,171- );172- ground.receiveShadow = true;173- group.add(ground);174-175- ctx.scene.add(group);176-177- const box = new THREE.Box3().setFromObject(group);178- fitShadowsTo(ctx.sun, box);179- ctx.scene.fog = null;180- const cams = createCameras(ctx, null, null);181- cams.frame(box);182- ctx.onFrame.add((dt) => cams.update(dt));183-184- if (seedEl) seedEl.textContent = "gallery";185- setStats(186- `gallery • ${assets.marbleIds.length} marble-kit + ${assets.natureIds.length} nature-kit` +187- (missing ? ` • ${missing} missing` : " • 0 missing"),188- );189- if (newTrackBtn) newTrackBtn.disabled = true;190-191- installCua(ctx, () => ({192- mode: "gallery",193- marblePieces: assets.marbleIds.length,194- naturePieces: assets.natureIds.length,195- missing,196- }));197-}198-19992 // ---------------------------------------------------------------------------20093 // Normal mode20194 // ---------------------------------------------------------------------------@@ -222,8 +115,18 @@ function boot(assets: Assets): void {
222115 let lastTrack: Track | null = null;223116 let lastSeed = "";224117 const trail = createTrail();118+ const boostTrail = createTrail(0xff5a24, {119+ maxPoints: 28,120+ headAlpha: 1,121+ additive: true,122+ renderOrder: 6,123+ });124+ boostTrail.obj.name = "boost-trail";225125 const trailPoint = new THREE.Vector3();226126 ctx.scene.add(trail.obj);127+ ctx.scene.add(boostTrail.obj);128+ let boostTrailFade = 0;129+ let boostTrailActive = false;227130 228131 const cams = createCameras(ctx, null, null);229132 // P = live photo export (shared with endless mode).@@ -257,7 +160,18 @@ function boot(assets: Assets): void {
257160 if (marble) {258161 // Ball center = the pivot child, not the group origin on the deck.259162 const center = marble.obj.children[0] ?? marble.obj;260- trail.update(center.getWorldPosition(trailPoint), ctx.camera);163+ center.getWorldPosition(trailPoint);164+ trail.update(trailPoint, ctx.camera);165+ if (marble.boosting) {166+ if (!boostTrailActive) boostTrail.reset();167+ boostTrailActive = true;168+ boostTrailFade = 1;169+ boostTrail.update(trailPoint, ctx.camera);170+ } else {171+ boostTrailActive = false;172+ boostTrailFade = Math.max(0, boostTrailFade - dt * 4);173+ boostTrail.fade(ctx.camera, boostTrailFade);174+ }261175 }262176 cams.update(dt);263177 });@@ -270,9 +184,13 @@ function boot(assets: Assets): void {
270184 built = null;271185 if (marble) {272186 ctx.scene.remove(marble.obj);187+ marble.dispose();273188 marble = null;274189 }275190 trail.reset();191+ boostTrail.reset();192+ boostTrailFade = 0;193+ boostTrailActive = false;276194 if (debugLine) {277195 ctx.scene.remove(debugLine);278196 debugLine.geometry.dispose();@@ -321,6 +239,7 @@ function boot(assets: Assets): void {
321239 );322240 ctx.scene.add(marble.obj);323241 trail.setWidth(marble.radius * 0.6);242+ boostTrail.setWidth(marble.radius * 1.5);324243 325244 if (DEBUG) {326245 debugLine = centerline.debugLine();@@ -9,7 +9,7 @@
99 import * as THREE from "three";1010 import type { Assets } from "./assets.ts";1111 import type { FanField } from "./fanField.ts";12-import { addMarbleCore } from "./marbleGlass.ts";12+import { addMarbleCore, disposeMarbleOwnedVisuals } from "./marbleGlass.ts";1313 1414 const MARBLE_PIECE = "marble-center-high";1515 const BASE_SPEED = 2.2;@@ -24,7 +24,10 @@ export interface Marble {
2424 s: number;2525 speed: number;2626 radius: number;27+ /** True only while a trackside boost is actively accelerating it. */28+ boosting: boolean;2729 update(dt: number): void;30+ dispose(): void;2831 }2932 3033 export function createMarble(@@ -84,6 +87,7 @@ export function createMarble(
8487 metalness: 0.1,8588 }),8689 );90+ fallback.userData.marbleOwnedVisual = true;8791 fallback.castShadow = true;8892 pivot.position.y = radius;8993 pivot.add(fallback);@@ -164,9 +168,17 @@ export function createMarble(
164168 s: 0,165169 speed: BASE_SPEED,166170 radius,171+ boosting: false,172+ dispose(): void {173+ disposeMarbleOwnedVisuals(obj);174+ },167175 update(dt: number) {168176 const total = active().getLength();169177 if (total <= 0 || dt <= 0) return;178+ // Boost is an acceleration state, not a lingering appearance. Clear it179+ // before any early-returning fall path so a bridge drop cannot draw a180+ // hot vertical streak after the marble has left the fan.181+ marble.boosting = false;170182 171183 // Mid-fall: eased vertical drop, then resume on the lower channel.172184 if (falling) {@@ -195,7 +207,10 @@ export function createMarble(
195207 for (const b of boosts) {196208 const dx = obj.position.x - b.pos[0];197209 const dz = obj.position.z - b.pos[2];198- if (dx * dx + dz * dz < 1.2 * 1.2) marble.speed += b.strength * dt;210+ if (dx * dx + dz * dz < 1.2 * 1.2) {211+ marble.speed += b.strength * dt;212+ marble.boosting = true;213+ }199214 }200215 // Wind (M19a): tangential component works the speed; lateral201216 // component drives the in-channel sway spring.@@ -119,10 +119,27 @@ export function addMarbleCore(mesh: THREE.Object3D): void {
119119 mat.name = "core";120120 const m = new THREE.Mesh(geo, mat);121121 m.name = "core";122+ // Unlike the cloned kit shell, this geometry/material pair is created123+ // per marble and must be released on same-page regeneration.124+ m.userData.marbleOwnedVisual = true;122125 m.position.copy(center);123126 mesh.add(m);124127 }125128 129+/** Dispose only the per-marble geometry/material injected above. Kit shell130+ * clones share their loaded assets and must remain alive for later seeds. */131+export function disposeMarbleOwnedVisuals(root: THREE.Object3D): void {132+ root.traverse((child: THREE.Object3D) => {133+ const mesh = child as THREE.Mesh;134+ if (!mesh.isMesh || !mesh.userData.marbleOwnedVisual) return;135+ mesh.geometry.dispose();136+ const materials = Array.isArray(mesh.material)137+ ? mesh.material138+ : [mesh.material];139+ for (const material of materials) material.dispose();140+ });141+}142+126143 /** Additive fresnel shell for instanced packs: rim-only glass illusion,127144 * order-independent (no sorting artifacts at any N). Declares the128145 * instancing attributes itself (raw ShaderMaterial gets none for free);@@ -210,9 +227,14 @@ function posHash(x: number, y: number, z: number): number {
210227 export function applyTunnelVariants(211228 group: THREE.Object3D,212229 renderer: THREE.WebGLRenderer,213-): { meshes: THREE.InstancedMesh[]; dispose(): void } {230+): {231+ meshes: THREE.InstancedMesh[];232+ cameraFadeMeshes: THREE.Mesh[];233+ dispose(): void;234+} {214235 const cloned: THREE.Material[] = [];215236 const clonedGeos: THREE.BufferGeometry[] = [];237+ const cameraFadeMeshes: THREE.Mesh[] = [];216238 group.traverse((o: THREE.Object3D) => {217239 const m = o as THREE.Mesh;218240 if (!m.isMesh || m.name !== "tunnel") return;@@ -240,6 +262,9 @@ export function applyTunnelVariants(
240262 });241263 cloned.push(mat);242264 m.material = mat;265+ m.geometry.computeBoundingBox();266+ m.userData.cameraBaseOpacity = mat.opacity;267+ cameraFadeMeshes.push(m);243268 } else if (h < 0.34) {244269 // Wire arch: a chainlink CAGE — alpha-cutout diamond weave over245270 // projected UVs (the kit's atlas UVs can't tile a repeat; the@@ -268,12 +293,18 @@ export function applyTunnelVariants(
268293 mat.polygonOffset = true;269294 mat.polygonOffsetFactor = -1;270295 mat.polygonOffsetUnits = -1;296+ mat.transparent = true;297+ mat.depthWrite = false;271298 cloned.push(mat);272299 m.material = mat;300+ m.geometry.computeBoundingBox();301+ m.userData.cameraBaseOpacity = 1;302+ cameraFadeMeshes.push(m);273303 }274304 });275305 return {276306 meshes: [],307+ cameraFadeMeshes,277308 dispose() {278309 for (const m of cloned) m.dispose();279310 for (const g of clonedGeos) g.dispose();@@ -90,9 +90,9 @@ function urlFor(c: MenuChoices, seed: string): string {
9090 /** Current seed from the hash (returning via ?menu=1 keeps it). */9191 function currentSeed(): string {9292 try {93- return decodeURIComponent(location.hash.slice(1)).trim();93+ return decodeURIComponent(location.hash.slice(1));9494 } catch {95- return location.hash.slice(1).trim();95+ return location.hash.slice(1);9696 }9797 }9898 @@ -114,13 +114,16 @@ export function maybeShowMenu(): boolean {
114114 #1a2b4a 0%, #101a30 40%, #0a0e18 100%);115115 font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;116116 color: #dce6f5;117- display: flex; align-items: center; justify-content: center;118- overflow: hidden;117+ display: block; overflow: auto; box-sizing: border-box;118+ padding: 24px 0 64px;119119 }120120 #menu canvas.stars { position: absolute; inset: 0; }121121 #menu .col {122122 position: relative; text-align: center;123123 display: flex; flex-direction: column; align-items: center;124+ justify-content: center; box-sizing: border-box;125+ width: 100%; max-width: 480px; min-width: 0;126+ min-height: calc(100vh - 88px); margin: 0 auto;124127 }125128 #menu .orb {126129 width: 64px; height: 64px; border-radius: 50%;@@ -135,23 +138,25 @@ export function maybeShowMenu(): boolean {
135138 50% { transform: translateY(-9px); }136139 }137140 #menu h1 {138- margin: 0; font-size: 44px; font-weight: 600;141+ margin: 0; font-size: clamp(30px, 8vw, 44px); font-weight: 600;139142 letter-spacing: 0.42em; text-indent: 0.42em;140143 text-shadow: 0 0 26px rgba(127, 212, 255, 0.35);141144 }142145 #menu .sub {143146 margin: 6px 0 44px; font-size: 12px; letter-spacing: 0.18em;144- color: rgba(220, 230, 245, 0.45);147+ color: rgba(220, 230, 245, 0.62);145148 }146149 #menu .rows {147150 display: flex; flex-direction: column; gap: 6px;148151 padding: 14px 18px;152+ width: calc(100% - 24px); max-width: 456px; box-sizing: border-box;149153 background: rgba(8, 12, 22, 0.5);150154 border: 12px solid transparent;151155 border-image: url("/ui/frame.png") 12 stretch;152156 }153157 #menu .row {154- display: grid; grid-template-columns: 24px 110px 240px;158+ display: grid;159+ grid-template-columns: 24px minmax(80px, 110px) minmax(0, 240px);155160 align-items: center; text-align: left;156161 padding: 7px 10px; border-radius: 6px; cursor: pointer;157162 color: rgba(220, 230, 245, 0.75);@@ -162,14 +167,24 @@ export function maybeShowMenu(): boolean {
162167 #menu .row.off { opacity: 0.35; pointer-events: none; }163168 #menu .row .k {164169 font-size: 12px; letter-spacing: 0.14em;165- color: rgba(220, 230, 245, 0.5);170+ color: rgba(220, 230, 245, 0.65);166171 }167172 #menu .row.sel .k { color: rgba(220, 230, 245, 0.8); }168- #menu .val { display: flex; align-items: center; gap: 10px; }173+ #menu .val {174+ display: flex; align-items: center; gap: 10px; min-width: 0;175+ }176+ #menu .val input { min-width: 0; }169177 #menu .val .arrow {170178 cursor: pointer; color: rgba(127, 212, 255, 0.5); user-select: none;179+ border: 0; padding: 0; background: transparent; font: inherit;171180 }172181 #menu .val .arrow:hover { color: #7fd4ff; }182+ #menu input#menu-seed:focus-visible,183+ #menu input#menu-racers:focus-visible,184+ #menu button:focus-visible,185+ #menu summary:focus-visible {186+ outline: 2px solid #7fd4ff; outline-offset: 3px;187+ }173188 #menu .val .v { flex: 1; text-align: center; color: #7fd4ff; }174189 #menu input#menu-seed {175190 width: 100%; box-sizing: border-box;@@ -187,6 +202,8 @@ export function maybeShowMenu(): boolean {
187202 }188203 #menu .row.start-row {189204 grid-template-columns: 24px 1fr; margin-top: 26px; text-align: center;205+ appearance: none; border: 0; width: 100%; font: inherit;206+ background: transparent;190207 }191208 #menu .row.start-row .k {192209 font-size: 17px; letter-spacing: 0.34em; text-indent: 0.34em;@@ -195,7 +212,8 @@ export function maybeShowMenu(): boolean {
195212 #menu .row.start-row.sel .k { text-shadow: 0 0 16px rgba(127, 212, 255, 0.6); }196213 #menu details {197214 margin-top: 34px; font-size: 11px;198- color: rgba(220, 230, 245, 0.4);215+ width: min(456px, calc(100vw - 32px));216+ color: rgba(220, 230, 245, 0.58);199217 }200218 #menu summary { cursor: pointer; letter-spacing: 0.14em; }201219 #menu .adv {@@ -203,14 +221,23 @@ export function maybeShowMenu(): boolean {
203221 align-items: flex-start;204222 }205223 #menu .adv label {206- display: flex; gap: 8px; align-items: center; cursor: pointer;207- color: rgba(220, 230, 245, 0.55);224+ display: flex; gap: 8px; align-items: flex-start; cursor: pointer;225+ text-align: left; white-space: normal;226+ color: rgba(220, 230, 245, 0.7);208227 }209228 #menu .adv input { accent-color: #7fd4ff; }210229 #menu .hint {211230 position: absolute; bottom: 18px; left: 0; right: 0;212- text-align: center; font-size: 10px; letter-spacing: 0.2em;213- color: rgba(220, 230, 245, 0.25);231+ text-align: center; font-size: 11px; letter-spacing: 0.16em;232+ color: rgba(220, 230, 245, 0.65);233+ }234+ (max-height: 560px) {235+ #menu { padding: 12px 0 28px; }236+ #menu .col { justify-content: flex-start; min-height: 0; }237+ #menu .orb { display: none; }238+ #menu .sub { margin-bottom: 18px; }239+ #menu details { margin-top: 18px; }240+ #menu .hint { display: none !important; }214241 }215242 </style>216243 <canvas class="stars"></canvas>@@ -220,25 +247,30 @@ export function maybeShowMenu(): boolean {
220247 <div class="rows">221248 <div class="row" data-row="mode">222249 <span class="cursor">▸</span><span class="k">MODE</span>223- <span class="val"><span class="arrow" data-dir="-1">◂</span>250+ <span class="val"><button type="button" class="arrow" data-dir="-1"251+ aria-label="Previous mode">◂</button>224252 <span class="v" id="menu-mode-v"></span>225- <span class="arrow" data-dir="1">▸</span></span>253+ <button type="button" class="arrow" data-dir="1"254+ aria-label="Next mode">▸</button></span>226255 </div>227256 <div class="row endless-only" data-row="racers">228257 <span class="cursor">▸</span><span class="k">RACERS</span>229- <span class="val"><span class="arrow" data-dir="-1">◂</span>258+ <span class="val"><button type="button" class="arrow" data-dir="-1"259+ aria-label="Fewer racers">◂</button>230260 <input id="menu-racers" inputmode="numeric" maxlength="4"231- spellcheck="false" autocomplete="off">232- <span class="arrow" data-dir="1">▸</span></span>261+ aria-label="Racers" spellcheck="false" autocomplete="off">262+ <button type="button" class="arrow" data-dir="1"263+ aria-label="More racers">▸</button></span>233264 </div>234265 <div class="row" data-row="seed">235266 <span class="cursor">▸</span><span class="k">SEED</span>236267 <span class="val"><input id="menu-seed" placeholder="random"268+ aria-label="Seed"237269 spellcheck="false" autocomplete="off"></span>238270 </div>239- <div class="row start-row" data-row="start">271+ <button type="button" class="row start-row" data-row="start">240272 <span class="cursor">▸</span><span class="k">START</span>241- </div>273+ </button>242274 </div>243275 <details>244276 <summary>ADVANCED</summary>@@ -279,9 +311,7 @@ export function maybeShowMenu(): boolean {
279311 seedStars();280312 addEventListener("resize", seedStars);281313 let last = performance.now();282- let alive = true;283314 const drift = (now: number) => {284- if (!alive) return;285315 const dt = Math.min(0.1, (now - last) / 1000);286316 last = now;287317 if (cx) {@@ -310,7 +340,10 @@ export function maybeShowMenu(): boolean {
310340 const racersInput = el<HTMLInputElement>("menu-racers");311341 racersInput.addEventListener("input", () => {312342 const v = Number.parseInt(racersInput.value, 10);313- if (Number.isFinite(v)) c.peers = Math.max(0, Math.min(9998, v - 1));343+ if (Number.isFinite(v)) {344+ c.peers = Math.max(0, Math.min(9998, v - 1));345+ if (v < 1 || v > 9999) racersInput.value = String(c.peers + 1);346+ }314347 });315348 racersInput.addEventListener("blur", () => sync());316349 const phys = el<HTMLInputElement>("menu-phys");@@ -329,24 +362,43 @@ export function maybeShowMenu(): boolean {
329362 const rows = ["mode", "racers", "seed", "start"] as const;330363 type Row = typeof rows[number];331364 let sel: Row = "start";365+ let selectionFocus = false;366+ let editingInput: HTMLInputElement | null = null;367+368+ for (const input of [seedInput, racersInput]) {369+ input.addEventListener("pointerdown", () => (editingInput = input));370+ input.addEventListener("focus", () => {371+ if (!selectionFocus) editingInput = input;372+ });373+ input.addEventListener("blur", () => {374+ if (editingInput === input) editingInput = null;375+ });376+ }332377 333378 const rowEl = (row: Row): HTMLElement =>334379 root.querySelector(`.row[data-row="${row}"]`) as HTMLElement;335380 336- const sync = () => {381+ const sync = (focusSelection = true) => {337382 el("menu-mode-v").textContent = c.endless ? "endless" : "loop";338383 if (document.activeElement !== racersInput) {339384 racersInput.value = String(c.peers + 1);340385 }341386 for (const r of root.querySelectorAll(".endless-only")) {342387 r.classList.toggle("off", !c.endless);388+ for (const control of r.querySelectorAll("input, button")) {389+ (control as HTMLInputElement | HTMLButtonElement).disabled = !c.endless;390+ }343391 }344392 if (!c.endless && sel === "racers") sel = "mode";345393 for (const row of rows) rowEl(row).classList.toggle("sel", row === sel);346- if (sel === "seed") seedInput.focus();347- else seedInput.blur();348- if (sel === "racers") racersInput.focus();349- else racersInput.blur();394+ if (focusSelection) {395+ selectionFocus = true;396+ if (sel === "seed") seedInput.focus();397+ else seedInput.blur();398+ if (sel === "racers") racersInput.focus();399+ else racersInput.blur();400+ selectionFocus = false;401+ }350402 };351403 352404 const adjust = (row: Row, dir: number) => {@@ -363,14 +415,16 @@ export function maybeShowMenu(): boolean {
363415 } catch {364416 // storage may be unavailable (private mode) — the url carries it all365417 }366- location.replace(urlFor(c, seedInput.value.trim()));418+ location.replace(urlFor(c, seedInput.value));367419 };368420 369421 for (const row of rows) {370422 const e = rowEl(row);371423 e.addEventListener("mouseenter", () => {372424 sel = row;373- sync();425+ // Hover may move the visual menu cursor, but must not steal the426+ // document focus/caret from a textbox or another native control.427+ sync(false);374428 });375429 e.addEventListener("click", (ev) => {376430 const arrow = (ev.target as HTMLElement).closest(".arrow");@@ -385,6 +439,13 @@ export function maybeShowMenu(): boolean {
385439 386440 addEventListener("keydown", (e) => {387441 if (!root.isConnected) return;442+ const target = e.target as HTMLElement | null;443+ if (editingInput && target === editingInput) return;444+ if (445+ target?.closest("button, summary, input[type=checkbox], select, textarea")446+ ) {447+ return;448+ }388449 if (e.key === "ArrowUp" || e.key === "ArrowDown") {389450 e.preventDefault();390451 const live = rows.filter((r) => r !== "racers" || c.endless);@@ -12,6 +12,9 @@ const HITS_CAP = 256;
1212 1313 export interface PhysicsModule {1414 _ph_init(gravityY: number): void;15+ /** Added with native lifecycle teardown. Optional so a browser that has16+ * a cached pre-teardown module can still regenerate instead of crashing. */17+ _ph_dispose?(): void;1518 _ph_begin_static(key: number): void;1619 _ph_add_box(1720 cx: number,@@ -195,12 +198,30 @@ export class PhysicsEngine {
195198 private readonly restitutions = new Map<number, number>();196199 /** Per-marble ghost cache (respawn immunity; spawn resets to solid). */197200 private readonly ghosts = new Map<number, boolean>();201+ private disposed = false;198202 199203 constructor(private readonly m: PhysicsModule) {204+ // The C shim is a singleton world; ph_init tears down any forgotten205+ // previous owner before creating the next one.200206 m._ph_init(GRAVITY_Y);201207 this.out = m._malloc(10 * 8);202208 }203209 210+ /** Release the singleton Box3D world and every JS-owned WASM buffer. */211+ dispose(): void {212+ if (this.disposed) return;213+ this.disposed = true;214+ if (this.batchPtr !== 0) this.m._free(this.batchPtr);215+ if (this.hitsPtr !== 0) this.m._free(this.hitsPtr);216+ this.m._free(this.out);217+ this.batchPtr = 0;218+ this.batchCap = 0;219+ this.hitsPtr = 0;220+ this.restitutions.clear();221+ this.ghosts.clear();222+ this.m._ph_dispose?.();223+ }224+204225 addSegment(key: number, proxies: readonly CollisionProxy[]): void {205226 this.m._ph_begin_static(key);206227 for (const p of proxies) {@@ -29,8 +29,31 @@ static int refMeshCount[MAX_REFS];
2929 static b3ShapeDef shapeDef;3030 static int pendingSlot = -1;3131 32+EMSCRIPTEN_KEEPALIVE33+void ph_dispose(void) {34+ if (B3_IS_NULL(world)) return;35+ // World teardown releases bodies/shapes first; mesh-data handles are36+ // caller-owned and are destroyed after no shape references remain.37+ b3DestroyWorld(world);38+ for (int i = 0; i < MAX_REFS; i++) {39+ if (refUsed[i]) {40+ for (int j = 0; j < refMeshCount[i]; j++) {41+ b3DestroyMesh(refMeshes[i][j]);42+ }43+ }44+ refMeshCount[i] = 0;45+ refUsed[i] = 0;46+ }47+ for (int i = 0; i < MAX_MARBLES; i++) marblesAlive[i] = 0;48+ pendingSlot = -1;49+ world = b3_nullWorldId;50+}51+3252 EMSCRIPTEN_KEEPALIVE3353 void ph_init(float gravityY) {54+ // The shim owns one global world. Reinitialization without teardown leaks55+ // every body, mesh and allocator from the previous same-page seed.56+ ph_dispose();3457 b3WorldDef def = b3DefaultWorldDef();3558 def.workerCount = 1; // determinism-friendly; perf headroom is huge3659 def.gravity = (b3Vec3){0, gravityY, 0};@@ -27,10 +27,7 @@ import { assemble } from "../gen/assemble.ts";
2727 import { pickStyle } from "../gen/style.ts";2828 import { rngFamily } from "../gen/rng.ts";2929 import { DIR_STEP, turnLeft, turnRight } from "../gen/geom.ts";30-import {31- type CollisionProxy,32- segmentProxies,33-} from "../gen/endless/proxies.ts";30+import { type CollisionProxy, segmentProxies } from "../gen/endless/proxies.ts";3431 import { scaleSeg, scaleVec, TRACK_SCALE } from "../gen/endless/scale.ts";3532 import { instantiatePlacements, type Spinner } from "./track.ts";3633 import { installCua } from "./cua.ts";@@ -52,6 +49,7 @@ interface Plot {
5249 rail: Rail;5350 proxies: CollisionProxy[];5451 overlay: THREE.Group;52+ labelSprite: THREE.Sprite | null;5553 label: string;5654 /** Finite, non-empty render bounds (T7 assertion). Phantoms pass via5755 * their ghosted host mesh; false is a loud data defect. */@@ -340,6 +338,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
340338 rail,341339 proxies,342340 overlay,341+ labelSprite: sprite,343342 label,344343 meshValid,345344 });@@ -357,6 +356,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
357356 root.add(marker);358357 359358 const ui = document.createElement("div");359+ ui.className = "piece-bench-toolbar";360360 ui.setAttribute("role", "toolbar");361361 ui.setAttribute("aria-label", "Piece collision audit controls");362362 Object.assign(ui.style, {@@ -429,6 +429,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
429429 // does not cover the mesh while keeping the exact proxy composition in the430430 // accessibility tree and in screenshots used for collision audits.431431 const auditFacts = document.createElement("pre");432+ auditFacts.className = "piece-bench-facts";432433 auditFacts.setAttribute("aria-label", "Selected piece collision facts");433434 Object.assign(auditFacts.style, {434435 position: "fixed",@@ -447,6 +448,42 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
447448 });448449 document.body.appendChild(auditFacts);449450 451+ // Keep the diagnostic controls usable on phone-sized windows. The bench452+ // is an audit surface, so clipped controls defeat its primary purpose.453+ const responsiveStyle = document.createElement("style");454+ responsiveStyle.textContent = `455+ @media (max-width: 600px) {456+ .piece-bench-toolbar {457+ left: 8px !important;458+ right: 8px !important;459+ bottom: 8px !important;460+ width: auto !important;461+ transform: none !important;462+ flex-wrap: wrap !important;463+ justify-content: center !important;464+ }465+ .piece-bench-toolbar [role="status"] {466+ order: -1;467+ width: auto !important;468+ flex: 1 0 calc(100% - 16px) !important;469+ }470+ .piece-bench-toolbar input {471+ width: auto !important;472+ min-width: 90px;473+ flex: 1 1 120px;474+ }475+ .piece-bench-facts {476+ left: 8px !important;477+ right: 8px !important;478+ bottom: 118px !important;479+ max-width: none !important;480+ max-height: 25vh;481+ overflow: hidden;482+ }483+ }484+ `;485+ document.head.appendChild(responsiveStyle);486+450487 let showCollision = false;451488 const collisionBtn = mkBtn("collision: off", () => {452489 showCollision = !showCollision;@@ -467,6 +504,8 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
467504 });468505 const simNearest: number[] = [];469506 const clearSim = () => {507+ engine?.dispose();508+ engine = null;470509 for (const m of simMarbles) root.remove(m);471510 simMarbles.length = 0;472511 simNearest.length = 0;@@ -644,10 +683,15 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
644683 if (plots.length === 0) return;645684 current = ((i % plots.length) + plots.length) % plots.length;646685 const plot = plots[current];686+ const compact = innerWidth <= 600;687+ for (const p of plots) {688+ if (p.labelSprite) p.labelSprite.visible = !compact;689+ }647690 selected = plot;648691 marker.visible = true;649692 marker.position.set(plot.origin.x, 0.03, plot.origin.z);650693 const pbox = new THREE.Box3().setFromObject(plot.group);694+ if (!compact && plot.labelSprite) pbox.expandByObject(plot.labelSprite);651695 // Empty bounds must not strand the camera on the previous piece652696 // (the cross-over audit blocker) — frame the plot origin instead.653697 if (pbox.isEmpty()) {@@ -707,6 +751,7 @@ export function buildPieceBench(ctx: SceneCtx, assets: Assets): void {
707751 if (ev.key === "ArrowLeft") focus(current - 1);708752 if (ev.key === "ArrowRight") focus(current + 1);709753 });754+ addEventListener("resize", () => focus(current));710755 // Clicking a plot focuses it too.711756 onPick = (plot) => focus(plots.indexOf(plot));712757 @@ -20,9 +20,9 @@ export function mintSeed(): string {
2020 2121 function readHash(): string {2222 try {23- return decodeURIComponent(location.hash.slice(1)).trim();23+ return decodeURIComponent(location.hash.slice(1));2424 } catch {25- return location.hash.slice(1).trim();25+ return location.hash.slice(1);2626 }2727 }2828 @@ -40,6 +40,9 @@ export function instantiatePlacements(
4040 }4141 // Inspect mode resolves picked meshes back to their placement.4242 obj.userData.placementIndex = index;43+ // Endless follow-camera collision uses the authored semantic role to44+ // avoid track walls/floors while ignoring support and scenery clutter.45+ obj.userData.placementRole = p.role;4346 if (!p.goal && pipeColorFor) {4447 assets.applyPipeColor(obj, pipeColorFor(p));4548 }@@ -8,21 +8,37 @@ const MIN_STEP = 0.06; // world units between samples
88 const JUMP_RESET = 4; // a respawn/warp teleports the marble; don't streak99 const HEAD_ALPHA = 0.85;1010 11+export interface TrailOptions {12+ maxPoints?: number;13+ minStep?: number;14+ jumpReset?: number;15+ headAlpha?: number;16+ additive?: boolean;17+ renderOrder?: number;18+}19+1120 export interface Trail {1221 obj: THREE.Mesh;1322 setWidth(w: number): void;1423 update(pos: THREE.Vector3, camera: THREE.Camera): void;24+ /** Redraw retained history without sampling a new point (afterglow). */25+ fade(camera: THREE.Camera, opacity: number): void;1526 reset(): void;1627 dispose(): void;1728 }1829 1930 export function createTrail(2031 color: THREE.ColorRepresentation = 0x7fd4ff,32+ options: TrailOptions = {},2133 ): Trail {22- const positions = new Float32Array(MAX_POINTS * 2 * 3);23- const colors = new Float32Array(MAX_POINTS * 2 * 4);34+ const maxPoints = Math.max(2, Math.floor(options.maxPoints ?? MAX_POINTS));35+ const minStep = options.minStep ?? MIN_STEP;36+ const jumpReset = options.jumpReset ?? JUMP_RESET;37+ const headAlpha = options.headAlpha ?? HEAD_ALPHA;38+ const positions = new Float32Array(maxPoints * 2 * 3);39+ const colors = new Float32Array(maxPoints * 2 * 4);2440 const index: number[] = [];25- for (let i = 0; i < MAX_POINTS - 1; i++) {41+ for (let i = 0; i < maxPoints - 1; i++) {2642 const a = i * 2;2743 index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);2844 }@@ -36,11 +52,12 @@ export function createTrail(
3652 transparent: true,3753 depthWrite: false,3854 side: THREE.DoubleSide,55+ blending: options.additive ? THREE.AdditiveBlending : THREE.NormalBlending,3956 });4057 const mesh = new THREE.Mesh(geometry, material);4158 mesh.name = "marble-trail";4259 mesh.frustumCulled = false;43- mesh.renderOrder = 5;60+ mesh.renderOrder = options.renderOrder ?? 5;4461 4562 const tint = new THREE.Color(color);4663 const history: THREE.Vector3[] = []; // newest first@@ -50,10 +67,12 @@ export function createTrail(
5067 const dir = new THREE.Vector3();5168 const view = new THREE.Vector3();5269 const side = new THREE.Vector3();70+ const previousSide = new THREE.Vector3();5371 54- function rewrite(camera: THREE.Camera): void {72+ function rewrite(camera: THREE.Camera, opacity = 1): void {5573 camera.getWorldPosition(camPos);5674 const last = history.length - 1;75+ let hasPreviousSide = false;5776 for (let i = 0; i < history.length; i++) {5877 const p = history[i];5978 // Central difference along the trail; degenerate at a single point.@@ -61,8 +80,16 @@ export function createTrail(
6180 view.copy(p).sub(camPos);6281 side.crossVectors(dir, view);6382 if (side.lengthSq() > 1e-10) side.normalize();83+ else if (hasPreviousSide) side.copy(previousSide);6484 else side.set(0, 1, 0);65- const fade = i === last ? 0 : 1 - i / (MAX_POINTS - 1);85+ // Camera-facing strips can choose either side-vector sign. Keep it86+ // continuous down the ribbon so sharp bends never twist the strip.87+ if (hasPreviousSide && side.dot(previousSide) < 0) side.negate();88+ previousSide.copy(side);89+ hasPreviousSide = true;90+ // Taper over the history that ACTUALLY exists. Using maxPoints here91+ // made every short/new trail end at nearly full width, then snap off.92+ const fade = last <= 0 ? 0 : 1 - i / last;6693 side.multiplyScalar(headWidth * fade * 0.5);6794 positions[i * 6] = p.x + side.x;6895 positions[i * 6 + 1] = p.y + side.y;@@ -70,7 +97,7 @@ export function createTrail(
7097 positions[i * 6 + 3] = p.x - side.x;7198 positions[i * 6 + 4] = p.y - side.y;7299 positions[i * 6 + 5] = p.z - side.z;73- const alpha = HEAD_ALPHA * fade * fade;100+ const alpha = headAlpha * Math.max(0, opacity) * fade * fade;74101 for (const v of [i * 8, i * 8 + 4]) {75102 colors[v] = tint.r;76103 colors[v + 1] = tint.g;@@ -90,17 +117,25 @@ export function createTrail(
90117 },91118 update(pos: THREE.Vector3, camera: THREE.Camera): void {92119 const head = history[0];93- if (head && pos.distanceTo(head) > JUMP_RESET) history.length = 0;120+ if (head && pos.distanceTo(head) > jumpReset) history.length = 0;94121 if (95- !history[0] || pos.distanceToSquared(history[0]) >= MIN_STEP * MIN_STEP122+ !history[0] || pos.distanceToSquared(history[0]) >= minStep * minStep96123 ) {97124 history.unshift(pos.clone());98- if (history.length > MAX_POINTS) history.length = MAX_POINTS;125+ if (history.length > maxPoints) history.length = maxPoints;99126 } else {100127 history[0].copy(pos);101128 }102129 rewrite(camera);103130 },131+ fade(camera: THREE.Camera, opacity: number): void {132+ if (opacity <= 0) {133+ history.length = 0;134+ geometry.setDrawRange(0, 0);135+ return;136+ }137+ rewrite(camera, opacity);138+ },104139 reset(): void {105140 history.length = 0;106141 geometry.setDrawRange(0, 0);@@ -170,8 +170,6 @@ export const CHANNEL_PROFILE: readonly (readonly [number, number])[] = [
170170 171171 /** Sampling step along the centerline, world units. */172172 const STEP = 0.6;173-/** Slab half-length: overlaps the next sample so seams can't catch. */174-const HALF_LEN = STEP * 0.75;175173 /** Channel geometry at slalom scale (track walls ±0.35, height +0.5). */176174 const FLOOR_HALF_W = 0.38 * TRACK_SCALE;177175 const FLOOR_HALF_T = 0.15;@@ -431,8 +429,7 @@ export function channelProxies(
431429 const stations: Station[] = [];432430 /** Floor-pierce apertures to emit (T2): center + travel yaw, resolved433431 * after sampling from the nearest non-vertical station. */434- const apertures: { c: readonly [number, number, number]; yaw: number }[] =435- [];432+ const apertures: { c: readonly [number, number, number]; yaw: number }[] = [];436433 for (const [segIdx, seg] of path.segments.entries()) {437434 const holes = (seg.holes ?? []) as readonly [number, number, number][];438435 sampleParts(@@ -29,6 +29,10 @@
2929 font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;3030 color: #dce6f5;3131 }32+ #hud[hidden],33+ #stats[hidden] {34+ display: none;35+ }3236 /* Game-skin chrome: Kenney 9-slice frame (CC0, tinted), not a web3337 widget — see static/ui/. */3438 #hud button,@@ -50,6 +54,13 @@
5054 #seed {5155 user-select: all;5256 cursor: copy;57+ display: inline-block;58+ box-sizing: border-box;59+ max-width: min(42vw, 32ch);60+ overflow: hidden;61+ text-overflow: ellipsis;62+ white-space: nowrap;63+ vertical-align: middle;5364 }5465 #stats {5566 position: fixed;@@ -13,7 +13,7 @@
1313 * - spawns never materialize on top of the player, never outside the band;1414 * - behaviour constants stay inside their designed envelopes.1515 */16-import { assert, assertEquals, assertNotEquals } from "@std/assert";16+import { assert, assertEquals } from "@std/assert";1717 import {1818 AHEAD_LIMIT,1919 BEHIND_LIMIT,@@ -92,6 +92,33 @@ function drive(
9292 9393 let module_: PhysicsModule | null = null;9494 95+Deno.test("physics engine: repeated disposal releases and reinitializes the world", async () => {96+ const module = await factory();97+ for (let round = 0; round < 4; round++) {98+ const engine = new PhysicsEngine(module);99+ engine.addSegment(round, [100+ {101+ kind: "slab",102+ center: [0, -0.1, 0],103+ half: [2, 0.1, 2],104+ yaw: 0,105+ pitch: 0,106+ },107+ ]);108+ engine.spawnMarble(0, [0, 0.4, 0], [1, 0, 0]);109+ engine.statesAll(1);110+ engine.hits();111+ engine.step(1 / 30);112+ const state = engine.state();113+ assert(114+ state.pos.every(Number.isFinite),115+ `non-finite state in round ${round}`,116+ );117+ engine.dispose();118+ engine.dispose(); // idempotent owner cleanup119+ }120+});121+95122 function driveWith(96123 seed: string,97124 segments: number,@@ -0,0 +1,51 @@
1+import { assert, assertEquals } from "@std/assert";2+import * as THREE from "three";3+import { createTrail } from "../src/client/trail.ts";4+5+function alphaAt(trail: ReturnType<typeof createTrail>, point: number): number {6+ const mesh = trail.obj;7+ const colors = mesh.geometry.getAttribute("color") as THREE.BufferAttribute;8+ return colors.getW(point * 2);9+}10+11+Deno.test("trail: short histories taper over their actual length", () => {12+ const trail = createTrail(0xffffff, { maxPoints: 12, minStep: 0 });13+ const camera = new THREE.PerspectiveCamera();14+ camera.position.set(0, 4, 8);15+ for (let x = 0; x < 4; x++) {16+ trail.update(new THREE.Vector3(x, 0, 0), camera);17+ }18+ assert(alphaAt(trail, 0) > alphaAt(trail, 1));19+ assert(alphaAt(trail, 1) > alphaAt(trail, 2));20+ assertEquals(alphaAt(trail, 3), 0);21+ trail.dispose();22+});23+24+Deno.test("trail: afterglow fades without adding geometry", () => {25+ const trail = createTrail(0xffffff, { minStep: 0 });26+ const camera = new THREE.PerspectiveCamera();27+ camera.position.set(0, 3, 6);28+ trail.update(new THREE.Vector3(0, 0, 0), camera);29+ trail.update(new THREE.Vector3(1, 0, 0), camera);30+ trail.update(new THREE.Vector3(2, 0, 0), camera);31+ const beforeCount = trail.obj.geometry.drawRange.count;32+ const beforeAlpha = alphaAt(trail, 0);33+ trail.fade(camera, 0.4);34+ assertEquals(trail.obj.geometry.drawRange.count, beforeCount);35+ assert(Math.abs(alphaAt(trail, 0) - beforeAlpha * 0.4) < 1e-6);36+ trail.fade(camera, 0);37+ assertEquals(trail.obj.geometry.drawRange.count, 0);38+ trail.dispose();39+});40+41+Deno.test("trail: warp reset never bridges distant points", () => {42+ const trail = createTrail(0xffffff, { minStep: 0, jumpReset: 2 });43+ const camera = new THREE.PerspectiveCamera();44+ camera.position.set(0, 3, 6);45+ trail.update(new THREE.Vector3(0, 0, 0), camera);46+ trail.update(new THREE.Vector3(1, 0, 0), camera);47+ assert(trail.obj.geometry.drawRange.count > 0);48+ trail.update(new THREE.Vector3(20, 0, 0), camera);49+ assertEquals(trail.obj.geometry.drawRange.count, 0);50+ trail.dispose();51+});@@ -17,7 +17,7 @@ fi
1717 git -C "$EXTERN" fetch --depth 1 origin "$BOX3D_COMMIT" 2>/dev/null || true1818 git -C "$EXTERN" checkout -q "$BOX3D_COMMIT"1919 20-emcmake cmake -S "$EXTERN" -B "$EXTERN/build-wasm" \20+emcmake cmake --fresh -S "$EXTERN" -B "$EXTERN/build-wasm" \2121 -DCMAKE_BUILD_TYPE=Release \2222 -DBOX3D_SAMPLES=OFF -DBOX3D_UNIT_TESTS=OFF -DBOX3D_BENCHMARKS=OFF \2323 >/dev/null@@ -30,7 +30,7 @@ emcc "$ROOT/src/client/physics/shim.c" \
3030 -O2 \3131 -sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,node \3232 -sALLOW_MEMORY_GROWTH=1 \33- -sEXPORTED_FUNCTIONS=_ph_init,_ph_begin_static,_ph_add_box,_ph_add_box_q,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_add_bumper_q,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_ghost,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_ph_states,_ph_hits,_malloc,_free \33+ -sEXPORTED_FUNCTIONS=_ph_init,_ph_dispose,_ph_begin_static,_ph_add_box,_ph_add_box_q,_ph_add_slab,_ph_add_mesh,_ph_add_bumper,_ph_add_bumper_q,_ph_end_static,_ph_remove_static,_ph_spawn_marble,_ph_destroy_marble,_ph_marble_ghost,_ph_marble_force,_ph_marble_jump,_ph_marble_mass,_ph_marble_restitution,_ph_gravity,_ph_step,_ph_marble_state,_ph_states,_ph_hits,_malloc,_free \3434 -sEXPORTED_RUNTIME_METHODS=HEAPF64,HEAPF32,HEAP32 \3535 -o "$ROOT/static/dist/box3d.mjs"3636