Two user corrections. (1) Boost speed increase and fuel efficiency are NOT user knobs — they are pilot personality traits: pilotFor gains boostMult (1.35–1.75) and efficiency (0.8–1.25) as appended draws, tangential to aggro/regen; BoostTuning and the ?bmult/?beff params are gone. Every racer now boosts with its own strength and burn economy. (2) The menu is a TITLE SCREEN page, not a web-ui overlay: opaque void with a drifting starfield and a bobbing marble orb; game-menu rows driven by arrow keys + enter (mouse works too) — MODE, RACERS, SEED, START, with the legacy toggles under a folded ADVANCED. SEED is free text (any string becomes the seed via the hash; blank mints random). When the menu owns the page the game never boots (no scene, HUD hidden). Routing contract unchanged: bare url or ?menu=1 only; seed links boot straight to the game. Verified in-browser: keyboard-only flow (navigate, set racers, type 'my custom seed!', enter) lands in the game with that exact seed and hero+4 racers; 0 console errors. 115 tests green.
- commit
- c4fefc24823b3f11127aac041b58d4ad2bcf93ab
- parent
- fefc475f6ab38651e29e57ab402f3606e429c892
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 29 | +- |
| M | src/ | 19 | -- |
| M | src/ | 10 | +- |
| M | src/ | 377 | ++++++++++++++++++++---------- |
| M | src/ | 33 | +- |
@@ -1234,18 +1234,23 @@ orders genuinely shuffle. NOTE race-order measurement pitfall: racers
12341234 reaching the built rail's END all ratchet to the same s — sample order12351235 BEFORE the horizon (multimarble test stops at endS−6).12361236 1237-MAIN MENU (user rule: settings belong on a MAIN MENU, not in-game1238-panels — src/client/menu.ts): primary choices are mode (endless/loop)1239-and racer count; the boost tuning knobs (speed increase ?bmult, fuel1240-efficiency ?beff — BoostTuning, global, tangential to per-pilot1241-aggro/regen) sit with them; a folded "advanced" section carries the1242-legacy toggles (phys/autopilot/debug/gallery). ROUTING CONTRACT: the1243-menu owns BARE urls and ?menu=1 only — every seed link (hash/params)1244-boots straight to the game, so shared URLs and CUA harnesses keep1245-working; START always writes an explicit endless= param (a bare1246-post-start url would loop back to the menu); choices persist in1247-localStorage as bare-visit prefill; the HUD "menu" button returns via1248-?menu=1 keeping seed + settings.1237+MAIN MENU (user rules: settings belong on a MAIN MENU; it is a TITLE1238+SCREEN page, "game feel, not a web ui", NOT an overlay —1239+src/client/menu.ts): opaque void page with drifting starfield + bobbing1240+marble orb; game-menu rows navigated by ↑↓ (cursor), ◂▸ (change),1241+ENTER (start); mouse works too. Rows: MODE endless/loop, RACERS1242+hero+N, SEED (free-text input, any string, blank = mint random); folded1243+ADVANCED carries the legacy toggles (phys/autopilot/debug/gallery).1244+Boost speed increase + fuel efficiency are NOT user knobs (user1245+correction) — they are PILOT PERSONALITY traits (pilotFor appended1246+draws boostMult 1.35–1.75, efficiency 0.8–1.25, tangential to1247+aggro/regen). ROUTING CONTRACT: the menu owns BARE urls and ?menu=11248+only — every seed link (hash/params) boots straight to the game1249+(shared URLs + CUA harnesses unaffected); when the menu is active the1250+game never boots (no scene, HUD hidden — it is a separate page); START1251+always writes an explicit endless= param; choices persist in1252+localStorage; the HUD "menu" button returns via ?menu=1 keeping seed +1253+settings.12491254 12501255 REMAINING / NEXT IDEAS: marble-marble collision-immunity window on12511256 respawn (shim filter bits — deferred from M15); rubber-banding via pilot@@ -42,8 +42,6 @@ import {
4242 autopilotForce,4343 type BoostState,4444 boostStep,45- type BoostTuning,46- DEFAULT_TUNING,4745 FULL_TANK,4846 type Neighbor,4947 type Pilot,@@ -340,19 +338,6 @@ class Session {
340338 /** Leader-cam (M15): OFF by default — the camera follows the hero (who341339 * carries the trail); SPACE elects chasing the race leader instead. */342340 private leaderCam = false;343- /** User boost tuning from the main menu (?bmult / ?beff), clamped;344- * global, layered on the per-pilot boost personality. */345- private readonly tuning: BoostTuning = (() => {346- const q = new URLSearchParams(location.search);347- const num = (k: string, fb: number, lo: number, hi: number): number => {348- const v = Number.parseFloat(q.get(k) ?? "");349- return Number.isFinite(v) ? Math.max(lo, Math.min(hi, v)) : fb;350- };351- return {352- mult: num("bmult", DEFAULT_TUNING.mult, 1, 2.5),353- efficiency: num("beff", DEFAULT_TUNING.efficiency, 0.25, 3),354- };355- })();356341 /** The pivot the trail rides — whichever marble the camera follows. */357342 private followedPivot: THREE.Object3D;358343 /** Racer idx the camera currently follows (leader-cam hysteresis). */@@ -566,7 +551,6 @@ class Session {
566551 n: number;567552 leader: number;568553 leaderCam: boolean;569- tuning: BoostTuning;570554 racers: {571555 idx: number;572556 s: number;@@ -582,7 +566,6 @@ class Session {
582566 n: this.racers.length,583567 leader: leader.idx,584568 leaderCam: this.leaderCam,585- tuning: this.tuning,586569 racers: this.racers.map((r) => ({587570 idx: r.idx,588571 s: Number(r.s.toFixed(1)),@@ -937,7 +920,6 @@ class Session {
937920 headroom,938921 gapAhead,939922 gapBehind,940- this.tuning,941923 );942924 f = autopilotForce(943925 rail,@@ -950,7 +932,6 @@ class Session {
950932 r.dbg,951933 others,952934 r.boost.boosting,953- this.tuning,954935 );955936 } else {956937 f = stationForce(@@ -66,7 +66,15 @@ menuBtn?.addEventListener("click", () => {
6666 6767 installDevUi();6868 69-const ctx = createScene(container);69+// The menu is its own PAGE (title screen): no game scene, no HUD — the70+// render loop never starts and START navigates to a game url. The71+// non-null trick is safe: every ctx consumer is behind !MENU_ACTIVE.72+if (MENU_ACTIVE) {73+ const hud = document.getElementById("hud");74+ if (hud) hud.hidden = true;75+ if (statsEl) statsEl.hidden = true;76+}77+const ctx = MENU_ACTIVE ? null! : createScene(container);7078 7179 function setStats(text: string): void {7280 if (statsEl) statsEl.textContent = text;@@ -1,29 +1,28 @@
11 /**2- * Main menu — the entry point for settings (user rule: settings belong3- * on a MAIN MENU, not in-game panels).2+ * Main menu — a TITLE SCREEN, not a web ui (user rule): the menu is its3+ * own page (fully opaque void + drifting starfield; the game never4+ * renders behind it), navigated like a game menu — up/down moves the5+ * cursor, left/right changes values, enter starts.46 *5- * Primary choices: mode (endless / loop) and racer peer count. The6- * autopilot's boost tuning (speed increase + fuel efficiency) sits with7- * them — global knobs layered tangentially on the per-pilot boost8- * personality. A folded "advanced" section carries the mostly-legacy9- * URL toggles (physics, autopilot law, debug overlay, asset gallery).7+ * Primary choices: mode (endless / loop), racer count, and a free-text8+ * SEED (any text; blank mints a random one). A folded "advanced"9+ * section carries the mostly-legacy URL toggles (physics, autopilot10+ * law, debug overlay, asset gallery). Boost speed and fuel efficiency11+ * are NOT here — they are pilot personality traits (autopilot.ts12+ * pilotFor), not user settings.1013 *11- * Routing contract: the menu shows on a BARE url (no search, no hash)12- * or on ?menu=1; every shared seed link and harness url (hash and/or13- * params present) boots straight into the game, so shared URLs and the14- * CUA soaks keep working. START always writes an explicit `endless`15- * param, so the post-start url is never bare. Choices persist in16- * localStorage as prefill for the next bare visit.14+ * Routing contract: the menu owns a BARE url (no search, no hash) and15+ * ?menu=1; every shared seed link and harness url (hash and/or params16+ * present) boots straight into the game, so shared URLs and the CUA17+ * soaks keep working. START always writes an explicit `endless` param,18+ * so the post-start url is never bare. Choices persist in localStorage19+ * as prefill for the next bare visit.1720 */1821 1922 /** Settings the menu writes into the url (read by main.ts / endless.ts). */2023 export interface MenuChoices {2124 endless: boolean;2225 peers: number;23- /** Boost speed increase (wishspeed multiplier while burning). */24- bmult: number;25- /** Boost fuel efficiency (burn-rate divisor; 2 = tank lasts twice). */26- beff: number;2726 physics: boolean;2827 autopilot: boolean;2928 debug: boolean;@@ -33,8 +32,6 @@ export interface MenuChoices {
3332 export const MENU_DEFAULTS: MenuChoices = {3433 endless: true,3534 peers: 5,36- bmult: 1.55,37- beff: 1,3835 physics: true,3936 autopilot: true,4037 debug: false,@@ -64,8 +61,6 @@ function prefill(params: URLSearchParams): MenuChoices {
6461 ? params.get("endless") !== "0"6562 : stored.endless,6663 peers: Math.max(0, Math.min(15, Math.round(num("peers", stored.peers)))),67- bmult: num("bmult", stored.bmult),68- beff: num("beff", stored.beff),6964 physics: params.has("phys") ? params.get("phys") !== "0" : stored.physics,7065 autopilot: params.has("autopilot")7166 ? params.get("autopilot") !== "0"@@ -77,22 +72,30 @@ function prefill(params: URLSearchParams): MenuChoices {
7772 };7873 }7974 80-function urlFor(c: MenuChoices, hash: string): string {75+function urlFor(c: MenuChoices, seed: string): string {8176 const q = new URLSearchParams();8277 // Always explicit — a bare url means "show the menu".8378 q.set("endless", c.endless ? "1" : "0");8479 if (c.endless) {8580 if (c.peers !== MENU_DEFAULTS.peers) q.set("peers", String(c.peers));86- if (c.bmult !== MENU_DEFAULTS.bmult) q.set("bmult", c.bmult.toFixed(2));87- if (c.beff !== MENU_DEFAULTS.beff) q.set("beff", c.beff.toFixed(2));8881 if (!c.physics) q.set("phys", "0");8982 if (!c.autopilot) q.set("autopilot", "0");9083 }9184 if (c.debug) q.set("debug", "1");9285 if (c.gallery) q.set("gallery", "1");86+ const hash = seed === "" ? "" : `#${encodeURIComponent(seed)}`;9387 return `${location.pathname}?${q.toString()}${hash}`;9488 }9589 90+/** Current seed from the hash (returning via ?menu=1 keeps it). */91+function currentSeed(): string {92+ try {93+ return decodeURIComponent(location.hash.slice(1)).trim();94+ } catch {95+ return location.hash.slice(1).trim();96+ }97+}98+9699 /** Renders the menu when the url asks for it. Returns true when the menu97100 * owns the page (the caller must NOT boot the game). */98101 export function maybeShowMenu(): boolean {@@ -107,86 +110,126 @@ export function maybeShowMenu(): boolean {
107110 <style>108111 #menu {109112 position: fixed; inset: 0; z-index: 40;110- display: flex; align-items: center; justify-content: center;111- background:112- radial-gradient(ellipse at 30% 20%, rgba(50, 80, 140, 0.25), transparent 60%),113- rgba(10, 14, 24, 0.92);114- font: 14px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;113+ background: radial-gradient(ellipse at 50% 118%,114+ #1a2b4a 0%, #101a30 40%, #0a0e18 100%);115+ font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;115116 color: #dce6f5;117+ display: flex; align-items: center; justify-content: center;118+ overflow: hidden;116119 }117- #menu .panel {118- width: min(420px, calc(100vw - 48px));119- background: rgba(14, 20, 32, 0.85);120- border: 1px solid rgba(220, 230, 245, 0.22);121- border-radius: 12px;122- padding: 26px 28px;123- backdrop-filter: blur(10px);120+ #menu canvas.stars { position: absolute; inset: 0; }121+ #menu .col {122+ position: relative; text-align: center;123+ display: flex; flex-direction: column; align-items: center;124+ }125+ #menu .orb {126+ width: 64px; height: 64px; border-radius: 50%;127+ background: radial-gradient(circle at 34% 30%,128+ #e8f7ff 0%, #9fdcff 28%, #7fd4ff 45%, #2a6ea8 90%);129+ box-shadow: 0 0 42px rgba(127, 212, 255, 0.45);130+ margin-bottom: 22px;131+ animation: menu-bob 4.2s ease-in-out infinite;132+ }133+ menu-bob {134+ 0%, 100% { transform: translateY(0); }135+ 50% { transform: translateY(-9px); }124136 }125137 #menu h1 {126- margin: 0 0 4px; font-size: 22px; letter-spacing: 0.14em;127- font-weight: 600;138+ margin: 0; font-size: 44px; font-weight: 600;139+ letter-spacing: 0.42em; text-indent: 0.42em;140+ text-shadow: 0 0 26px rgba(127, 212, 255, 0.35);128141 }129142 #menu .sub {130- margin: 0 0 20px; color: rgba(220, 230, 245, 0.5); font-size: 12px;143+ margin: 6px 0 44px; font-size: 12px; letter-spacing: 0.18em;144+ color: rgba(220, 230, 245, 0.45);131145 }132- #menu .modes { display: flex; gap: 8px; margin-bottom: 18px; }133- #menu .modes button {134- flex: 1; padding: 10px 0; font: inherit; cursor: pointer;135- background: rgba(220, 230, 245, 0.06); color: inherit;136- border: 1px solid rgba(220, 230, 245, 0.25); border-radius: 8px;146+ #menu .rows { display: flex; flex-direction: column; gap: 6px; }147+ #menu .row {148+ display: grid; grid-template-columns: 24px 110px 240px;149+ align-items: center; text-align: left;150+ padding: 7px 10px; border-radius: 6px; cursor: pointer;151+ color: rgba(220, 230, 245, 0.75);137152 }138- #menu .modes button.on {139- border-color: #7fd4ff; color: #7fd4ff;140- background: rgba(127, 212, 255, 0.1);153+ #menu .row .cursor { color: #7fd4ff; visibility: hidden; }154+ #menu .row.sel { background: rgba(127, 212, 255, 0.07); color: #eaf4ff; }155+ #menu .row.sel .cursor { visibility: visible; }156+ #menu .row.off { opacity: 0.35; pointer-events: none; }157+ #menu .row .k {158+ font-size: 12px; letter-spacing: 0.14em;159+ color: rgba(220, 230, 245, 0.5);141160 }142- #menu label.row {143- display: block; margin: 14px 0 4px; font-size: 12px;144- color: rgba(220, 230, 245, 0.75);161+ #menu .row.sel .k { color: rgba(220, 230, 245, 0.8); }162+ #menu .val { display: flex; align-items: center; gap: 10px; }163+ #menu .val .arrow {164+ cursor: pointer; color: rgba(127, 212, 255, 0.5); user-select: none;165+ }166+ #menu .val .arrow:hover { color: #7fd4ff; }167+ #menu .val .v { flex: 1; text-align: center; color: #7fd4ff; }168+ #menu input#menu-seed {169+ width: 100%; box-sizing: border-box;170+ background: transparent; border: none; outline: none;171+ border-bottom: 1px solid rgba(127, 212, 255, 0.35);172+ color: #7fd4ff; font: inherit; text-align: center;173+ padding: 2px 0; letter-spacing: 0.06em;174+ }175+ #menu input#menu-seed::placeholder { color: rgba(127, 212, 255, 0.3); }176+ #menu .row.start-row {177+ grid-template-columns: 24px 1fr; margin-top: 26px; text-align: center;178+ }179+ #menu .row.start-row .k {180+ font-size: 17px; letter-spacing: 0.34em; text-indent: 0.34em;181+ color: #7fd4ff; text-align: center;145182 }146- #menu label.row .val { float: right; color: #7fd4ff; }147- #menu input[type="range"] { width: 100%; accent-color: #7fd4ff; }183+ #menu .row.start-row.sel .k { text-shadow: 0 0 16px rgba(127, 212, 255, 0.6); }148184 #menu details {149- margin-top: 18px; border-top: 1px solid rgba(220, 230, 245, 0.12);150- padding-top: 10px;185+ margin-top: 34px; font-size: 11px;186+ color: rgba(220, 230, 245, 0.4);151187 }152- #menu summary {153- cursor: pointer; font-size: 12px; color: rgba(220, 230, 245, 0.55);188+ #menu summary { cursor: pointer; letter-spacing: 0.14em; }189+ #menu .adv {190+ display: flex; flex-direction: column; gap: 4px; margin-top: 8px;191+ align-items: flex-start;154192 }155193 #menu .adv label {156- display: flex; gap: 8px; align-items: center;157- margin: 8px 0; font-size: 12px; color: rgba(220, 230, 245, 0.7);194+ display: flex; gap: 8px; align-items: center; cursor: pointer;195+ color: rgba(220, 230, 245, 0.55);158196 }159197 #menu .adv input { accent-color: #7fd4ff; }160- #menu .start {161- margin-top: 22px; width: 100%; padding: 12px 0;162- font: inherit; font-size: 15px; letter-spacing: 0.2em;163- cursor: pointer; border-radius: 8px;164- background: rgba(127, 212, 255, 0.14); color: #7fd4ff;165- border: 1px solid #7fd4ff;198+ #menu .hint {199+ position: absolute; bottom: 18px; left: 0; right: 0;200+ text-align: center; font-size: 10px; letter-spacing: 0.2em;201+ color: rgba(220, 230, 245, 0.25);166202 }167- #menu .start:hover { background: rgba(127, 212, 255, 0.24); }168- #menu .endless-only.off { opacity: 0.35; pointer-events: none; }169203 </style>170- <div class="panel">204+ <canvas class="stars"></canvas>205+ <div class="col">206+ <div class="orb"></div>171207 <h1>MARBLES</h1>172- <p class="sub">seeded marble racing in the void</p>173- <div class="modes">174- <button id="menu-endless">endless</button>175- <button id="menu-loop">loop</button>176- </div>177- <div class="endless-only" id="menu-endless-rows">178- <label class="row">racers · hero + <span class="val" id="menu-peers-val"></span>179- <input type="range" id="menu-peers" min="0" max="15" step="1">180- </label>181- <label class="row">boost speed increase <span class="val" id="menu-bmult-val"></span>182- <input type="range" id="menu-bmult" min="1" max="2.2" step="0.05">183- </label>184- <label class="row">boost fuel efficiency <span class="val" id="menu-beff-val"></span>185- <input type="range" id="menu-beff" min="0.5" max="2" step="0.05">186- </label>208+ <p class="sub">SEEDED RACING IN THE VOID</p>209+ <div class="rows">210+ <div class="row" data-row="mode">211+ <span class="cursor">▸</span><span class="k">MODE</span>212+ <span class="val"><span class="arrow" data-dir="-1">◂</span>213+ <span class="v" id="menu-mode-v"></span>214+ <span class="arrow" data-dir="1">▸</span></span>215+ </div>216+ <div class="row endless-only" data-row="racers">217+ <span class="cursor">▸</span><span class="k">RACERS</span>218+ <span class="val"><span class="arrow" data-dir="-1">◂</span>219+ <span class="v" id="menu-peers-v"></span>220+ <span class="arrow" data-dir="1">▸</span></span>221+ </div>222+ <div class="row" data-row="seed">223+ <span class="cursor">▸</span><span class="k">SEED</span>224+ <span class="val"><input id="menu-seed" placeholder="random"225+ spellcheck="false" autocomplete="off"></span>226+ </div>227+ <div class="row start-row" data-row="start">228+ <span class="cursor">▸</span><span class="k">START</span>229+ </div>187230 </div>188231 <details>189- <summary>advanced</summary>232+ <summary>ADVANCED</summary>190233 <div class="adv">191234 <label class="endless-only"><input type="checkbox" id="menu-phys"> physics marble (off = kinematic rider)</label>192235 <label class="endless-only"><input type="checkbox" id="menu-ap"> autopilot law (off = legacy assist)</label>@@ -194,73 +237,149 @@ export function maybeShowMenu(): boolean {
194237 <label><input type="checkbox" id="menu-gallery"> asset gallery</label>195238 </div>196239 </details>197- <button class="start" id="menu-start">START</button>198240 </div>241+ <div class="hint">↑↓ SELECT · ◂▸ CHANGE · ENTER START</div>199242 `;200243 document.body.appendChild(root);201244 245+ // Drifting starfield — the menu is a place, not a form.246+ const canvas = root.querySelector("canvas.stars") as HTMLCanvasElement;247+ const cx = canvas.getContext("2d");248+ interface Star {249+ x: number;250+ y: number;251+ r: number;252+ v: number;253+ a: number;254+ }255+ let stars: Star[] = [];256+ const seedStars = () => {257+ canvas.width = innerWidth;258+ canvas.height = innerHeight;259+ stars = Array.from({ length: 140 }, () => ({260+ x: Math.random() * canvas.width,261+ y: Math.random() * canvas.height,262+ r: 0.4 + Math.random() * 1.2,263+ v: 2 + Math.random() * 7,264+ a: 0.25 + Math.random() * 0.6,265+ }));266+ };267+ seedStars();268+ addEventListener("resize", seedStars);269+ let last = performance.now();270+ let alive = true;271+ const drift = (now: number) => {272+ if (!alive) return;273+ const dt = Math.min(0.1, (now - last) / 1000);274+ last = now;275+ if (cx) {276+ cx.clearRect(0, 0, canvas.width, canvas.height);277+ for (const st of stars) {278+ st.y += st.v * dt;279+ if (st.y > canvas.height + 2) {280+ st.y = -2;281+ st.x = Math.random() * canvas.width;282+ }283+ cx.globalAlpha = st.a;284+ cx.fillStyle = "#cfe6ff";285+ cx.beginPath();286+ cx.arc(st.x, st.y, st.r, 0, Math.PI * 2);287+ cx.fill();288+ }289+ }290+ requestAnimationFrame(drift);291+ };292+ requestAnimationFrame(drift);293+202294 const el = <T extends HTMLElement>(id: string): T =>203295 root.querySelector(`#${id}`) as T;204- const endlessBtn = el<HTMLButtonElement>("menu-endless");205- const loopBtn = el<HTMLButtonElement>("menu-loop");206- const peers = el<HTMLInputElement>("menu-peers");207- const bmult = el<HTMLInputElement>("menu-bmult");208- const beff = el<HTMLInputElement>("menu-beff");296+ const seedInput = el<HTMLInputElement>("menu-seed");297+ seedInput.value = currentSeed();209298 const phys = el<HTMLInputElement>("menu-phys");210299 const ap = el<HTMLInputElement>("menu-ap");211300 const debug = el<HTMLInputElement>("menu-debug");212301 const gallery = el<HTMLInputElement>("menu-gallery");302+ phys.checked = c.physics;303+ ap.checked = c.autopilot;304+ debug.checked = c.debug;305+ gallery.checked = c.gallery;306+ phys.addEventListener("change", () => (c.physics = phys.checked));307+ ap.addEventListener("change", () => (c.autopilot = ap.checked));308+ debug.addEventListener("change", () => (c.debug = debug.checked));309+ gallery.addEventListener("change", () => (c.gallery = gallery.checked));310+311+ const rows = ["mode", "racers", "seed", "start"] as const;312+ type Row = typeof rows[number];313+ let sel: Row = "start";314+315+ const rowEl = (row: Row): HTMLElement =>316+ root.querySelector(`.row[data-row="${row}"]`) as HTMLElement;213317 214318 const sync = () => {215- endlessBtn.classList.toggle("on", c.endless);216- loopBtn.classList.toggle("on", !c.endless);217- for (const row of root.querySelectorAll(".endless-only")) {218- row.classList.toggle("off", !c.endless);319+ el("menu-mode-v").textContent = c.endless ? "endless" : "loop";320+ el("menu-peers-v").textContent = `hero + ${c.peers}`;321+ for (const r of root.querySelectorAll(".endless-only")) {322+ r.classList.toggle("off", !c.endless);219323 }220- peers.value = String(c.peers);221- el("menu-peers-val").textContent = String(c.peers);222- bmult.value = String(c.bmult);223- el("menu-bmult-val").textContent = `×${c.bmult.toFixed(2)}`;224- beff.value = String(c.beff);225- el("menu-beff-val").textContent = `×${c.beff.toFixed(2)}`;226- phys.checked = c.physics;227- ap.checked = c.autopilot;228- debug.checked = c.debug;229- gallery.checked = c.gallery;324+ if (!c.endless && sel === "racers") sel = "mode";325+ for (const row of rows) rowEl(row).classList.toggle("sel", row === sel);326+ if (sel === "seed") seedInput.focus();327+ else seedInput.blur();230328 };231- endlessBtn.addEventListener("click", () => {232- c.endless = true;233- sync();234- });235- loopBtn.addEventListener("click", () => {236- c.endless = false;237- sync();238- });239- peers.addEventListener("input", () => {240- c.peers = Number.parseInt(peers.value, 10);241- sync();242- });243- bmult.addEventListener("input", () => {244- c.bmult = Number.parseFloat(bmult.value);245- sync();246- });247- beff.addEventListener("input", () => {248- c.beff = Number.parseFloat(beff.value);329+330+ const adjust = (row: Row, dir: number) => {331+ if (row === "mode") c.endless = !c.endless;332+ else if (row === "racers") {333+ c.peers = Math.max(0, Math.min(15, c.peers + dir));334+ }249335 sync();250- });251- phys.addEventListener("change", () => (c.physics = phys.checked));252- ap.addEventListener("change", () => (c.autopilot = ap.checked));253- debug.addEventListener("change", () => (c.debug = debug.checked));254- gallery.addEventListener("change", () => (c.gallery = gallery.checked));255- sync();336+ };256337 257- el("menu-start").addEventListener("click", () => {338+ const start = () => {258339 try {259340 localStorage.setItem(STORE_KEY, JSON.stringify(c));260341 } catch {261342 // storage may be unavailable (private mode) — the url carries it all262343 }263- location.replace(urlFor(c, location.hash));344+ location.replace(urlFor(c, seedInput.value.trim()));345+ };346+347+ for (const row of rows) {348+ const e = rowEl(row);349+ e.addEventListener("mouseenter", () => {350+ sel = row;351+ sync();352+ });353+ e.addEventListener("click", (ev) => {354+ const arrow = (ev.target as HTMLElement).closest(".arrow");355+ if (arrow) {356+ adjust(row, Number.parseInt(arrow.getAttribute("data-dir")!, 10));357+ return;358+ }359+ if (row === "start") start();360+ else if (row !== "seed") adjust(row, 1);361+ });362+ }363+364+ addEventListener("keydown", (e) => {365+ if (!root.isConnected) return;366+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {367+ e.preventDefault();368+ const live = rows.filter((r) => r !== "racers" || c.endless);369+ const i = live.indexOf(sel);370+ const n = live.length;371+ sel = live[(i + (e.key === "ArrowDown" ? 1 : n - 1)) % n];372+ sync();373+ } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {374+ if (sel === "seed") return; // the input owns its caret375+ e.preventDefault();376+ adjust(sel, e.key === "ArrowRight" ? 1 : -1);377+ } else if (e.key === "Enter") {378+ e.preventDefault();379+ start();380+ }264381 });382+383+ sync();265384 return true;266385 }@@ -50,6 +50,12 @@ export interface Pilot {
5050 * the same cycle and the pack surged in unison — regen spread turns5151 * the waves into duels. */5252 regen: number;53+ /** Boost speed increase: this pilot's wishspeed multiplier while54+ * burning (the envelope still caps it — corner-safe at any value). */55+ boostMult: number;56+ /** Boost fuel efficiency: divides the burn rate (1.25 = the tank57+ * lasts 25% longer at the same speed). */58+ efficiency: number;5359 /** Lateral offset target from the rail line (world units; drivers set5460 * this per step from pilotOffset() — 0 rides the line). */5561 offset: number;@@ -66,6 +72,8 @@ export const DEFAULT_PILOT: Pilot = {
6672 eagerness: 1,6773 aggro: 1,6874 regen: 1,75+ boostMult: 1.55,76+ efficiency: 1,6977 offset: 0,7078 wanderAmp: 0,7179 phase1: 0,@@ -85,12 +93,16 @@ export function pilotFor(seed: string, index = 0): Pilot {
8593 const phase2 = rng.float() * Math.PI * 2;8694 const aggro = 0.7 + rng.float() * 0.6; // appended draw (boost round)8795 const regen = 0.75 + rng.float() * 0.5; // appended draw (boost round)96+ const boostMult = 1.35 + rng.float() * 0.4; // appended draw97+ const efficiency = 0.8 + rng.float() * 0.45; // appended draw8898 return {8999 bravery,90100 lookGain,91101 eagerness,92102 aggro,93103 regen,104+ boostMult,105+ efficiency,94106 offset: 0,95107 wanderAmp,96108 phase1,@@ -110,20 +122,9 @@ export function pilotFor(seed: string, index = 0): Pilot {
110122 // permits it). Aggro scales the trigger ranges: personalities differ in111123 // WHEN they spend, which is what makes the race a contest.112124 113-/** Wishspeed multiplier while boosting (DEFAULT_TUNING.mult). */125+/** Baseline boost wishspeed multiplier (DEFAULT_PILOT.boostMult;126+ * pilotFor draws each racer's own around it). */114127 export const BOOST_MULT = 1.55;115-116-/** User-facing boost tuning (main-menu knobs) — GLOBAL, layered117- * tangentially on the per-pilot personality (aggro/regen): `mult` is the118- * boost speed increase, `efficiency` divides the burn rate (2 = a tank119- * lasts twice as long). The envelope still caps wishspeed, so no tuning120- * value can break cornering safety. */121-export interface BoostTuning {122- mult: number;123- efficiency: number;124-}125-126-export const DEFAULT_TUNING: BoostTuning = { mult: BOOST_MULT, efficiency: 1 };127128 /** Full tank burns in ~3s. */128129 const BOOST_DRAIN = 1 / 3;129130 /** Empty tank refills in ~12s. */@@ -160,10 +161,9 @@ export function boostStep(
160161 headroom: number | null,161162 gapAhead: number | null,162163 gapBehind: number | null,163- tuning: BoostTuning = DEFAULT_TUNING,164164 ): BoostState {165165 if (st.boosting) {166- const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN / tuning.efficiency);166+ const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN / pilot.efficiency);167167 const capped = headroom !== null && headroom < 0.1;168168 return { fuel, boosting: fuel > 0.02 && !capped };169169 }@@ -335,7 +335,6 @@ export function autopilotForce(
335335 others?: readonly Neighbor[],336336 /** Fuel burn (boostStep): raises the target inside the same envelope. */337337 boosting = false,338- tuning: BoostTuning = DEFAULT_TUNING,339338 ): [number, number, number] | null {340339 const t = rail.tans[idx];341340 if (!t) return null;@@ -368,7 +367,7 @@ export function autopilotForce(
368367 369368 // --- speed planning: online backward brake sweep over the lookahead ---370369 const sHere = rail.s[idx];371- const targetHere = CRUISE * (mod?.speed ?? 1) * (boosting ? tuning.mult : 1);370+ const targetHere = CRUISE * (mod?.speed ?? 1) * (boosting ? pilot.boostMult : 1);372371 let vSafe = VMAX;373372 let climbTy = t[1];374373 let prev: readonly [number, number, number] = t;