- racePlace (pure) + PlaceHud: minimal '#3/128' bottom-right, 4Hz refresh, hidden solo/kinematic, exposed in CUA state (USER SPEC: at most place/N — no strips, no lists). - UI skin from Kenney 'Fantasy UI Borders' (CC0): corner-tick 9-slice border-image pre-tinted to HUD ink, dark translucent backing — toast, place readout, HUD chrome, menu card. Web-radius look removed. - Rubber-banding (Melder/Game AI Pro — SKILL, never thrust): rubberMod modulates corner bravery (±8%) + jump eagerness (±25%) from a 2s-EMA race-position fraction; decide() routes all pilot reads through a per-racer scratch pilot (base identity untouched). Separation sees effective bravery, so trailing racers also shove braver.
- commit
- a8f0a2eb197c7f19948d06981bad460e1a1e46c7
- parent
- 10e347eb7e5b2d5c0362857d03290682b8be54f2
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 83 | ++++++++++++++++++++++++++---- |
| M | src/ | 83 | +++++++++++++++++++++++++++++- |
| M | src/ | 8 | +++- |
| M | src/ | 29 | ++++++++++ |
| M | static/ | 15 | +++-- |
| A | static/ | bin | |
| A | static/ | bin | |
| M | tests/ | 36 | +++++++++++++ |
| M | tests/ | 37 | ++++++++++++- |
@@ -49,6 +49,7 @@ import {
4949 type PilotDebug,5050 pilotFor,5151 pilotOffset,52+ rubberMod,5253 wantJump,5354 } from "./physics/autopilot.ts";5455 import { racerIdentity } from "./peersCore.ts";@@ -73,7 +74,7 @@ import { drawPipeColor } from "./pipeColor.ts";
7374 import { PeerRing } from "./peers.ts";7475 import { installPhotoKey } from "./photo.ts";7576 import { AmbientAudio } from "./audio.ts";76-import { milestoneFor, MilestoneToast } from "./hud.ts";77+import { milestoneFor, MilestoneToast, PlaceHud, racePlace } from "./hud.ts";7778 import { installCua } from "./cua.ts";7879 7980 // Per-tile physics modifiers + planner zones live in physics/rail.ts now@@ -165,7 +166,9 @@ export function bootEndless(
165166 "audio-toggle",166167 ) as HTMLButtonElement | null;167168 const setAudioLabel = () => {168- if (audioBtn) audioBtn.textContent = audio.enabled ? "audio: on" : "audio: off";169+ if (audioBtn) {170+ audioBtn.textContent = audio.enabled ? "audio: on" : "audio: off";171+ }169172 };170173 const toggleAudio = () => {171174 audio.toggle();@@ -190,7 +193,10 @@ export function bootEndless(
190193 // P = live photo export (user rule: never freeze the run).191194 const photo = installPhotoKey(192195 ctx,193- () => `marbles-${session?.seed ?? "void"}-${Math.round(session?.marbleS ?? 0)}u`,196+ () =>197+ `marbles-${session?.seed ?? "void"}-${198+ Math.round(session?.marbleS ?? 0)199+ }u`,194200 );195201 196202 ctx.onFrame.add((dt) => {@@ -271,6 +277,7 @@ export function bootEndless(
271277 peers: session.peerRing.liveCount,272278 peerRing: session.peerRing.stats(session.marbleS),273279 milestone: session.toast.last,280+ place: session.placeHud.last,274281 photos: photo.count,275282 audio: audio.enabled,276283 // Instancing gate: island pieces share draw calls now.@@ -280,10 +287,23 @@ export function bootEndless(
280287 }));281288 }282289 290+/** Place-readout refresh period (seconds) — 4Hz; the count is O(N). */291+const PLACE_REFRESH = 0.25;292+293+/** Rubber-banding EMA time constant (seconds) — rank order flaps in a294+ * tight pack; skill modulation follows the smoothed position. */295+const RUBBER_TAU = 2;296+283297 /** One physics racer (M15): the hero (idx 0) or a real peer marble. */284298 interface Racer {285299 idx: number;286300 pilot: Pilot;301+ /** Effective pilot this decision (rubber-banding writes skill-modulated302+ * bravery/eagerness here each decide; `pilot` is the base identity). */303+ pilotEff: Pilot;304+ /** Smoothed race-position fraction (0 = leading, 1 = last) — rank order305+ * flaps in a tight pack, so the modulation follows a ~2s EMA. */306+ rubber: number;287307 mass: number;288308 /** Scene objects — null for instanced peers (big packs render through289309 * one InstancedMesh instead of per-racer groups). */@@ -353,6 +373,10 @@ class Session {
353373 readonly peerRing: PeerRing;354374 /** Distance milestone toast (thresholds pure in hud.ts). */355375 readonly toast = new MilestoneToast();376+ /** Minimal leaderboard: `#place/N` (counting pure in hud.ts). */377+ readonly placeHud = new PlaceHud();378+ /** Place refresh throttle — the O(N) count runs at 4Hz, not 60. */379+ private placeClock = PLACE_REFRESH;356380 /** Box3D driver (null → kinematic rail rider, ?phys=0). */357381 private engine: PhysicsEngine | null = null;358382 /** The per-step control law: the autopilot (M14a) unless ?autopilot=0@@ -561,6 +585,8 @@ class Session {
561585 this.racers.push({562586 idx: 0,563587 pilot: pilotFor(seed, 0),588+ pilotEff: pilotFor(seed, 0),589+ rubber: 0.5,564590 mass: this.engine.massOf(0),565591 obj: this.marbleObj,566592 pivot: this.pivot,@@ -701,6 +727,8 @@ class Session {
701727 this.racers.push({702728 idx: i,703729 pilot: pilotFor(seed, i),730+ pilotEff: pilotFor(seed, i),731+ rubber: 0.5,704732 mass: this.engine.massOf(i),705733 obj,706734 pivot,@@ -1091,10 +1119,18 @@ class Session {
10911119 let f: [number, number, number] | null;10921120 if (this.useAutopilot) {10931121 r.simT += FIXED_DT * K;1094- r.pilot.offset = pilotOffset(r.pilot, r.simT);10951122 // Rank-window rivals: nearest distinct racer ahead/behind, and up10961123 // to four separation partners within 2 units of progress.10971124 const k0 = this.sorted.indexOf(r);1125+ // Rubber-banding through pilot SKILL (never thrust): the smoothed1126+ // race-position fraction modulates corner bravery + jump eagerness1127+ // on the scratch pilot. sorted is ascending in s — index 0 trails.1128+ const n2 = this.sorted.length;1129+ const behind = n2 > 1 ? 1 - k0 / (n2 - 1) : 0.5;1130+ r.rubber += (behind - r.rubber) *1131+ Math.min(1, (FIXED_DT * K) / RUBBER_TAU);1132+ rubberMod(r.pilot, r.rubber, r.pilotEff);1133+ r.pilotEff.offset = pilotOffset(r.pilot, r.simT);10981134 let gapAhead: number | null = null;10991135 let gapBehind: number | null = null;11001136 this.othersScratch.length = 0;@@ -1103,7 +1139,7 @@ class Session {
11031139 const d = o.s - r.s;11041140 if (gapAhead === null && d > 0.05) gapAhead = d;11051141 if (d < 2 && this.othersScratch.length < 4) {1106- this.othersScratch.push({ pos: o.pos, bravery: o.pilot.bravery });1142+ this.othersScratch.push({ pos: o.pos, bravery: o.pilotEff.bravery });11071143 }11081144 }11091145 for (let k = k0 - 1; k >= 0 && k >= k0 - 3; k--) {@@ -1111,15 +1147,16 @@ class Session {
11111147 const d = r.s - o.s;11121148 if (gapBehind === null && d > 0.05) gapBehind = d;11131149 if (d < 2 && this.othersScratch.length < 4) {1114- this.othersScratch.push({ pos: o.pos, bravery: o.pilot.bravery });1150+ this.othersScratch.push({ pos: o.pos, bravery: o.pilotEff.bravery });11151151 }11161152 }1117- const headroom = r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined1118- ? r.dbg.vSafe - r.dbg.wishspeed1119- : null;1153+ const headroom =1154+ r.dbg.vSafe !== undefined && r.dbg.wishspeed !== undefined1155+ ? r.dbg.vSafe - r.dbg.wishspeed1156+ : null;11201157 r.boost = boostStep(11211158 r.boost,1122- r.pilot,1159+ r.pilotEff,11231160 FIXED_DT * K,11241161 headroom,11251162 gapAhead,@@ -1132,12 +1169,19 @@ class Session {
11321169 r.vel,11331170 r.mass,11341171 nextRail,1135- r.pilot,1172+ r.pilotEff,11361173 r.dbg,11371174 this.othersScratch,11381175 r.boost.boosting,11391176 );1140- const j = wantJump(rail, r.nearest.idx, r.pos, r.vel, nextRail, r.pilot);1177+ const j = wantJump(1178+ rail,1179+ r.nearest.idx,1180+ r.pos,1181+ r.vel,1182+ nextRail,1183+ r.pilotEff,1184+ );11411185 if (j) {11421186 engine.jump(r.idx, j[0], j[1]);11431187 if (!r.wasHop && r.idx === 0) this.audio.cue("launch");@@ -1671,6 +1715,20 @@ class Session {
16711715 16721716 this.toast.update(dt);16731717 1718+ // Minimal leaderboard: `#place/N`, hero vs. everyone, throttled —1719+ // hidden unless a real race is on (peers exist).1720+ this.placeClock += dt;1721+ if (this.placeClock >= PLACE_REFRESH) {1722+ this.placeClock = 0;1723+ const hero = this.racers[0];1724+ if (hero && this.racers.length > 1) {1725+ this.placeHud.set(1726+ racePlace(hero.s, this.racers.map((r) => r.s)),1727+ this.racers.length,1728+ );1729+ } else this.placeHud.hide();1730+ }1731+16741732 // The follow camera reads marble.s off the proxy we handed it.16751733 this.syncCameraProxy();16761734 @@ -1742,6 +1800,7 @@ class Session {
17421800 this.atmosphere.dispose();17431801 this.peerRing.dispose();17441802 this.toast.dispose();1803+ this.placeHud.dispose();17451804 this.ctx.scene.remove(this.root);17461805 this.ctx.scene.remove(this.marbleObj);17471806 this.ctx.scene.remove(this.trail.obj);@@ -3,8 +3,26 @@
33 * crosses a distance threshold. Thresholds are deterministic from44 * distance alone (pure `milestoneFor`, headless-tested); the toast is a55 * 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+ * Skin: HUD chrome wears a 9-slice frame from Kenney's "Fantasy UI12+ * Borders" (CC0), pre-tinted to the HUD ink and served from static/ui/13+ * — pixel-game frame, not web widget.614 */715 16+/** Shared game-skin: 9-slice frame over a dark translucent backing.17+ * The sprite is 48px with 12px corners; `px` scales the drawn border. */18+function applyFrame(el: HTMLElement, px: number): void {19+ Object.assign(el.style, {20+ background: "rgba(10, 15, 26, 0.68)",21+ border: `${px}px solid transparent`,22+ borderImage: 'url("/ui/frame.png") 12 stretch',23+ });24+}25+826 /** Milestone thresholds: landmarks early, then every 500 units. */927 export function milestoneFor(prevS: number, s: number): number | null {1028 if (s <= prevS) return null;@@ -45,15 +63,13 @@ export class MilestoneToast {
4563 font: "600 15px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",4664 letterSpacing: "0.14em",4765 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",66+ padding: "2px 10px",5267 opacity: "0",5368 transition: `opacity ${FADE}s ease`,5469 pointerEvents: "none",5570 backdropFilter: "blur(6px)",5671 });72+ applyFrame(this.el, 12);5773 document.body.appendChild(this.el);5874 }5975 @@ -78,3 +94,62 @@ export class MilestoneToast {
7894 this.el.remove();7995 }8096 }97+98+/** Hero's race place: 1 + racers strictly ahead of it. Pass every99+ * racer's progress — the hero's own s is never strictly ahead of100+ * itself, so including it is harmless. Ties share the better place. */101+export function racePlace(heroS: number, all: Iterable<number>): number {102+ let place = 1;103+ for (const s of all) if (s > heroS) place++;104+ return place;105+}106+107+/** Minimal leaderboard (user spec): a fixed `#3/128` readout, nothing108+ * more. Hidden until a real race exists; the session owns the element109+ * and disposes it with the run. */110+export class PlaceHud {111+ private el: HTMLDivElement;112+ /** Last text shown (CUA/tests); "" while hidden. */113+ last = "";114+115+ constructor() {116+ this.el = document.createElement("div");117+ this.el.id = "place";118+ this.el.dataset.hud = "";119+ Object.assign(this.el.style, {120+ position: "fixed",121+ right: "14px",122+ bottom: "14px",123+ zIndex: "10",124+ font: "600 16px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",125+ letterSpacing: "0.12em",126+ color: "#dce6f5",127+ padding: "0 6px",128+ opacity: "0",129+ transition: "opacity 0.3s ease",130+ pointerEvents: "none",131+ });132+ applyFrame(this.el, 12);133+ document.body.appendChild(this.el);134+ }135+136+ /** Show `#{place}/{n}` (only touches the DOM when the text changes). */137+ set(place: number, n: number): void {138+ const text = `#${place}/${n}`;139+ if (text !== this.last) {140+ this.last = text;141+ this.el.textContent = text;142+ }143+ this.el.style.opacity = "1";144+ }145+146+ hide(): void {147+ if (this.last === "") return;148+ this.last = "";149+ this.el.style.opacity = "0";150+ }151+152+ dispose(): void {153+ this.el.remove();154+ }155+}@@ -143,7 +143,13 @@ export function maybeShowMenu(): boolean {
143143 margin: 6px 0 44px; font-size: 12px; letter-spacing: 0.18em;144144 color: rgba(220, 230, 245, 0.45);145145 }146- #menu .rows { display: flex; flex-direction: column; gap: 6px; }146+ #menu .rows {147+ display: flex; flex-direction: column; gap: 6px;148+ padding: 14px 18px;149+ background: rgba(8, 12, 22, 0.5);150+ border: 12px solid transparent;151+ border-image: url("/ui/frame.png") 12 stretch;152+ }147153 #menu .row {148154 display: grid; grid-template-columns: 24px 110px 240px;149155 align-items: center; text-align: left;@@ -177,6 +177,35 @@ export function boostStep(
177177 return { fuel, boosting: ignite };178178 }179179 180+// --- rubber-banding through pilot SKILL (Melder, Game AI Pro) --------------181+//182+// Never thrust: a trailing racer gets no extra engine — it gets a braver183+// PILOT. Corner margins widen (bravery multiplies the curvature-derived184+// corner speeds) and rescue jumps commit earlier (eagerness raises the185+// top-up ceiling); the leader's pilot turns conservative by the same law.186+// Capped so personality still dominates the modulation: at the extremes187+// corners swing ±8% and jump eagerness ±25% around the seed-drawn traits.188+189+/** Corner-speed swing at the field's extremes (fraction of bravery). */190+const RUBBER_BRAVERY = 0.16;191+/** Jump-eagerness swing at the extremes (fraction of eagerness). */192+const RUBBER_EAGERNESS = 0.5;193+194+/**195+ * Race-position → pilot-skill modulation. `behind` ∈ [0, 1]: 0 = leading196+ * the field, 1 = dead last, 0.5 = mid-pack (neutral — a lone racer or an197+ * exact midfielder keeps its base traits). Drivers smooth `behind` (rank198+ * order flaps in a tight pack) and write the result onto a per-racer199+ * scratch pilot each decision; `base` is never mutated.200+ */201+export function rubberMod(base: Pilot, behind: number, out: Pilot): Pilot {202+ const x = Math.min(1, Math.max(0, behind)) - 0.5;203+ Object.assign(out, base);204+ out.bravery = base.bravery * (1 + RUBBER_BRAVERY * x);205+ out.eagerness = base.eagerness * (1 + RUBBER_EAGERNESS * x);206+ return out;207+}208+180209 /** Lateral offset target at sim-time t — a slow two-tone wander (closed181210 * form: deterministic under fixed stepping, no draw-order coupling, and182211 * mean-reverting like an OU process without carrying state). Stays well@@ -4,7 +4,8 @@
44 <meta charset="utf-8" />55 <meta name="viewport" content="width=device-width, initial-scale=1" />66 <title>marbles</title>7- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%237fd4ff'/%3E%3Ccircle cx='11' cy='11' r='5' fill='%23e8f7ff'/%3E%3C/svg%3E" />7+ <link rel="icon"8+ href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%237fd4ff'/%3E%3Ccircle cx='11' cy='11' r='5' fill='%23e8f7ff'/%3E%3C/svg%3E" />89 <style>910 html,1011 body {@@ -28,13 +29,15 @@
2829 font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;2930 color: #dce6f5;3031 }32+ /* Game-skin chrome: Kenney 9-slice frame (CC0, tinted), not a web33+ widget — see static/ui/. */3134 #hud button,3235 #seed {33- background: rgba(14, 20, 32, 0.72);36+ background: rgba(10, 15, 26, 0.68);3437 color: #dce6f5;35- border: 1px solid rgba(220, 230, 245, 0.25);36- border-radius: 6px;37- padding: 6px 10px;38+ border: 6px solid transparent;39+ border-image: url("/ui/frame-dim.png") 12 stretch;40+ padding: 1px 5px;3841 font: inherit;3942 backdrop-filter: blur(6px);4043 }@@ -42,7 +45,7 @@
4245 cursor: pointer;4346 }4447 #hud button:hover {45- border-color: rgba(220, 230, 245, 0.6);48+ filter: brightness(1.45);4649 }4750 #seed {4851 user-select: all;Binary file.
Binary file.
@@ -11,7 +11,10 @@ import {
1111 boostStep,1212 DEFAULT_PILOT,1313 FULL_TANK,14+ type Pilot,1415 type PilotDebug,16+ pilotFor,17+ rubberMod,1518 wantJump,1619 } from "../src/client/physics/autopilot.ts";1720 @@ -239,6 +242,39 @@ function gapRail(): Rail {
239242 return railOf(ys, mods);240243 }241244 245+Deno.test("rubber-banding: skill modulation, never thrust", () => {246+ const base: Pilot = pilotFor("rubber-seed", 3);247+ const out = { ...DEFAULT_PILOT };248+249+ // Mid-pack (or lone racer) is exactly neutral.250+ rubberMod(base, 0.5, out);251+ assertEquals(out.bravery, base.bravery);252+ assertEquals(out.eagerness, base.eagerness);253+254+ // Trailing: braver corners + earlier jump commits, monotone in the255+ // deficit; leading: conservative by the same law.256+ rubberMod(base, 1, out);257+ const trailBrave = out.bravery;258+ const trailEager = out.eagerness;259+ assert(trailBrave > base.bravery);260+ assert(trailEager > base.eagerness);261+ rubberMod(base, 0, out);262+ assert(out.bravery < base.bravery);263+ assert(out.eagerness < base.eagerness);264+265+ // Capped: personality still dominates (≤ ±8% corners, ≤ ±25% jumps).266+ assert(trailBrave <= base.bravery * 1.08 + 1e-12);267+ assert(trailEager <= base.eagerness * 1.25 + 1e-12);268+269+ // Out-of-range positions clamp; other traits copy through untouched.270+ rubberMod(base, 7, out);271+ assertEquals(out.bravery, trailBrave);272+ assertEquals(out.lookGain, base.lookGain);273+ assertEquals(out.boostMult, base.boostMult);274+ // The base pilot is never mutated.275+ assertEquals(base, pilotFor("rubber-seed", 3));276+});277+242278 Deno.test("wantJump: fires only at the lip, only on a rescuable undershoot", () => {243279 const rail = gapRail();244280 const lip = 7;@@ -1,9 +1,10 @@
11 /**22 * HUD distance milestones: thresholds are a pure function of the arc3- * interval crossed this frame (hud.ts milestoneFor).3+ * interval crossed this frame (hud.ts milestoneFor). Race place is a4+ * pure count of racers strictly ahead (hud.ts racePlace).45 */56 import { assert, assertEquals } from "@std/assert";6-import { milestoneFor } from "../src/client/hud.ts";7+import { milestoneFor, racePlace } from "../src/client/hud.ts";78 89 Deno.test("milestones: landmark thresholds then every 500", () => {910 assertEquals(milestoneFor(99, 100.5), 100);@@ -33,5 +34,35 @@ Deno.test("milestones: dense stepping never skips a threshold", () => {
3334 if (m !== null) seen.push(m);3435 s = next;3536 }36- assertEquals(seen, [100, 250, 500, 750, 1000, 1500, 2000, 2500, 3000].filter((m) => m <= s));37+ assertEquals(38+ seen,39+ [100, 250, 500, 750, 1000, 1500, 2000, 2500, 3000].filter((m) => m <= s),40+ );41+});42+43+Deno.test("race place: 1 + racers strictly ahead", () => {44+ // Leading the pack.45+ assertEquals(racePlace(50, [50, 10, 30]), 1);46+ // Mid-pack: two ahead.47+ assertEquals(racePlace(30, [30, 50, 40, 10]), 3);48+ // Dead last.49+ assertEquals(racePlace(0, [0, 1, 2, 3]), 4);50+});51+52+Deno.test("race place: ties share the better place, hero self-safe", () => {53+ // Including the hero's own s never counts it as ahead of itself.54+ assertEquals(racePlace(25, [25]), 1);55+ // An exact tie is not "ahead" — both read the better place.56+ assertEquals(racePlace(25, [25, 25, 40]), 2);57+ // Solo race (no peers passed at all).58+ assertEquals(racePlace(7, []), 1);59+});60+61+Deno.test("race place: big field stays exact", () => {62+ const n = 9999;63+ const all = Array.from({ length: n }, (_, i) => i);64+ // Hero at progress 4999 -> 5000..9998 are ahead (4999 of them).65+ assertEquals(racePlace(4999, all), 5000);66+ assertEquals(racePlace(n - 1, all), 1);67+ assertEquals(racePlace(-1, all), n + 1);3768 });