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 * Race place readout (leaderboard, user spec): at MOST `#3/128` — the8 * hero's place out of all racers. The counting is pure (`racePlace`,9 * headless-tested); `PlaceHud` owns its element like the toast does.10 *11 * Surface styling matches the shared playback dock.12 */1314/** A quiet translucent surface that leaves the track visible. */15function applyFrame(el: HTMLElement): void {16 Object.assign(el.style, {17 background: "rgba(13, 28, 41, 0.84)",18 border: "1px solid rgba(193, 225, 224, 0.2)",19 borderRadius: "10px",20 backdropFilter: "blur(12px)",21 });22}2324/** Milestone thresholds: landmarks early, then every 500 units. */25export function milestoneFor(prevS: number, s: number): number | null {26 if (s <= prevS) return null;27 const fixed = [100, 250, 500, 750, 1000];28 let best: number | null = null;29 for (const m of fixed) {30 if (prevS < m && m <= s) best = m;31 }32 // After 1000: every 500.33 const from = Math.max(1000, prevS);34 const step = Math.floor(from / 500) * 500 + 500;35 for (let m = step; m <= s; m += 500) {36 if (prevS < m) best = m;37 }38 return best;39}4041const SHOW_TIME = 2.6;42const FADE = 0.45;4344export class MilestoneToast {45 private el: HTMLDivElement;46 private clock = 0;47 private visible = false;48 /** Last text shown (CUA/tests). */49 last = "";5051 constructor() {52 this.el = document.createElement("div");53 this.el.id = "milestone";54 this.el.dataset.hud = "";55 Object.assign(this.el.style, {56 position: "fixed",57 top: "88px",58 left: "50%",59 transform: "translateX(-50%)",60 zIndex: "10",61 fontFamily: "inherit",62 fontSize: "13px",63 fontWeight: "600",64 lineHeight: "1.5",65 whiteSpace: "pre-line",66 textAlign: "center",67 color: "#dce6f5",68 padding: "10px 16px",69 opacity: "0",70 transition: `opacity ${FADE}s ease`,71 pointerEvents: "none",72 backdropFilter: "blur(6px)",73 });74 applyFrame(this.el);75 document.body.appendChild(this.el);76 }7778 show(text: string): void {79 this.last = text;80 this.el.textContent = text;81 this.el.style.opacity = "1";82 this.visible = true;83 this.clock = 0;84 }8586 update(dt: number): void {87 if (!this.visible) return;88 this.clock += dt;89 if (this.clock >= SHOW_TIME) {90 this.el.style.opacity = "0";91 this.visible = false;92 }93 }9495 dispose(): void {96 this.el.remove();97 }98}99100/** Hero's race place: 1 + racers strictly ahead of it. Pass every101 * racer's progress — the hero's own s is never strictly ahead of102 * itself, so including it is harmless. Ties share the better place. */103export function racePlace(heroS: number, all: Iterable<number>): number {104 let place = 1;105 for (const s of all) if (s > heroS) place++;106 return place;107}108109/** Minimal leaderboard (user spec): a fixed `#3/128` readout, nothing110 * more. Hidden until a real race exists; the session owns the element111 * and disposes it with the run. */112export class PlaceHud {113 private el: HTMLDivElement;114 /** Last text shown (CUA/tests); "" while hidden. */115 last = "";116117 constructor() {118 this.el = document.createElement("div");119 this.el.id = "place";120 this.el.dataset.hud = "";121 Object.assign(this.el.style, {122 position: "fixed",123 right: "24px",124 top: "76px",125 zIndex: "10",126 font: "600 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",127 letterSpacing: "0.04em",128 color: "#dce6f5",129 padding: "8px 12px",130 opacity: "0",131 transition: "opacity 0.3s ease",132 pointerEvents: "none",133 });134 applyFrame(this.el);135 document.body.appendChild(this.el);136 }137138 /** Show `#{place}/{n}` (only touches the DOM when the text changes).139 * `rivalPlace` (M20b, flag-gated by the caller) appends the hero's140 * grudge target's place as one quiet token: `#3/128 · R#7`. */141 set(place: number, n: number, rivalPlace?: number): void {142 const text = rivalPlace !== undefined143 ? `#${place}/${n} · R#${rivalPlace}`144 : `#${place}/${n}`;145 if (text !== this.last) {146 this.last = text;147 this.el.textContent = text;148 }149 this.el.style.opacity = "1";150 }151152 hide(): void {153 if (this.last === "") return;154 this.last = "";155 this.el.style.opacity = "0";156 }157158 dispose(): void {159 this.el.remove();160 }161}