clone

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

HUD distance milestones + live photo key (promoted backlog)

Dami committed 0b332b0
Milestone toast: pure milestoneFor thresholds (100/250/500/750/1000 then
every 500), self-owned DOM toast, CUA-exposed. Photo: P re-renders and
downloads the canvas live (user rule: never freeze the run) in both
modes. 3 rule tests; browser-verified.
commit
0b332b0d3795a36971d616ee7eecb9b5c28c061e
parent
394cfd7577f472db0c29a7e072cbb472e676a02e
author
Dami <dami.etoile@gmail.com>
6 files changed, +198 -0
Mprogress.md11++++
Msrc/client/endless.ts24+++++++++
Asrc/client/hud.ts80++++++++++++++++++++++++++++++
Msrc/client/main.ts3+
Asrc/client/photo.ts43++++++++++++++++
Atests/hud_test.ts37++++++++++++++
M progress.md +11 -0
M src/client/endless.ts +24 -0
@@ -42,6 +42,8 @@ import type { DecorCatalog } from "../gen/decor.ts";
4242 import { genVersion } from "../gen/version.ts";4343 import { drawPipeColor } from "./pipeColor.ts";4444 import { PeerRing } from "./peers.ts";45+import { installPhotoKey } from "./photo.ts";46+import { milestoneFor, MilestoneToast } from "./hud.ts";4547 import { installCua } from "./cua.ts";4648 4749 // Per-tile physics modifiers + planner zones live in physics/rail.ts now
@@ -111,6 +113,12 @@ export function bootEndless(
111113 112114 let session: Session | null = null;113115 116+ // P = live photo export (user rule: never freeze the run).117+ const photo = installPhotoKey(118+ ctx,119+ () => `marbles-${session?.seed ?? "void"}-${Math.round(session?.marbleS ?? 0)}u`,120+ );121+114122 ctx.onFrame.add((dt) => {115123 session?.update(dt);116124 cams.update(dt);
@@ -184,6 +192,8 @@ export function bootEndless(
184192 // Peer ring lifecycle (marble ring): live count + relative arcs.185193 peers: session.peerRing.liveCount,186194 peerRing: session.peerRing.stats(session.marbleS),195+ milestone: session.toast.last,196+ photos: photo.count,187197 // Instancing gate: island pieces share draw calls now.188198 drawCalls: ctx.renderer.info.render.calls,189199 }
@@ -221,6 +231,8 @@ class Session {
221231 readonly atmosphere: Atmosphere;222232 /** The revolving ring of seeded NPC marbles around the player. */223233 readonly peerRing: PeerRing;234+ /** Distance milestone toast (thresholds pure in hud.ts). */235+ readonly toast = new MilestoneToast();224236 /** Box3D driver (null → kinematic rail rider, ?phys=0). */225237 private engine: PhysicsEngine | null = null;226238 private readonly rails = new Map<number, Rail>();
@@ -547,9 +559,18 @@ class Session {
547559 548560 update(dt: number): void {549561 if (dt <= 0) return;562+ const prevS = this.marbleS;550563 if (this.engine) this.physicsMove(dt);551564 else this.kinematicMove(dt);552565 566+ const milestone = milestoneFor(prevS, this.marbleS);567+ if (milestone !== null) {568+ this.toast.show(569+ `✦ ${milestone.toLocaleString("en-US")} TRAVELLED · ` +570+ `${this.islandsSeen} ISLANDS ✦`,571+ );572+ }573+553574 const next = this.segmentAt(this.marbleS);554575 if (next && next.index !== this.currentIndex) {555576 this.currentIndex = next.index;
@@ -752,6 +773,8 @@ class Session {
752773 return false;753774 });754775 776+ this.toast.update(dt);777+755778 // The follow camera reads marble.s off the proxy we handed it.756779 this.syncCameraProxy();757780
@@ -795,6 +818,7 @@ class Session {
795818 this.exiting = [];796819 this.atmosphere.dispose();797820 this.peerRing.dispose();821+ this.toast.dispose();798822 this.ctx.scene.remove(this.root);799823 this.ctx.scene.remove(this.marbleObj);800824 this.cams.retargetPath(null, null);
A src/client/hud.ts +80 -0
@@ -0,0 +1,80 @@
1+/**2+ * HUD distance milestones (backlog): a quiet toast when the journey3+ * crosses a distance threshold. Thresholds are deterministic from4+ * distance alone (pure `milestoneFor`, headless-tested); the toast is a5+ * self-owned DOM element so endless sessions can dispose cleanly.6+ */7+8+/** Milestone thresholds: landmarks early, then every 500 units. */9+export function milestoneFor(prevS: number, s: number): number | null {10+ if (s <= prevS) return null;11+ const fixed = [100, 250, 500, 750, 1000];12+ let best: number | null = null;13+ for (const m of fixed) {14+ if (prevS < m && m <= s) best = m;15+ }16+ // After 1000: every 500.17+ const from = Math.max(1000, prevS);18+ const step = Math.floor(from / 500) * 500 + 500;19+ for (let m = step; m <= s; m += 500) {20+ if (prevS < m) best = m;21+ }22+ return best;23+}24+25+const SHOW_TIME = 2.6;26+const FADE = 0.45;27+28+export class MilestoneToast {29+ private el: HTMLDivElement;30+ private clock = 0;31+ private visible = false;32+ /** Last text shown (CUA/tests). */33+ last = "";34+35+ constructor() {36+ this.el = document.createElement("div");37+ this.el.id = "milestone";38+ this.el.dataset.hud = "";39+ Object.assign(this.el.style, {40+ position: "fixed",41+ top: "18%",42+ left: "50%",43+ transform: "translateX(-50%)",44+ zIndex: "10",45+ font: "600 15px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",46+ letterSpacing: "0.14em",47+ color: "#dce6f5",48+ background: "rgba(14, 20, 32, 0.55)",49+ border: "1px solid rgba(220, 230, 245, 0.25)",50+ borderRadius: "8px",51+ padding: "8px 18px",52+ opacity: "0",53+ transition: `opacity ${FADE}s ease`,54+ pointerEvents: "none",55+ backdropFilter: "blur(6px)",56+ });57+ document.body.appendChild(this.el);58+ }59+60+ show(text: string): void {61+ this.last = text;62+ this.el.textContent = text;63+ this.el.style.opacity = "1";64+ this.visible = true;65+ this.clock = 0;66+ }67+68+ update(dt: number): void {69+ if (!this.visible) return;70+ this.clock += dt;71+ if (this.clock >= SHOW_TIME) {72+ this.el.style.opacity = "0";73+ this.visible = false;74+ }75+ }76+77+ dispose(): void {78+ this.el.remove();79+ }80+}
M src/client/main.ts +3 -0
A src/client/photo.ts +43 -0
A tests/hud_test.ts +37 -0