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>
| M | progress.md | 11 | ++++ |
| M | src/ | 24 | +++++++++ |
| A | src/ | 80 | ++++++++++++++++++++++++++++++ |
| M | src/ | 3 | + |
| A | src/ | 43 | ++++++++++++++++ |
| A | tests/ | 37 | ++++++++++++++ |
@@ -812,6 +812,17 @@ zones (icy needs a visible surface treatment first — zones must READ).
812812 813813 **DONE this round (post-M12, user-directed):**814814 815+- **HUD DISTANCE MILESTONES + PHOTO KEY** (promoted backlog): milestone816+ toast (src/client/hud.ts — pure milestoneFor: landmarks 100/250/500/817+ 750/1000 then every 500; MilestoneToast self-owned DOM, fades, CUA818+ `milestone`); endless Session shows "✦ N TRAVELLED · K ISLANDS ✦" on819+ crossing. PHOTO: user rule "don't freeze — P exports an image LIVE":820+ src/client/photo.ts installPhotoKey re-renders and downloads the821+ canvas as marbles-<seed>-<distance>u.png (HUD is DOM so captures are822+ clean; the sim never pauses). Wired in BOTH modes; CUA `photos`.823+ 3 rule tests (tests/hud_test.ts); browser-verified (milestone fired,824+ photo count incremented).825+815826 - **FULL-HELIX SPIRAL DIVES IN THE VOID** (promoted backlog): new816827 planner clause tryHelixDive (path.ts, weight 0.5, one per segment) —817828 the loop-mode full-helix ensemble (HELIX_LOOP_SPECS, approach corner +@@ -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);@@ -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+}@@ -20,6 +20,7 @@ import type { Track } from "../shared/types.ts";
2020 import { genVersion } from "../gen/version.ts";2121 import { installCua } from "./cua.ts";2222 import { installInspect } from "./inspect.ts";23+import { installPhotoKey } from "./photo.ts";2324 import { bootEndless } from "./endless.ts";2425 2526 const params = new URLSearchParams(location.search);@@ -182,6 +183,8 @@ function boot(assets: Assets): void {
182183 let lastSeed = "";183184 184185 const cams = createCameras(ctx, null, null);186+ // P = live photo export (shared with endless mode).187+ installPhotoKey(ctx, () => `marbles-${lastSeed || "loop"}`);185188 const inspector = installInspect(ctx, () => ({186189 seed: lastSeed,187190 track: lastTrack,@@ -0,0 +1,43 @@
1+/**2+ * Photo key (backlog, user rule: never freeze the run): `P` exports a3+ * still of the live spectacle. The WebGL canvas holds only the scene (the4+ * HUD is DOM), so a capture is already clean; the scene is re-rendered5+ * immediately before reading the canvas because the default framebuffer6+ * is not preserved between frames (preserveDrawingBuffer: false).7+ */8+import type { SceneCtx } from "./scene.ts";9+10+export interface PhotoKey {11+ /** Shots taken this session (CUA/tests). */12+ readonly count: number;13+ dispose(): void;14+}15+16+export function installPhotoKey(17+ ctx: SceneCtx,18+ filename: () => string,19+): PhotoKey {20+ let count = 0;21+ const onKey = (ev: KeyboardEvent) => {22+ if (ev.key !== "p" && ev.key !== "P") return;23+ ctx.renderer.render(ctx.scene, ctx.camera);24+ ctx.renderer.domElement.toBlob((blob: Blob | null) => {25+ if (!blob) return;26+ const a = document.createElement("a");27+ a.href = URL.createObjectURL(blob);28+ a.download = `${filename()}.png`;29+ a.click();30+ URL.revokeObjectURL(a.href);31+ }, "image/png");32+ count++;33+ };34+ addEventListener("keydown", onKey);35+ return {36+ get count() {37+ return count;38+ },39+ dispose() {40+ removeEventListener("keydown", onKey);41+ },42+ };43+}@@ -0,0 +1,37 @@
1+/**2+ * HUD distance milestones: thresholds are a pure function of the arc3+ * interval crossed this frame (hud.ts milestoneFor).4+ */5+import { assert, assertEquals } from "@std/assert";6+import { milestoneFor } from "../src/client/hud.ts";7+8+Deno.test("milestones: landmark thresholds then every 500", () => {9+ assertEquals(milestoneFor(99, 100.5), 100);10+ assertEquals(milestoneFor(100, 101), null);11+ assertEquals(milestoneFor(240, 260), 250);12+ assertEquals(milestoneFor(999, 1001), 1000);13+ assertEquals(milestoneFor(1200, 1300), null);14+ assertEquals(milestoneFor(1499, 1500), 1500);15+ assertEquals(milestoneFor(7400, 7501), 7500);16+ // A big skip reports the LAST threshold crossed.17+ assertEquals(milestoneFor(0, 2600), 2500);18+});19+20+Deno.test("milestones: never fire backward or in place", () => {21+ assertEquals(milestoneFor(500, 500), null);22+ assertEquals(milestoneFor(600, 400), null);23+ // Respawn jump backward then forward again re-crosses honestly.24+ assert(milestoneFor(400, 501) === 500);25+});26+27+Deno.test("milestones: dense stepping never skips a threshold", () => {28+ let s = 0;29+ const seen: number[] = [];30+ while (s < 3000) {31+ const next = s + 0.9;32+ const m = milestoneFor(s, next);33+ if (m !== null) seen.push(m);34+ s = next;35+ }36+ assertEquals(seen, [100, 250, 500, 750, 1000, 1500, 2000, 2500, 3000].filter((m) => m <= s));37+});