clone

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

Main menu: mode + racer count + boost tuning knobs; advanced fold for legacy toggles

Dami committed fefc475
Settings live on a main menu (user rule), not in-game panels. Primary:
endless/loop and racer count. The new autopilot knobs — boost speed
increase (?bmult, the burn wishspeed multiplier) and boost fuel
efficiency (?beff, burn-rate divisor) — are global BoostTuning layered
tangentially on the per-pilot boost personality; the envelope still
caps wishspeed so no knob value can break cornering. A folded advanced
section carries the mostly-legacy toggles (physics marble, autopilot
law, debug overlay, asset gallery).

Routing: the menu owns bare urls and ?menu=1; every seed link boots
straight into the game (shared URLs + CUA harnesses unaffected). START
writes explicit params (always endless=) and keeps the hash; choices
persist in localStorage as prefill; a HUD menu button returns with
seed + session settings intact.

Verified in-browser: bare url -> menu; sliders -> START ->
?endless=1&peers=3&bmult=2.00&beff=1.50 with race telemetry showing
n=4 and tuning {2, 1.5} live in the physics; seed link skips the menu;
menu button reopens it; 0 console errors. 115 tests green.
commit
fefc475f6ab38651e29e57ab402f3606e429c892
parent
f3ce236907735158dd8b9c4630a58d746fb708d0
author
Dami <dami.etoile@gmail.com>
6 files changed, +335 -6
Mprogress.md13+
Msrc/client/endless.ts23++-
Msrc/client/main.ts17++-
Asrc/client/menu.ts266++++++++++++++++++++++++++++++
Msrc/client/physics/autopilot.ts20++-
Mstatic/index.html2+
M progress.md +13 -0
M src/client/endless.ts +21 -2
@@ -42,6 +42,8 @@ import {
4242 autopilotForce,4343 type BoostState,4444 boostStep,45+ type BoostTuning,46+ DEFAULT_TUNING,4547 FULL_TANK,4648 type Neighbor,4749 type Pilot,
@@ -338,6 +340,19 @@ class Session {
338340 /** Leader-cam (M15): OFF by default — the camera follows the hero (who339341 * carries the trail); SPACE elects chasing the race leader instead. */340342 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+ })();341356 /** The pivot the trail rides — whichever marble the camera follows. */342357 private followedPivot: THREE.Object3D;343358 /** Racer idx the camera currently follows (leader-cam hysteresis). */
@@ -551,6 +566,7 @@ class Session {
551566 n: number;552567 leader: number;553568 leaderCam: boolean;569+ tuning: BoostTuning;554570 racers: {555571 idx: number;556572 s: number;
@@ -566,6 +582,7 @@ class Session {
566582 n: this.racers.length,567583 leader: leader.idx,568584 leaderCam: this.leaderCam,585+ tuning: this.tuning,569586 racers: this.racers.map((r) => ({570587 idx: r.idx,571588 s: Number(r.s.toFixed(1)),
@@ -816,8 +833,8 @@ class Session {
816833 const milestone = milestoneFor(prevS, this.marbleS);817834 if (milestone !== null) {818835 this.toast.show(819- `✦ ${milestone.toLocaleString("en-US")} TRAVELLED · ` +820- `${this.islandsSeen} ISLANDS ✦`,836+ `${milestone.toLocaleString("en-US")}u\n ` +837+ `${this.islandsSeen} islands`,821838 );822839 this.audio.cue("milestone");823840 }
@@ -920,6 +937,7 @@ class Session {
920937 headroom,921938 gapAhead,922939 gapBehind,940+ this.tuning,923941 );924942 f = autopilotForce(925943 rail,
@@ -932,6 +950,7 @@ class Session {
932950 r.dbg,933951 others,934952 r.boost.boosting,953+ this.tuning,935954 );936955 } else {937956 f = stationForce(
M src/client/main.ts +16 -1
@@ -24,6 +24,7 @@ import { installPhotoKey } from "./photo.ts";
2424 import { bootEndless } from "./endless.ts";2525 import { createTrail } from "./trail.ts";2626 import { installDevUi } from "./devui.ts";27+import { maybeShowMenu } from "./menu.ts";2728 2829 const params = new URLSearchParams(location.search);2930 const GALLERY = params.get("gallery") === "1";
@@ -41,14 +42,27 @@ const modeBtn = document.getElementById("mode-toggle") as
4142 | HTMLButtonElement4243 | null;4344 45+// Main menu (settings entry point): owns bare urls and ?menu=1; every46+// shared seed link boots straight into the game.47+const MENU_ACTIVE = maybeShowMenu();48+4449 // Mode switch is a real navigation (search param changes, hash seed kept).4550 if (modeBtn) {4651 modeBtn.textContent = ENDLESS ? "loop mode" : "endless mode";4752 modeBtn.addEventListener("click", () => {48- const search = ENDLESS ? "?endless=0" : "";53+ const search = ENDLESS ? "?endless=0" : "?endless=1";4954 location.href = location.pathname + search + location.hash;5055 });5156 }57+// Back to the menu, keeping the seed and this session's settings.58+const menuBtn = document.getElementById("menu-btn") as59+ | HTMLButtonElement60+ | null;61+menuBtn?.addEventListener("click", () => {62+ const q = new URLSearchParams(location.search);63+ q.set("menu", "1");64+ location.href = `${location.pathname}?${q.toString()}${location.hash}`;65+});5266 5367 installDevUi();5468
@@ -340,6 +354,7 @@ function boot(assets: Assets): void {
340354 // ---------------------------------------------------------------------------341355 342356 loadAssets().then(async (assets) => {357+ if (MENU_ACTIVE) return; // the menu owns the page; START navigates343358 if (GALLERY) buildGallery(assets);344359 else if (ENDLESS) {345360 // Physics is the endless default (?phys=0 falls back to the kinematic
A src/client/menu.ts +266 -0
@@ -0,0 +1,266 @@
1+/**2+ * Main menu — the entry point for settings (user rule: settings belong3+ * on a MAIN MENU, not in-game panels).4+ *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).10+ *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.17+ */18+19+/** Settings the menu writes into the url (read by main.ts / endless.ts). */20+export interface MenuChoices {21+ endless: boolean;22+ 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;27+ physics: boolean;28+ autopilot: boolean;29+ debug: boolean;30+ gallery: boolean;31+}32+33+export const MENU_DEFAULTS: MenuChoices = {34+ endless: true,35+ peers: 5,36+ bmult: 1.55,37+ beff: 1,38+ physics: true,39+ autopilot: true,40+ debug: false,41+ gallery: false,42+};43+44+const STORE_KEY = "marbles-menu";45+46+function loadStored(): Partial<MenuChoices> {47+ try {48+ return JSON.parse(localStorage.getItem(STORE_KEY) ?? "{}");49+ } catch {50+ return {};51+ }52+}53+54+/** Prefill: url params (a ?menu=1 revisit keeps the session's settings)55+ * override stored choices override defaults. */56+function prefill(params: URLSearchParams): MenuChoices {57+ const stored = { ...MENU_DEFAULTS, ...loadStored() };58+ const num = (key: string, fallback: number): number => {59+ const v = Number.parseFloat(params.get(key) ?? "");60+ return Number.isFinite(v) ? v : fallback;61+ };62+ return {63+ endless: params.has("endless")64+ ? params.get("endless") !== "0"65+ : stored.endless,66+ 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),69+ physics: params.has("phys") ? params.get("phys") !== "0" : stored.physics,70+ autopilot: params.has("autopilot")71+ ? params.get("autopilot") !== "0"72+ : stored.autopilot,73+ debug: params.has("debug") ? params.get("debug") === "1" : stored.debug,74+ gallery: params.has("gallery")75+ ? params.get("gallery") === "1"76+ : stored.gallery,77+ };78+}79+80+function urlFor(c: MenuChoices, hash: string): string {81+ const q = new URLSearchParams();82+ // Always explicit — a bare url means "show the menu".83+ q.set("endless", c.endless ? "1" : "0");84+ if (c.endless) {85+ 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));88+ if (!c.physics) q.set("phys", "0");89+ if (!c.autopilot) q.set("autopilot", "0");90+ }91+ if (c.debug) q.set("debug", "1");92+ if (c.gallery) q.set("gallery", "1");93+ return `${location.pathname}?${q.toString()}${hash}`;94+}95+96+/** Renders the menu when the url asks for it. Returns true when the menu97+ * owns the page (the caller must NOT boot the game). */98+export function maybeShowMenu(): boolean {99+ const params = new URLSearchParams(location.search);100+ const bare = location.search === "" && location.hash === "";101+ if (!bare && params.get("menu") !== "1") return false;102+103+ const c = prefill(params);104+ const root = document.createElement("div");105+ root.id = "menu";106+ root.innerHTML = `107+ <style>108+ #menu {109+ 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;115+ color: #dce6f5;116+ }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);124+ }125+ #menu h1 {126+ margin: 0 0 4px; font-size: 22px; letter-spacing: 0.14em;127+ font-weight: 600;128+ }129+ #menu .sub {130+ margin: 0 0 20px; color: rgba(220, 230, 245, 0.5); font-size: 12px;131+ }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;137+ }138+ #menu .modes button.on {139+ border-color: #7fd4ff; color: #7fd4ff;140+ background: rgba(127, 212, 255, 0.1);141+ }142+ #menu label.row {143+ display: block; margin: 14px 0 4px; font-size: 12px;144+ color: rgba(220, 230, 245, 0.75);145+ }146+ #menu label.row .val { float: right; color: #7fd4ff; }147+ #menu input[type="range"] { width: 100%; accent-color: #7fd4ff; }148+ #menu details {149+ margin-top: 18px; border-top: 1px solid rgba(220, 230, 245, 0.12);150+ padding-top: 10px;151+ }152+ #menu summary {153+ cursor: pointer; font-size: 12px; color: rgba(220, 230, 245, 0.55);154+ }155+ #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);158+ }159+ #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;166+ }167+ #menu .start:hover { background: rgba(127, 212, 255, 0.24); }168+ #menu .endless-only.off { opacity: 0.35; pointer-events: none; }169+ </style>170+ <div class="panel">171+ <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>187+ </div>188+ <details>189+ <summary>advanced</summary>190+ <div class="adv">191+ <label class="endless-only"><input type="checkbox" id="menu-phys"> physics marble (off = kinematic rider)</label>192+ <label class="endless-only"><input type="checkbox" id="menu-ap"> autopilot law (off = legacy assist)</label>193+ <label><input type="checkbox" id="menu-debug"> debug overlay</label>194+ <label><input type="checkbox" id="menu-gallery"> asset gallery</label>195+ </div>196+ </details>197+ <button class="start" id="menu-start">START</button>198+ </div>199+ `;200+ document.body.appendChild(root);201+202+ const el = <T extends HTMLElement>(id: string): T =>203+ 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");209+ const phys = el<HTMLInputElement>("menu-phys");210+ const ap = el<HTMLInputElement>("menu-ap");211+ const debug = el<HTMLInputElement>("menu-debug");212+ const gallery = el<HTMLInputElement>("menu-gallery");213+214+ 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);219+ }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;230+ };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);249+ 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();256+257+ el("menu-start").addEventListener("click", () => {258+ try {259+ localStorage.setItem(STORE_KEY, JSON.stringify(c));260+ } catch {261+ // storage may be unavailable (private mode) — the url carries it all262+ }263+ location.replace(urlFor(c, location.hash));264+ });265+ return true;266+}
M src/client/physics/autopilot.ts +17 -3
@@ -110,8 +110,20 @@ export function pilotFor(seed: string, index = 0): Pilot {
110110 // permits it). Aggro scales the trigger ranges: personalities differ in111111 // WHEN they spend, which is what makes the race a contest.112112 113-/** Wishspeed multiplier while boosting. */113+/** Wishspeed multiplier while boosting (DEFAULT_TUNING.mult). */114114 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 };115127 /** Full tank burns in ~3s. */116128 const BOOST_DRAIN = 1 / 3;117129 /** Empty tank refills in ~12s. */
@@ -148,9 +160,10 @@ export function boostStep(
148160 headroom: number | null,149161 gapAhead: number | null,150162 gapBehind: number | null,163+ tuning: BoostTuning = DEFAULT_TUNING,151164 ): BoostState {152165 if (st.boosting) {153- const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN);166+ const fuel = Math.max(0, st.fuel - dt * BOOST_DRAIN / tuning.efficiency);154167 const capped = headroom !== null && headroom < 0.1;155168 return { fuel, boosting: fuel > 0.02 && !capped };156169 }
@@ -322,6 +335,7 @@ export function autopilotForce(
322335 others?: readonly Neighbor[],323336 /** Fuel burn (boostStep): raises the target inside the same envelope. */324337 boosting = false,338+ tuning: BoostTuning = DEFAULT_TUNING,325339 ): [number, number, number] | null {326340 const t = rail.tans[idx];327341 if (!t) return null;
@@ -354,7 +368,7 @@ export function autopilotForce(
354368 355369 // --- speed planning: online backward brake sweep over the lookahead ---356370 const sHere = rail.s[idx];357- const targetHere = CRUISE * (mod?.speed ?? 1) * (boosting ? BOOST_MULT : 1);371+ const targetHere = CRUISE * (mod?.speed ?? 1) * (boosting ? tuning.mult : 1);358372 let vSafe = VMAX;359373 let climbTy = t[1];360374 let prev: readonly [number, number, number] = t;
M static/index.html +2 -0