User feedback: a camera that silently switches subjects is confusing as a default, and the trail (on the hero) was never on screen. Default is now hero-follow; SPACE elects leader-cam (toast confirms). The trail rides whichever marble the camera follows, resetting on handover. Lead changes get 0.75u hysteresis — the soak showed neck-and-neck racers alternating the top spot every few frames, flickering the shot and resetting the trail endlessly. Verified in-browser: full trail in both modes, stable subject, 0 console errors.
- commit
- c4eac0b20c5b80a382f35ee3ee9258cb7eef043d
- parent
- 183245158fc94678aa6d1ccecbb25b1fd6ca46f7
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 7 | +++ |
| M | src/ | 63 | +++++++++++++++++++++++++----- |
@@ -1210,6 +1210,13 @@ M13 handoff below). Browser soak recipe: PORT=8907 deno task serve +
12101210 scratch playwright soak.mjs (advanceTime(60000) per sim-minute; state12111211 JSON now carries `race` telemetry).12121212 1213+USER FEEDBACK APPLIED: leader-cam is OPT-IN (SPACE toggles hero-cam ↔1214+leader-cam, toast confirms) — the default follows the hero, who carries1215+the trail; the trail rides the CAMERA SUBJECT and resets on handover,1216+with 0.75u hysteresis on lead changes (neck-and-neck racers alternate1217+the top spot every few frames; without hysteresis the shot flickered1218+and the trail reset endlessly).1219+12131220 REMAINING / NEXT IDEAS: marble-marble collision-immunity window on12141221 respawn (shim filter bits — deferred from M15); rubber-banding via pilot12151222 skill params (never thrust — Melder); race HUD (leaderboard strip, name@@ -162,6 +162,13 @@ export function bootEndless(
162162 }163163 addEventListener("keydown", (e) => {164164 if (e.key === "m" || e.key === "M") toggleAudio();165+ // SPACE elects leader-cam (M15): the camera chases the race leader166+ // instead of the hero marble. Hero-follow is the default — a camera167+ // that silently switches subjects mid-run reads as confusing.168+ if (e.code === "Space") {169+ e.preventDefault();170+ session?.toggleLeaderCam();171+ }165172 });166173 167174 // P = live photo export (user rule: never freeze the run).@@ -319,6 +326,13 @@ class Session {
319326 private racers: Racer[] = [];320327 /** Real racer-peer count (0 outside physics-autopilot mode). */321328 private realPeerCount = 0;329+ /** Leader-cam (M15): OFF by default — the camera follows the hero (who330+ * carries the trail); SPACE elects chasing the race leader instead. */331+ private leaderCam = false;332+ /** The pivot the trail rides — whichever marble the camera follows. */333+ private followedPivot: THREE.Object3D;334+ /** Racer idx the camera currently follows (leader-cam hysteresis). */335+ private followedIdx = 0;322336 private readonly rails = new Map<number, Rail>();323337 /** Active diamond branch per segment — respawns alternate it, so a324338 * fall's retry may ride the OTHER branch (respawn variety). */@@ -406,6 +420,7 @@ class Session {
406420 this.pivot.add(mesh);407421 }408422 this.trail.setWidth(this.radius * 0.6);423+ this.followedPivot = this.pivot;409424 ctx.scene.add(this.marbleObj);410425 ctx.scene.add(this.trail.obj);411426 // Ring size is session config, not seed material: ?peers=N (0 turns@@ -505,6 +520,7 @@ class Session {
505520 raceStats(): {506521 n: number;507522 leader: number;523+ leaderCam: boolean;508524 racers: { idx: number; s: number; speed: number }[];509525 } | null {510526 if (this.racers.length === 0) return null;@@ -513,6 +529,7 @@ class Session {
513529 return {514530 n: this.racers.length,515531 leader: leader.idx,532+ leaderCam: this.leaderCam,516533 racers: this.racers.map((r) => ({517534 idx: r.idx,518535 s: Number(r.s.toFixed(1)),@@ -973,17 +990,42 @@ class Session {
973990 return;974991 }975992 976- // Leader-cam: the camera follows whoever leads the race; the HUD977- // speed reads the leader too. Lead changes are the narrative.978- let leader = this.racers[0];979- for (const r of this.racers) if (r.s > leader.s) leader = r;980- this.speed = leader.speed;981- this.marbleS = Math.max(this.marbleS, leader.s);993+ // Camera subject: the hero by default; the race leader when the user994+ // elected leader-cam (SPACE). The trail follows the same subject.995+ // Hysteresis on lead changes: neck-and-neck racers alternate the top996+ // spot every few frames — the camera only hands over when someone997+ // clearly leads the current subject (0.75u), or the shot flickers998+ // and the trail resets endlessly.999+ let followed = this.racers[0];1000+ if (this.leaderCam) {1001+ const current = this.racers.find((r) => r.idx === this.followedIdx) ??1002+ followed;1003+ let top = current;1004+ for (const r of this.racers) if (r.s > top.s) top = r;1005+ followed = top.s > current.s + 0.75 ? top : current;1006+ }1007+ this.followedIdx = followed.idx;1008+ this.speed = followed.speed;1009+ this.marbleS = Math.max(this.marbleS, followed.s);9821010 if (this.cameraProxy) {983- this.cameraProxy.obj = leader.obj;1011+ this.cameraProxy.obj = followed.obj;9841012 this.cameraProxy.s = this.marbleS;9851013 this.cameraProxy.speed = this.speed;9861014 }1015+ if (this.followedPivot !== followed.pivot) {1016+ this.followedPivot = followed.pivot;1017+ this.trail.reset();1018+ }1019+ }1020+1021+ /** SPACE toggle: hero-follow (default) ↔ leader-cam. */1022+ toggleLeaderCam(): void {1023+ this.leaderCam = !this.leaderCam;1024+ if (this.racers.length > 0) {1025+ this.toast.show(1026+ this.leaderCam ? "LEADER CAM" : "HERO CAM",1027+ );1028+ }9871029 }9881030 9891031 /** Respawn a peer racer at the pack tail (never past the keep window's@@ -1154,10 +1196,11 @@ class Session {
11541196 }11551197 11561198 private finishFrame(dt: number): void {1157- // Trail follows the ball's center (the pivot), not the group origin1158- // that sits on the deck.1199+ // Trail follows the CAMERA SUBJECT's ball center (the hero by1200+ // default; the leader when leader-cam is elected) — a trail on an1201+ // off-screen marble is a trail nobody sees.11591202 this.trail.update(1160- this.pivot.getWorldPosition(this.trailPoint),1203+ this.followedPivot.getWorldPosition(this.trailPoint),11611204 this.ctx.camera,11621205 );11631206 // The marble's rolling voice follows its speed.