Use a dedicated illustrated title screen with racer count and optional seed. Keep Classic loop, steering and diagnostics in Simulation options; name the Modern and Legacy steering choices explicitly. Remove redundant subtitles and the duplicate top-bar wordmark. Add visible camera, pause, fullscreen, share and settings controls with native keyboard behavior, responsive layouts and clear loading failure recovery. Hide hero/leader selection and its shortcut in solo or decorative-peer runs.
- commit
- bb28b82cc536eda960692c8eef76c9c6943f34e8
- parent
- 9784c75b23512b64a832ae2b4ec9e7d92619dbd3
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 5 | +- |
| M | src/ | 9 | +- |
| M | src/ | 46 | ++- |
| M | src/ | 42 | +- |
| M | src/ | 90 | +++- |
| M | src/ | 465 | ++++++---------------- |
| A | src/ | 70 | +++ |
| M | src/ | 15 | +- |
| M | static/ | 117 | ++---- |
| A | static/ | 639 | ++++++++++++++++++++++++++++++ |
@@ -26,13 +26,16 @@ export function installCua(ctx: SceneCtx, getState: () => CuaState): void {
2626 JSON.stringify({2727 coords: "y up; x/z horizontal grid, unit 1.0; rotations in quarter turns",2828 hash: location.hash,29+ paused: !!ctx.paused,2930 ...getState(),3031 });3132 3233 (globalThis as unknown as Window).advanceTime = (ms: number) => {3334 const steps = Math.max(1, Math.round(ms / (1000 / 60)));3435 for (let i = 0; i < steps; i++) {35- for (const fn of ctx.onFrame) fn(1 / 60, performance.now() / 1000);36+ for (const fn of ctx.onFrame) {37+ fn(ctx.paused ? 0 : 1 / 60, performance.now() / 1000);38+ }3639 }3740 ctx.renderer.render(ctx.scene, ctx.camera);3841 };@@ -1,8 +1,7 @@
1-// Dev-facing HUD elements (stats readout, seed, loop/endless mode switch,2-// audio + camera toggles) stay hidden until the tilde key reveals them —1+// Diagnostics stay hidden until the tilde key reveals them —32 // the same key that toggles inspect mode in loop mode, so ` means43 // "dev view" everywhere. The M/C shortcuts still work while hidden.5-const DEV_IDS = ["stats", "seed", "mode-toggle", "audio-toggle", "cam-mode"];4+const DEV_IDS = ["stats"];65 76 export function installDevUi(): void {87 const els = DEV_IDS@@ -14,6 +13,10 @@ export function installDevUi(): void {
1413 };1514 apply();1615 addEventListener("keydown", (e) => {16+ const target = e.target as HTMLElement | null;17+ if (18+ target?.isContentEditable || target?.closest("input, textarea, select")19+ ) return;1720 if (e.code === "Backquote") {1821 shown = !shown;1922 apply();@@ -102,6 +102,7 @@ import { installPhotoKey } from "./photo.ts";
102102 import { AmbientAudio } from "./audio.ts";103103 import { milestoneFor, MilestoneToast, PlaceHud, racePlace } from "./hud.ts";104104 import { installCua } from "./cua.ts";105+import { editingControl } from "./runControls.ts";105106 106107 // Per-tile physics modifiers + planner zones live in physics/rail.ts now107108 // (M11): ONE rail law shared with the headless survivability tests.@@ -163,6 +164,8 @@ interface UiRefs {
163164 statsEl: HTMLElement | null;164165 newTrackBtn: HTMLButtonElement | null;165166 camBtn: HTMLButtonElement | null;167+ onError?: (err: unknown) => void;168+ onReady?: () => void;166169 }167170 168171 export function bootEndless(@@ -193,12 +196,24 @@ export function bootEndless(
193196 });194197 const setCamLabel = (mode: CamMode) => {195198 if (ui.camBtn) {196- ui.camBtn.textContent = mode === "orbit" ? "follow cam" : "orbit cam";199+ ui.camBtn.textContent = mode === "orbit" ? "Follow cam" : "Orbit cam";200+ ui.camBtn.setAttribute("aria-pressed", String(mode === "follow"));197201 }198202 };199203 cams.onModeChange = setCamLabel;200204 201205 let session: Session | null = null;206+ const leaderBtn = document.getElementById("leader-cam");207+ const toggleLeader = () => {208+ if (!session?.canFollowLeader) return;209+ session?.toggleLeaderCam();210+ const active = session?.raceStats()?.leaderCam ?? false;211+ if (leaderBtn) {212+ leaderBtn.textContent = active ? "Race leader" : "Your marble";213+ leaderBtn.setAttribute("aria-pressed", String(active));214+ }215+ };216+ leaderBtn?.addEventListener("click", toggleLeader);202217 203218 // Ambient audio (M13): procedural mood pad + rolling voice + event204219 // cues. Muted until the user's toggle gesture (autoplay policy); the@@ -218,17 +233,20 @@ export function bootEndless(
218233 setAudioLabel();219234 };220235 if (audioBtn) {221- audioBtn.hidden = false;222236 audioBtn.addEventListener("click", toggleAudio);223237 }224238 addEventListener("keydown", (e) => {239+ if (240+ editingControl(e.target) || e.repeat || e.metaKey || e.ctrlKey || e.altKey241+ ) return;225242 if (e.key === "m" || e.key === "M") toggleAudio();226243 // SPACE elects leader-cam (M15): the camera chases the race leader227244 // instead of the hero marble. Hero-follow is the default — a camera228245 // that silently switches subjects mid-run reads as confusing.229- if (e.code === "Space") {246+ if (e.code === "Space" && session?.canFollowLeader) {247+ if ((e.target as HTMLElement | null)?.closest("button, summary")) return;230248 e.preventDefault();231- session?.toggleLeaderCam();249+ toggleLeader();232250 }233251 });234252 @@ -249,6 +267,7 @@ export function bootEndless(
249267 250268 initSeed((seed) => {251269 session?.dispose();270+ session = null;252271 try {253272 session = new Session(254273 seed,@@ -269,11 +288,23 @@ export function bootEndless(
269288 err instanceof Error ? err.message : String(err)270289 }`;271290 }291+ ui.onError?.(err);272292 return;273293 }274294 // Endless is a journey — follow by default.275295 cams.setMode("follow");276296 setCamLabel(cams.mode);297+ if (leaderBtn) {298+ leaderBtn.hidden = !session.canFollowLeader;299+ leaderBtn.textContent = "Your marble";300+ leaderBtn.setAttribute("aria-pressed", "false");301+ }302+ for (303+ const hint of document.querySelectorAll<HTMLElement>(".endless-help")304+ ) {305+ hint.hidden = !session.canFollowLeader;306+ }307+ ui.onReady?.();277308 });278309 279310 ui.newTrackBtn?.addEventListener("click", () => newSeed());@@ -2599,12 +2630,17 @@ class Session {
25992630 }26002631 }26012632 2633+ get canFollowLeader(): boolean {2634+ return this.racers.length > 1;2635+ }2636+26022637 /** SPACE toggle: hero-follow (default) ↔ leader-cam. */26032638 toggleLeaderCam(): void {2639+ if (!this.canFollowLeader) return;26042640 this.leaderCam = !this.leaderCam;26052641 if (this.racers.length > 0) {26062642 this.toast.show(2607- this.leaderCam ? "LEADER CAM" : "HERO CAM",2643+ this.leaderCam ? "Following the leader" : "Following your marble",26082644 );26092645 }26102646 }@@ -8,18 +8,16 @@
88 * hero's place out of all racers. The counting is pure (`racePlace`,99 * headless-tested); `PlaceHud` owns its element like the toast does.1010 *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.11+ * Surface styling matches the shared playback dock.1412 */1513 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 {14+/** A quiet translucent surface that leaves the track visible. */15+function applyFrame(el: HTMLElement): void {1916 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',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)",2321 });2422 }2523 @@ -56,20 +54,24 @@ export class MilestoneToast {
5654 this.el.dataset.hud = "";5755 Object.assign(this.el.style, {5856 position: "fixed",59- top: "18%",57+ top: "88px",6058 left: "50%",6159 transform: "translateX(-50%)",6260 zIndex: "10",63- font: "600 15px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",64- letterSpacing: "0.14em",61+ fontFamily: "inherit",62+ fontSize: "13px",63+ fontWeight: "600",64+ lineHeight: "1.5",65+ whiteSpace: "pre-line",66+ textAlign: "center",6567 color: "#dce6f5",66- padding: "2px 10px",68+ padding: "10px 16px",6769 opacity: "0",6870 transition: `opacity ${FADE}s ease`,6971 pointerEvents: "none",7072 backdropFilter: "blur(6px)",7173 });72- applyFrame(this.el, 12);74+ applyFrame(this.el);7375 document.body.appendChild(this.el);7476 }7577 @@ -118,18 +120,18 @@ export class PlaceHud {
118120 this.el.dataset.hud = "";119121 Object.assign(this.el.style, {120122 position: "fixed",121- right: "14px",122- bottom: "14px",123+ right: "24px",124+ top: "76px",123125 zIndex: "10",124- font: "600 16px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",125- letterSpacing: "0.12em",126+ font: "600 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",127+ letterSpacing: "0.04em",126128 color: "#dce6f5",127- padding: "0 6px",129+ padding: "8px 12px",128130 opacity: "0",129131 transition: "opacity 0.3s ease",130132 pointerEvents: "none",131133 });132- applyFrame(this.el, 12);134+ applyFrame(this.el);133135 document.body.appendChild(this.el);134136 }135137 @@ -28,6 +28,7 @@ import { installDevUi } from "./devui.ts";
2828 import { maybeShowMenu } from "./menu.ts";2929 import { buildTwistDemo } from "./twistDemo.ts";3030 import { buildPieceBench } from "./pieceBench.ts";31+import { installRunControls } from "./runControls.ts";3132 3233 const params = new URLSearchParams(location.search);3334 const GALLERY = params.get("gallery") === "1";@@ -49,13 +50,22 @@ const modeBtn = document.getElementById("mode-toggle") as
4950 // Main menu (settings entry point): owns bare urls and ?menu=1; every5051 // shared seed link boots straight into the game.5152 const MENU_ACTIVE = maybeShowMenu();53+const loadingEl = document.getElementById("loading")!;54+if (MENU_ACTIVE) loadingEl.hidden = true;55+document.getElementById("run-mode")!.textContent = ENDLESS56+ ? "Endless run"57+ : "Classic loop";58+for (const el of document.querySelectorAll<HTMLElement>(".endless-help")) {59+ el.hidden = !ENDLESS;60+}5261 5362 // Mode switch is a real navigation (search param changes, hash seed kept).5463 if (modeBtn) {5564 modeBtn.textContent = ENDLESS ? "loop mode" : "endless mode";5665 modeBtn.addEventListener("click", () => {57- const search = ENDLESS ? "?endless=0" : "?endless=1";58- location.href = location.pathname + search + location.hash;66+ const q = new URLSearchParams(location.search);67+ q.set("endless", ENDLESS ? "0" : "1");68+ location.href = `${location.pathname}?${q}${location.hash}`;5969 });6070 }6171 // Back to the menu, keeping the seed and this session's settings.@@ -79,6 +89,7 @@ if (MENU_ACTIVE) {
7989 if (statsEl) statsEl.hidden = true;8090 }8191 const ctx = MENU_ACTIVE ? null! : createScene(container);92+if (!MENU_ACTIVE) installRunControls(ctx);8293 8394 function setStats(text: string): void {8495 if (statsEl) statsEl.textContent = text;@@ -87,6 +98,12 @@ function setStats(text: string): void {
8798 function fail(err: unknown): void {8899 console.error(err);89100 setStats(`error: ${err instanceof Error ? err.message : String(err)}`);101+ loadingEl.hidden = false;102+ loadingEl.classList.add("failed");103+ document.getElementById("loading-message")!.textContent =104+ "This world couldn't load.";105+ loadingEl.querySelector("span")!.textContent =106+ "Return to Settings to try another world.";90107 }91108 92109 // ---------------------------------------------------------------------------@@ -138,7 +155,8 @@ function boot(assets: Assets): void {
138155 }));139156 const setCamLabel = (mode: CamMode) => {140157 if (camBtn) {141- camBtn.textContent = mode === "orbit" ? "follow cam" : "orbit cam";158+ camBtn.textContent = mode === "orbit" ? "Follow cam" : "Orbit cam";159+ camBtn.setAttribute("aria-pressed", String(mode === "follow"));142160 }143161 };144162 cams.onModeChange = setCamLabel;@@ -248,10 +266,17 @@ function boot(assets: Assets): void {
248266 249267 fitShadowsTo(ctx.sun, built.box);250268 cams.retarget(centerline.curve, marble);251- cams.frame(built.box);269+ // Frame the circuit itself. Scenery can spread far beyond it and270+ // used to leave the marble run as a tiny dot in an empty overview.271+ const circuitBox = new THREE.Box3().setFromPoints(272+ centerline.curve.getPoints(256),273+ );274+ cams.frame(circuitBox.expandByScalar(1.5));252275 253276 if (seedEl) seedEl.textContent = seed;254277 const s = track.stats;278+ loadingEl.hidden = true;279+ loadingEl.classList.remove("failed");255280 setStats(256281 `${s.theme} • ${s.setting} • ${s.pieceCount} pieces • ` +257282 `${track.loop.totalLength.toFixed(0)}u • maxY ${s.maxDeckY}` +@@ -304,29 +329,36 @@ function boot(assets: Assets): void {
304329 305330 // ---------------------------------------------------------------------------306331 307-loadAssets().then(async (assets) => {308- if (MENU_ACTIVE) return; // the menu owns the page; START navigates309- if (TWIST_DEMO) buildTwistDemo(ctx, assets);310- else if (GALLERY) buildPieceBench(ctx, assets);311- else if (ENDLESS) {312- // Physics is the endless default (?phys=0 falls back to the kinematic313- // rail rider). The wasm module is a separate lazy artifact.314- let physics = null;315- if (params.get("phys") !== "0") {316- try {317- const factory =318- (await import(new URL("/dist/box3d.mjs", location.href).href))319- .default;320- physics = await factory();321- } catch (err) {322- console.warn("[endless] physics module unavailable:", err);332+if (!MENU_ACTIVE) {333+ loadAssets().then(async (assets) => {334+ if (TWIST_DEMO) buildTwistDemo(ctx, assets);335+ else if (GALLERY) buildPieceBench(ctx, assets);336+ else if (ENDLESS) {337+ // Physics is the endless default (?phys=0 falls back to the kinematic338+ // rail rider). The wasm module is a separate lazy artifact.339+ let physics = null;340+ if (params.get("phys") !== "0") {341+ try {342+ const factory =343+ (await import(new URL("/dist/box3d.mjs", location.href).href))344+ .default;345+ physics = await factory();346+ } catch (err) {347+ console.warn("[endless] physics module unavailable:", err);348+ }323349 }324- }325- bootEndless(ctx, assets, decorCatalog(assets), {326- seedEl,327- statsEl,328- newTrackBtn,329- camBtn,330- }, physics);331- } else boot(assets);332-}).catch(fail);350+ bootEndless(ctx, assets, decorCatalog(assets), {351+ seedEl,352+ statsEl,353+ newTrackBtn,354+ camBtn,355+ onError: fail,356+ onReady: () => {357+ loadingEl.hidden = true;358+ loadingEl.classList.remove("failed");359+ },360+ }, physics);361+ } else boot(assets);362+ if (!loadingEl.classList.contains("failed")) loadingEl.hidden = true;363+ }).catch(fail);364+}@@ -1,13 +1,12 @@
11 /**22 * 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.3+ * own page (opaque, with a track illustration; the game never renders4+ * behind it). Native buttons and a form support touch, Tab and Enter.65 *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 efficiency6+ * Primary choices: racer count and a free-text SEED (any text; blank7+ * mints a random one). A folded "Simulation options" section carries8+ * classic loop, physics, autopilot law, debug overlay and asset gallery.9+ * Boost speed and fuel efficiency1110 * are NOT here — they are pilot personality traits (autopilot.ts1211 * pilotFor), not user settings.1312 *@@ -96,373 +95,165 @@ function currentSeed(): string {
9695 }9796 }9897 99-/** Renders the menu when the url asks for it. Returns true when the menu100- * owns the page (the caller must NOT boot the game). */98+/** A full-screen title menu. Shared seed URLs still boot directly into play. */10199 export function maybeShowMenu(): boolean {102100 const params = new URLSearchParams(location.search);103101 const bare = location.search === "" && location.hash === "";104102 if (!bare && params.get("menu") !== "1") return false;105103 106104 const c = prefill(params);107- const root = document.createElement("div");105+ const root = document.createElement("main");108106 root.id = "menu";109107 root.innerHTML = `110- <style>111- #menu {112- position: fixed; inset: 0; z-index: 40;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;116- color: #dce6f5;117- display: block; overflow: auto; box-sizing: border-box;118- padding: 24px 0 64px;119- }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- justify-content: center; box-sizing: border-box;125- width: 100%; max-width: 480px; min-width: 0;126- min-height: calc(100vh - 88px); margin: 0 auto;127- }128- #menu .orb {129- width: 64px; height: 64px; border-radius: 50%;130- background: radial-gradient(circle at 34% 30%,131- #e8f7ff 0%, #9fdcff 28%, #7fd4ff 45%, #2a6ea8 90%);132- box-shadow: 0 0 42px rgba(127, 212, 255, 0.45);133- margin-bottom: 22px;134- animation: menu-bob 4.2s ease-in-out infinite;135- }136- @keyframes menu-bob {137- 0%, 100% { transform: translateY(0); }138- 50% { transform: translateY(-9px); }139- }140- #menu h1 {141- margin: 0; font-size: clamp(30px, 8vw, 44px); font-weight: 600;142- letter-spacing: 0.42em; text-indent: 0.42em;143- text-shadow: 0 0 26px rgba(127, 212, 255, 0.35);144- }145- #menu .sub {146- margin: 6px 0 44px; font-size: 12px; letter-spacing: 0.18em;147- color: rgba(220, 230, 245, 0.62);148- }149- #menu .rows {150- display: flex; flex-direction: column; gap: 6px;151- padding: 14px 18px;152- width: calc(100% - 24px); max-width: 456px; box-sizing: border-box;153- background: rgba(8, 12, 22, 0.5);154- border: 12px solid transparent;155- border-image: url("/ui/frame.png") 12 stretch;156- }157- #menu .row {158- display: grid;159- grid-template-columns: 24px minmax(80px, 110px) minmax(0, 240px);160- align-items: center; text-align: left;161- padding: 7px 10px; border-radius: 6px; cursor: pointer;162- color: rgba(220, 230, 245, 0.75);163- }164- #menu .row .cursor { color: #7fd4ff; visibility: hidden; }165- #menu .row.sel { background: rgba(127, 212, 255, 0.07); color: #eaf4ff; }166- #menu .row.sel .cursor { visibility: visible; }167- #menu .row.off { opacity: 0.35; pointer-events: none; }168- #menu .row .k {169- font-size: 12px; letter-spacing: 0.14em;170- color: rgba(220, 230, 245, 0.65);171- }172- #menu .row.sel .k { color: rgba(220, 230, 245, 0.8); }173- #menu .val {174- display: flex; align-items: center; gap: 10px; min-width: 0;175- }176- #menu .val input { min-width: 0; }177- #menu .val .arrow {178- cursor: pointer; color: rgba(127, 212, 255, 0.5); user-select: none;179- border: 0; padding: 0; background: transparent; font: inherit;180- }181- #menu .val .arrow:hover { color: #7fd4ff; }182- #menu input#menu-seed:focus-visible,183- #menu input#menu-racers:focus-visible,184- #menu button:focus-visible,185- #menu summary:focus-visible {186- outline: 2px solid #7fd4ff; outline-offset: 3px;187- }188- #menu .val .v { flex: 1; text-align: center; color: #7fd4ff; }189- #menu input#menu-seed {190- width: 100%; box-sizing: border-box;191- background: transparent; border: none; outline: none;192- border-bottom: 1px solid rgba(127, 212, 255, 0.35);193- color: #7fd4ff; font: inherit; text-align: center;194- padding: 2px 0; letter-spacing: 0.06em;195- }196- #menu input#menu-seed::placeholder { color: rgba(127, 212, 255, 0.3); }197- #menu input#menu-racers {198- flex: 1; width: 100%; box-sizing: border-box;199- background: transparent; border: none; outline: none;200- color: #7fd4ff; font: inherit; text-align: center;201- letter-spacing: 0.06em;202- }203- #menu .row.start-row {204- grid-template-columns: 24px 1fr; margin-top: 26px; text-align: center;205- appearance: none; border: 0; width: 100%; font: inherit;206- background: transparent;207- }208- #menu .row.start-row .k {209- font-size: 17px; letter-spacing: 0.34em; text-indent: 0.34em;210- color: #7fd4ff; text-align: center;211- }212- #menu .row.start-row.sel .k { text-shadow: 0 0 16px rgba(127, 212, 255, 0.6); }213- #menu details {214- margin-top: 34px; font-size: 11px;215- width: min(456px, calc(100vw - 32px));216- color: rgba(220, 230, 245, 0.58);217- }218- #menu summary { cursor: pointer; letter-spacing: 0.14em; }219- #menu .adv {220- display: flex; flex-direction: column; gap: 4px; margin-top: 8px;221- align-items: flex-start;222- }223- #menu .adv label {224- display: flex; gap: 8px; align-items: flex-start; cursor: pointer;225- text-align: left; white-space: normal;226- color: rgba(220, 230, 245, 0.7);227- }228- #menu .adv input { accent-color: #7fd4ff; }229- #menu .hint {230- position: absolute; bottom: 18px; left: 0; right: 0;231- text-align: center; font-size: 11px; letter-spacing: 0.16em;232- color: rgba(220, 230, 245, 0.65);233- }234- @media (max-height: 560px) {235- #menu { padding: 12px 0 28px; }236- #menu .col { justify-content: flex-start; min-height: 0; }237- #menu .orb { display: none; }238- #menu .sub { margin-bottom: 18px; }239- #menu details { margin-top: 18px; }240- #menu .hint { display: none !important; }241- }242- </style>243- <canvas class="stars"></canvas>244- <div class="col">245- <div class="orb"></div>246- <h1>MARBLES</h1>247- <div class="rows">248- <div class="row" data-row="mode">249- <span class="cursor">▸</span><span class="k">MODE</span>250- <span class="val"><button type="button" class="arrow" data-dir="-1"251- aria-label="Previous mode">◂</button>252- <span class="v" id="menu-mode-v"></span>253- <button type="button" class="arrow" data-dir="1"254- aria-label="Next mode">▸</button></span>255- </div>256- <div class="row endless-only" data-row="racers">257- <span class="cursor">▸</span><span class="k">RACERS</span>258- <span class="val"><button type="button" class="arrow" data-dir="-1"259- aria-label="Fewer racers">◂</button>260- <input id="menu-racers" inputmode="numeric" maxlength="4"261- aria-label="Racers" spellcheck="false" autocomplete="off">262- <button type="button" class="arrow" data-dir="1"263- aria-label="More racers">▸</button></span>264- </div>265- <div class="row" data-row="seed">266- <span class="cursor">▸</span><span class="k">SEED</span>267- <span class="val"><input id="menu-seed" placeholder="random"268- aria-label="Seed"269- spellcheck="false" autocomplete="off"></span>270- </div>271- <button type="button" class="row start-row" data-row="start">272- <span class="cursor">▸</span><span class="k">START</span>273- </button>108+ <div class="menu-top"><a class="wordmark" href="/" aria-label="Marbles home"><i aria-hidden="true"></i></a></div>109+ <div class="menu-layout">110+ <div class="menu-art" aria-hidden="true">111+ <div class="art-orbit orbit-one"></div><div class="art-orbit orbit-two"></div>112+ <svg class="track-art" viewBox="0 0 600 560" fill="none">113+ <defs>114+ <linearGradient id="track-ink" x1="80" y1="60" x2="520" y2="480" gradientUnits="userSpaceOnUse"><stop stop-color="#8ee3cf"/><stop offset="1" stop-color="#284b60"/></linearGradient>115+ <radialGradient id="orb-orange" cx=".3" cy=".25" r=".8"><stop stop-color="#fff2d3"/><stop offset=".25" stop-color="#ffc088"/><stop offset=".6" stop-color="#f07953"/><stop offset="1" stop-color="#7c3044"/></radialGradient>116+ <radialGradient id="orb-blue" cx=".3" cy=".25" r=".8"><stop stop-color="#ecfffc"/><stop offset=".3" stop-color="#a0e9df"/><stop offset=".65" stop-color="#62a9ba"/><stop offset="1" stop-color="#2a435f"/></radialGradient>117+ <radialGradient id="orb-lilac" cx=".3" cy=".25" r=".8"><stop stop-color="#fff4ff"/><stop offset=".3" stop-color="#c6b6ee"/><stop offset=".65" stop-color="#9583c9"/><stop offset="1" stop-color="#493757"/></radialGradient>118+ <filter id="art-shadow" x="-30%" y="-30%" width="160%" height="180%"><feDropShadow dx="0" dy="18" stdDeviation="12" flood-color="#020b13" flood-opacity=".5"/></filter>119+ </defs>120+ <g filter="url(#art-shadow)" transform="rotate(-17 300 280)">121+ <path d="M135 125H390Q475 125 475 210V260Q475 340 395 340H210Q130 340 130 415Q130 470 205 470H340" stroke="#0b1e2d" stroke-width="60" stroke-linecap="round"/>122+ <path d="M135 113H390Q475 113 475 198V248Q475 328 395 328H210Q130 328 130 403Q130 458 205 458H340" stroke="url(#track-ink)" stroke-width="53" stroke-linecap="round"/>123+ <path d="M135 113H390Q475 113 475 198V248Q475 328 395 328H210Q130 328 130 403Q130 458 205 458H340" stroke="#193b4a" stroke-width="35" stroke-linecap="round"/>124+ <path d="M135 111H390Q475 111 475 196V246Q475 326 395 326H210Q130 326 130 401Q130 456 205 456H340" stroke="#315764" stroke-width="24" stroke-linecap="round"/>125+ <path d="M154 94H385" stroke="#bdfff0" stroke-opacity=".5" stroke-width="2" stroke-linecap="round"/>126+ <path d="M455 209V244Q455 308 397 308" stroke="#bdfff0" stroke-opacity=".35" stroke-width="2" stroke-linecap="round"/>127+ <circle cx="272" cy="95" r="36" fill="url(#orb-orange)"/>128+ <ellipse cx="262" cy="83" rx="12" ry="7" transform="rotate(-30 262 83)" fill="white" fill-opacity=".65"/>129+ <circle cx="450" cy="277" r="24" fill="url(#orb-blue)"/>130+ <ellipse cx="443" cy="269" rx="7" ry="4" transform="rotate(-30 443 269)" fill="white" fill-opacity=".65"/>131+ <circle cx="220" cy="311" r="20" fill="url(#orb-lilac)"/>132+ <circle cx="215" cy="304" r="5" fill="white" fill-opacity=".6"/>133+ </g>134+ <circle cx="100" cy="250" r="3" fill="#92dcca"/><circle cx="470" cy="440" r="2" fill="#f3b798"/>135+ <path d="M490 76v14m-7-7h14M91 431v10m-5-5h10" stroke="#b3cacd" stroke-opacity=".6"/>136+ </svg>274137 </div>275- <details>276- <summary>ADVANCED</summary>277- <div class="adv">278- <label class="endless-only"><input type="checkbox" id="menu-phys"> physics marble (off = kinematic rider)</label>279- <label class="endless-only"><input type="checkbox" id="menu-ap"> autopilot law (off = legacy assist)</label>280- <label><input type="checkbox" id="menu-debug"> debug overlay</label>281- <label><input type="checkbox" id="menu-gallery"> asset gallery</label>138+ <form class="menu-panel" id="menu-form">139+ <h1>marbles<span>.</span></h1>140+ <fieldset class="field-choices endless-only"><legend>How many racers?</legend>141+ <div class="racer-options">142+ <button type="button" data-racers="1">Solo</button><button type="button" data-racers="6">6</button>143+ <button type="button" data-racers="32">32</button><button type="button" data-racers="128">128</button>144+ <input id="menu-racers" placeholder="Custom" type="number" min="1" max="9999" step="1" inputmode="numeric" aria-label="Custom racer count" title="Custom racer count, 1 to 9999">145+ </div>146+ </fieldset>147+ <div class="seed-field"><label for="menu-seed">World seed <span>optional</span></label>148+ <input id="menu-seed" placeholder="Surprise me" spellcheck="false" autocomplete="off">282149 </div>283- </details>150+ <button type="submit" id="start-run" class="start-run">Let's roll <span aria-hidden="true">↗</span></button>151+ <details class="menu-advanced"><summary>Simulation options</summary><div class="adv">152+ <label class="endless-only steering-setting">Steering <select id="menu-ap"><option value="modern">Modern autopilot</option><option value="legacy">Legacy track assist</option></select></label>153+ <label><input type="checkbox" id="menu-classic"> Classic loop</label>154+ <label class="endless-only"><input type="checkbox" id="menu-phys"> Physical simulation</label>155+ <label><input type="checkbox" id="menu-debug"> Track diagnostics</label>156+ <label><input type="checkbox" id="menu-gallery"> Piece gallery</label>157+ </div></details>158+ </form>284159 </div>285- <div class="hint">↑↓ SELECT · ◂▸ CHANGE · ENTER START</div>286160 `;287161 document.body.appendChild(root);288-289- // Drifting starfield — the menu is a place, not a form.290- const canvas = root.querySelector("canvas.stars") as HTMLCanvasElement;291- const cx = canvas.getContext("2d");292- interface Star {293- x: number;294- y: number;295- r: number;296- v: number;297- a: number;298- }299- let stars: Star[] = [];300- const seedStars = () => {301- canvas.width = innerWidth;302- canvas.height = innerHeight;303- stars = Array.from({ length: 140 }, () => ({304- x: Math.random() * canvas.width,305- y: Math.random() * canvas.height,306- r: 0.4 + Math.random() * 1.2,307- v: 2 + Math.random() * 7,308- a: 0.25 + Math.random() * 0.6,309- }));310- };311- seedStars();312- addEventListener("resize", seedStars);313- let last = performance.now();314- const drift = (now: number) => {315- const dt = Math.min(0.1, (now - last) / 1000);316- last = now;317- if (cx) {318- cx.clearRect(0, 0, canvas.width, canvas.height);319- for (const st of stars) {320- st.y += st.v * dt;321- if (st.y > canvas.height + 2) {322- st.y = -2;323- st.x = Math.random() * canvas.width;324- }325- cx.globalAlpha = st.a;326- cx.fillStyle = "#cfe6ff";327- cx.beginPath();328- cx.arc(st.x, st.y, st.r, 0, Math.PI * 2);329- cx.fill();330- }331- }332- requestAnimationFrame(drift);333- };334- requestAnimationFrame(drift);335-336162 const el = <T extends HTMLElement>(id: string): T =>337163 root.querySelector(`#${id}`) as T;338164 const seedInput = el<HTMLInputElement>("menu-seed");339- seedInput.value = currentSeed();340165 const racersInput = el<HTMLInputElement>("menu-racers");341- racersInput.addEventListener("input", () => {342- const v = Number.parseInt(racersInput.value, 10);343- if (Number.isFinite(v)) {344- c.peers = Math.max(0, Math.min(9998, v - 1));345- if (v < 1 || v > 9999) racersInput.value = String(c.peers + 1);346- }347- });348- racersInput.addEventListener("blur", () => sync());349- const phys = el<HTMLInputElement>("menu-phys");350- const ap = el<HTMLInputElement>("menu-ap");351- const debug = el<HTMLInputElement>("menu-debug");352- const gallery = el<HTMLInputElement>("menu-gallery");353- phys.checked = c.physics;354- ap.checked = c.autopilot;355- debug.checked = c.debug;356- gallery.checked = c.gallery;357- phys.addEventListener("change", () => (c.physics = phys.checked));358- ap.addEventListener("change", () => (c.autopilot = ap.checked));359- debug.addEventListener("change", () => (c.debug = debug.checked));360- gallery.addEventListener("change", () => (c.gallery = gallery.checked));361-362- const rows = ["mode", "racers", "seed", "start"] as const;363- type Row = typeof rows[number];364- let sel: Row = "start";365- let selectionFocus = false;366- let editingInput: HTMLInputElement | null = null;367-368- for (const input of [seedInput, racersInput]) {369- input.addEventListener("pointerdown", () => (editingInput = input));370- input.addEventListener("focus", () => {371- if (!selectionFocus) editingInput = input;372- });373- input.addEventListener("blur", () => {374- if (editingInput === input) editingInput = null;375- });166+ const classicInput = el<HTMLInputElement>("menu-classic");167+ seedInput.value = currentSeed();168+ if (!c.endless) {169+ root.querySelector<HTMLDetailsElement>(".menu-advanced")!.open = true;376170 }377-378- const rowEl = (row: Row): HTMLElement =>379- root.querySelector(`.row[data-row="${row}"]`) as HTMLElement;380-381- const sync = (focusSelection = true) => {382- el("menu-mode-v").textContent = c.endless ? "endless" : "loop";171+ const sync = () => {172+ classicInput.checked = !c.endless;383173 if (document.activeElement !== racersInput) {384- racersInput.value = String(c.peers + 1);174+ racersInput.value = [1, 6, 32, 128].includes(c.peers + 1)175+ ? ""176+ : String(c.peers + 1);385177 }386- for (const r of root.querySelectorAll(".endless-only")) {387- r.classList.toggle("off", !c.endless);388- for (const control of r.querySelectorAll("input, button")) {389- (control as HTMLInputElement | HTMLButtonElement).disabled = !c.endless;390- }178+ for (179+ const button of root.querySelectorAll<HTMLButtonElement>("[data-racers]")180+ ) {181+ button.setAttribute(182+ "aria-pressed",183+ String(Number(button.dataset.racers) === c.peers + 1),184+ );391185 }392- if (!c.endless && sel === "racers") sel = "mode";393- for (const row of rows) rowEl(row).classList.toggle("sel", row === sel);394- if (focusSelection) {395- selectionFocus = true;396- if (sel === "seed") seedInput.focus();397- else seedInput.blur();398- if (sel === "racers") racersInput.focus();399- else racersInput.blur();400- selectionFocus = false;186+ for (const row of root.querySelectorAll<HTMLElement>(".endless-only")) {187+ row.classList.toggle("off", !c.endless);188+ for (189+ const control of row.querySelectorAll<190+ HTMLInputElement | HTMLButtonElement | HTMLSelectElement191+ >("input, button, select")192+ ) control.disabled = !c.endless;401193 }194+ (window as unknown as Record<string, unknown>).render_game_to_text = () =>195+ JSON.stringify({ mode: "menu", choices: c, seed: seedInput.value });402196 };403-404- const adjust = (row: Row, dir: number) => {405- if (row === "mode") c.endless = !c.endless;406- else if (row === "racers") {407- c.peers = Math.max(0, Math.min(9998, c.peers + dir));197+ classicInput.addEventListener("change", () => {198+ c.endless = !classicInput.checked;199+ sync();200+ });201+ for (202+ const button of root.querySelectorAll<HTMLButtonElement>("[data-racers]")203+ ) {204+ button.addEventListener("click", () => {205+ c.peers = Number(button.dataset.racers) - 1;206+ sync();207+ });208+ }209+ racersInput.addEventListener("input", () => {210+ const count = racersInput.valueAsNumber;211+ if (Number.isFinite(count)) {212+ c.peers = Math.max(0, Math.min(9998, Math.round(count) - 1));408213 }409214 sync();410- };411-215+ });216+ racersInput.addEventListener("blur", () => {217+ sync();218+ });219+ const steeringSelect = el<HTMLSelectElement>("menu-ap");220+ steeringSelect.value = c.autopilot ? "modern" : "legacy";221+ steeringSelect.addEventListener("change", () => {222+ c.autopilot = steeringSelect.value === "modern";223+ });224+ for (225+ const [id, key] of [["menu-phys", "physics"], [226+ "menu-debug",227+ "debug",228+ ], ["menu-gallery", "gallery"]] as const229+ ) {230+ const input = el<HTMLInputElement>(id);231+ input.checked = c[key];232+ input.addEventListener("change", () => {233+ c[key] = input.checked;234+ });235+ }412236 const start = () => {413237 try {414238 localStorage.setItem(STORE_KEY, JSON.stringify(c));415- } catch {416- // storage may be unavailable (private mode) — the url carries it all417- }239+ } catch { /* URL carries the choices when storage is unavailable. */ }418240 location.replace(urlFor(c, seedInput.value));419241 };420-421- for (const row of rows) {422- const e = rowEl(row);423- e.addEventListener("mouseenter", () => {424- sel = row;425- // Hover may move the visual menu cursor, but must not steal the426- // document focus/caret from a textbox or another native control.427- sync(false);428- });429- e.addEventListener("click", (ev) => {430- const arrow = (ev.target as HTMLElement).closest(".arrow");431- if (arrow) {432- adjust(row, Number.parseInt(arrow.getAttribute("data-dir")!, 10));433- return;434- }435- if (row === "start") start();436- else if (row === "mode") adjust(row, 1);437- });438- }439-242+ el<HTMLFormElement>("menu-form").addEventListener("submit", (e) => {243+ e.preventDefault();244+ start();245+ });440246 addEventListener("keydown", (e) => {441- if (!root.isConnected) return;442247 const target = e.target as HTMLElement | null;443- if (editingInput && target === editingInput) return;444248 if (445- target?.closest("button, summary, input[type=checkbox], select, textarea")446- ) {447- return;448- }449- if (e.key === "ArrowUp" || e.key === "ArrowDown") {450- e.preventDefault();451- const live = rows.filter((r) => r !== "racers" || c.endless);452- const i = live.indexOf(sel);453- const n = live.length;454- sel = live[(i + (e.key === "ArrowDown" ? 1 : n - 1)) % n];455- sync();456- } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {457- if (sel === "seed") return; // the input owns its caret458- e.preventDefault();459- adjust(sel, e.key === "ArrowRight" ? 1 : -1);460- } else if (e.key === "Enter") {249+ target?.closest("input, button, summary, textarea, select") ||250+ e.isComposing251+ ) return;252+ if (e.key === "Enter") {461253 e.preventDefault();462254 start();463255 }464256 });465-466257 sync();467258 return true;468259 }@@ -0,0 +1,70 @@
1+import type { SceneCtx } from "./scene.ts";2+3+/** Keep letter shortcuts out of text editing and select controls. */4+export function editingControl(target: EventTarget | null): boolean {5+ return target instanceof HTMLElement &&6+ (target.isContentEditable ||7+ target.closest("input, textarea, select") !== null);8+}9+10+/** Shared controls for both run modes. The simulation pauses while the11+ * camera can still orbit, and sharing always copies the complete seed URL. */12+export function installRunControls(ctx: SceneCtx): void {13+ const pause = document.getElementById("pause-run")!;14+ const paused = document.getElementById("paused-label")!;15+ const fullscreen = document.getElementById("fullscreen")!;16+ const share = document.getElementById("share-run")!;17+ const status = document.getElementById("share-status")!;18+ let messageTimer: ReturnType<typeof setTimeout>;19+ const message = (text: string) => {20+ clearTimeout(messageTimer);21+ status.textContent = text;22+ messageTimer = setTimeout(() => status.textContent = "", 3500);23+ };24+ const setPaused = (value: boolean) => {25+ ctx.paused = value;26+ pause.textContent = ctx.paused ? "Resume" : "Pause";27+ pause.setAttribute("aria-pressed", String(ctx.paused));28+ paused.hidden = !ctx.paused;29+ };30+ const togglePause = () => setPaused(!ctx.paused);31+ document.getElementById("new-track")?.addEventListener(32+ "click",33+ () => setPaused(false),34+ );35+ const toggleFullscreen = async () => {36+ try {37+ if (document.fullscreenElement) await document.exitFullscreen();38+ else await document.documentElement.requestFullscreen();39+ } catch {40+ message("Full screen is unavailable in this browser.");41+ }42+ };43+ document.addEventListener("fullscreenchange", () => {44+ const active = !!document.fullscreenElement;45+ fullscreen.textContent = active ? "Exit full screen" : "Full screen";46+ fullscreen.setAttribute("aria-pressed", String(active));47+ });48+ pause.addEventListener("click", togglePause);49+ fullscreen.addEventListener("click", toggleFullscreen);50+ share.addEventListener("click", async () => {51+ try {52+ await navigator.clipboard.writeText(location.href);53+ message("World link copied. Send someone the same adventure.");54+ } catch {55+ message("Copy the address bar to share this world.");56+ }57+ });58+ addEventListener("keydown", (e) => {59+ if (60+ editingControl(e.target) || e.repeat || e.metaKey || e.ctrlKey || e.altKey61+ ) return;62+ if (e.code === "KeyK") {63+ e.preventDefault();64+ togglePause();65+ } else if (e.code === "KeyF") {66+ e.preventDefault();67+ void toggleFullscreen();68+ }69+ });70+}@@ -6,6 +6,8 @@ export interface SceneCtx {
66 camera: THREE.PerspectiveCamera;77 sun: THREE.DirectionalLight;88 hemi: THREE.HemisphereLight;9+ /** Playback control; zero-dt frames let orbit controls keep responding. */10+ paused?: boolean;911 /** Per-frame hooks, called with dt (seconds) and elapsed time. */1012 onFrame: Set<(dt: number, t: number) => void>;1113 }@@ -53,15 +55,24 @@ export function createScene(container: HTMLElement): SceneCtx {
5355 addEventListener("resize", resize);5456 resize();5557 58+ const ctx: SceneCtx = {59+ renderer,60+ scene,61+ camera,62+ sun,63+ hemi,64+ onFrame,65+ paused: false,66+ };5667 const clock = new THREE.Clock();5768 renderer.setAnimationLoop(() => {5869 const dt = Math.min(clock.getDelta(), 0.1);5970 const t = clock.elapsedTime;60- for (const fn of onFrame) fn(dt, t);71+ for (const fn of onFrame) fn(ctx.paused ? 0 : dt, t);6172 renderer.render(scene, camera);6273 });6374 64- return { renderer, scene, camera, sun, hemi, onFrame };75+ return ctx;6576 }6677 6778 /** Fit the sun's shadow frustum tightly around a world-space bounding box. */@@ -6,88 +6,49 @@
66 <title>marbles</title>77 <link rel="icon"88 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" />9- <style>10- html,11- body {12- margin: 0;13- height: 100%;14- overflow: hidden;15- background: #0e1420;16- }17- #app {18- position: fixed;19- inset: 0;20- }21- #hud {22- position: fixed;23- top: 12px;24- left: 12px;25- z-index: 10;26- display: flex;27- gap: 8px;28- align-items: center;29- font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;30- color: #dce6f5;31- }32- #hud[hidden],33- #stats[hidden] {34- display: none;35- }36- /* Game-skin chrome: Kenney 9-slice frame (CC0, tinted), not a web37- widget — see static/ui/. */38- #hud button,39- #seed {40- background: rgba(10, 15, 26, 0.68);41- color: #dce6f5;42- border: 6px solid transparent;43- border-image: url("/ui/frame-dim.png") 12 stretch;44- padding: 1px 5px;45- font: inherit;46- backdrop-filter: blur(6px);47- }48- #hud button {49- cursor: pointer;50- }51- #hud button:hover {52- filter: brightness(1.45);53- }54- #seed {55- user-select: all;56- cursor: copy;57- display: inline-block;58- box-sizing: border-box;59- max-width: min(42vw, 32ch);60- overflow: hidden;61- text-overflow: ellipsis;62- white-space: nowrap;63- vertical-align: middle;64- }65- #stats {66- position: fixed;67- bottom: 12px;68- left: 12px;69- z-index: 10;70- font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;71- color: rgba(220, 230, 245, 0.55);72- white-space: pre;73- }74- </style>9+ <link rel="stylesheet" href="/ui.css" />7510 </head>7611 <body>7712 <div id="app"></div>78- <div id="hud">79- <button id="menu-btn"80- title="Back to the main menu (settings)">menu</button>81- <button id="new-track"82- title="Generate a new seed (adds a history entry)">new track</button>83- <button id="cam-mode"84- title="Toggle orbit / follow camera (C)">follow cam</button>85- <button id="mode-toggle"86- title="Switch between the finite loop and the endless void line"></button>87- <button id="audio-toggle" hidden88- title="Toggle ambient audio (M)">audio: off</button>89- <span id="seed" title="Click to copy URL"></span>13+ <div id="hud" data-hud>14+ <header class="run-topbar">15+ <div16+ class="run-identity"><span class="wordmark"><i></i>marbles</span><span id="run-mode">Endless run</span><span id="seed" title="World seed"></span></div>17+ <nav class="run-actions" aria-label="Run options">18+ <button id="new-track" class="primary"19+ title="Generate another world">New run <span aria-hidden="true">↗</span></button>20+ <button id="menu-btn"21+ title="Choose a mode, field size, or seed">Settings</button>22+ </nav>23+ </header>24+ <nav class="control-dock" aria-label="Playback and camera controls">25+ <button id="cam-mode" title="Switch camera (C)"26+ aria-pressed="false">Follow cam</button>27+ <button id="leader-cam" hidden28+ title="Follow the leader or your marble (Space)"29+ aria-pressed="false">Your marble</button>30+ <span class="dock-divider" aria-hidden="true"></span>31+ <button id="pause-run" title="Pause or resume (K)"32+ aria-pressed="false">Pause</button>33+ <button id="fullscreen" title="Toggle full screen (F)"34+ aria-pressed="false">Full screen</button>35+ <button id="share-run" title="Copy this world's link">Share</button>36+ <details id="controls-help">37+ <summary aria-label="Show controls" title="Controls">?</summary>38+ <div39+ class="controls-card"><strong>Enjoy the ride</strong><p>Marbles steer themselves. Switch cameras to find your favorite view.</p>40+ <dl><dt>C</dt><dd>Change camera</dd><dt>K</dt><dd>Pause / resume</dd><dt>F</dt><dd>Full screen</dd><dt class="endless-help">Space</dt><dd class="endless-help">Your marble / race leader</dd><dt>Drag</dt><dd>Rotate in orbit view</dd><dt>Scroll / pinch</dt><dd>Zoom in orbit view</dd></dl>41+ </div>42+ </details>43+ <button id="mode-toggle" hidden title="Change run mode"></button>44+ <button id="audio-toggle" hidden title="Toggle audio (M)"></button>45+ </nav>46+ <div id="paused-label"47+ hidden>Paused <span>Take a look around. Resume when you're ready.</span></div>48+ <div id="share-status" role="status" aria-live="polite"></div>9049 </div>50+ <div id="loading" role="status"51+ aria-live="polite"><div class="loading-orb"></div><strong id="loading-message">Building your world…</strong><span>Finding a few unexpected turns.</span></div>9152 <div id="stats"></div>9253 <script type="module" src="/dist/app.js"></script>9354 <script>@@ -0,0 +1,639 @@
1+:root {2+ color-scheme: dark;3+ --ink: #f1f0e7;4+ --muted: #96aaae;5+ --mint: #a4e9d5;6+ --orange: #f7ac86;7+ --line: rgba(193,225,224,.13);8+ font-family: "Avenir Next", Avenir, -apple-system, BlinkMacSystemFont,9+ "Segoe UI", sans-serif;10+}11+* {12+ box-sizing: border-box;13+}14+html,15+body {16+ margin: 0;17+ height: 100%;18+ overflow: hidden;19+ background: #0b1721;20+ color: var(--ink);21+}22+button,23+input,24+select,25+summary {26+ font: inherit;27+}28+button {29+ cursor: pointer;30+}31+button,32+input,33+select {34+ -webkit-tap-highlight-color: transparent;35+}36+button:focus-visible,37+input:focus-visible,38+select:focus-visible,39+summary:focus-visible,40+a:focus-visible {41+ outline: 2px solid var(--mint);42+ outline-offset: 4px;43+}44+button:disabled,45+input:disabled,46+select:disabled {47+ cursor: default;48+}49+[hidden] {50+ display: none ;51+}52+#app {53+ position: fixed;54+ inset: 0;55+}56+.wordmark {57+ display: inline-flex;58+ align-items: center;59+ gap: 10px;60+ font-size: 21px;61+ font-weight: 750;62+ letter-spacing: -.8px;63+ text-decoration: none;64+ color: var(--ink);65+}66+.wordmark i {67+ display: inline-block;68+ width: 17px;69+ height: 17px;70+ border-radius: 50%;71+ background: radial-gradient(circle at 30% 25%, #fff3dd, #f6aa82 48%, #bc6672);72+ box-shadow: 0 0 24px #f6aa8220;73+}74+#menu {75+ position: fixed;76+ inset: 0;77+ z-index: 40;78+ overflow: auto;79+ background: radial-gradient(ellipse at 25% 45%, #1b3940 0%, #101f2c 43%,80+ #0b1721 76%);81+ padding: 30px 5vw 20px;82+}83+.menu-top {84+ display: flex;85+ align-items: center;86+ justify-content: space-between;87+ max-width: 1280px;88+ margin: auto;89+}90+.menu-layout {91+ display: grid;92+ grid-template-columns: 1.2fr 1fr;93+ gap: 50px;94+ align-items: center;95+ max-width: 1130px;96+ min-height: calc(100svh - 134px);97+ margin: auto;98+ padding: 32px 0;99+}100+.menu-art {101+ position: relative;102+ align-self: center;103+ min-width: 0;104+}105+.track-art {106+ display: block;107+ width: 110%;108+ margin-left: -7%;109+ position: relative;110+ z-index: 1;111+ animation: art-float 8s ease-in-out infinite;112+}113+.art-orbit {114+ position: absolute;115+ border: 1px solid #b1e7df14;116+ border-radius: 50%;117+ width: 90%;118+ aspect-ratio: 1;119+ top: 3%;120+ left: 0;121+}122+.orbit-two {123+ width: 120%;124+ left: -15%;125+ top: -12%;126+ border-style: dashed;127+ border-color: #b1e7df0a;128+}129+@keyframes art-float {130+ 0%,131+ 100% {132+ transform: translateY(0);133+ }134+ 50% {135+ transform: translateY(-12px);136+ }137+}138+.menu-panel {139+ max-width: 440px;140+ width: 100%;141+ margin: auto;142+}143+#menu h1 {144+ margin: 0 0 28px;145+ font-size: clamp(54px, 6.5vw, 84px);146+ line-height: 1.08;147+ letter-spacing: -.075em;148+ font-weight: 750;149+}150+#menu h1 span {151+ color: var(--orange);152+}153+#menu fieldset {154+ border: 0;155+ padding: 0;156+ margin: 0 0 24px;157+ min-width: 0;158+}159+#menu legend,160+.seed-field label {161+ display: block;162+ padding: 0;163+ font-weight: 600;164+ font-size: 12px;165+ margin-bottom: 10px;166+}167+.racer-options {168+ display: grid;169+ grid-template-columns: repeat(4,1fr) 1.25fr;170+ gap: 7px;171+}172+.racer-options button,173+.racer-options input {174+ min-width: 0;175+ width: 100%;176+ border-radius: 8px;177+ border: 1px solid var(--line);178+ background: #ffffff03;179+ color: var(--muted);180+ text-align: center;181+ padding: 9px 4px;182+ font-size: 12px;183+}184+.racer-options button:hover {185+ color: var(--ink);186+ border-color: #a4e9d566;187+}188+.racer-options button[aria-pressed=true] {189+ color: var(--mint);190+ border-color: #a4e9d577;191+ background: #a4e9d50b;192+}193+.racer-options input {194+ color: var(--ink);195+ appearance: textfield;196+ -moz-appearance: textfield;197+}198+.racer-options input::-webkit-inner-spin-button {199+ -webkit-appearance: none;200+}201+.endless-only.off {202+ opacity: .4;203+}204+.seed-field {205+ margin-bottom: 22px;206+}207+.seed-field label span {208+ color: #82999e;209+ font-size: 10px;210+ font-weight: 400;211+ margin-left: 6px;212+}213+#menu-seed {214+ width: 100%;215+ background: #08141d55;216+ border: 1px solid var(--line);217+ border-radius: 8px;218+ padding: 11px 12px;219+ color: var(--ink);220+ font-size: 13px;221+}222+#menu-seed::placeholder {223+ color: #738d92;224+}225+.start-run {226+ display: flex;227+ justify-content: space-between;228+ align-items: center;229+ width: 100%;230+ color: #162a2c;231+ background: var(--mint);232+ border: 0;233+ border-radius: 10px;234+ padding: 14px 17px;235+ font-size: 14px;236+ font-weight: 700;237+ box-shadow: 0 5px 30px #6dc9ba0d;238+ transition: background .18s, transform .18s;239+}240+.start-run:hover {241+ background: #c7f7e6;242+ transform: translateY(-1px);243+}244+.start-run span {245+ font-size: 21px;246+ line-height: 1;247+}248+.menu-advanced {249+ margin-top: 17px;250+ color: #8da3a8;251+ font-size: 11px;252+}253+.menu-advanced summary {254+ cursor: pointer;255+ width: max-content;256+ padding: 4px 0;257+}258+.adv {259+ display: grid;260+ grid-template-columns: 1fr 1fr;261+ gap: 10px;262+ padding: 13px 0 4px;263+}264+.adv label {265+ display: flex;266+ gap: 7px;267+ align-items: center;268+ line-height: 1.5;269+}270+.adv input {271+ accent-color: var(--mint);272+ margin: 0;273+}274+.steering-setting {275+ grid-column: 1 / -1;276+}277+.adv select {278+ min-width: 0;279+ padding: 7px 10px;280+ border: 1px solid var(--line);281+ border-radius: 8px;282+ background: #101f2c;283+ color: var(--ink);284+}285+#hud {286+ position: fixed;287+ inset: 0;288+ z-index: 10;289+ pointer-events: none;290+}291+#hud button,292+#hud summary,293+#hud .controls-card {294+ pointer-events: auto;295+}296+.run-topbar {297+ position: absolute;298+ top: max(20px, env(safe-area-inset-top));299+ left: max(24px, env(safe-area-inset-left));300+ right: max(24px, env(safe-area-inset-right));301+ display: flex;302+ align-items: center;303+ justify-content: space-between;304+ gap: 15px;305+}306+.run-identity {307+ display: flex;308+ align-items: center;309+ gap: 18px;310+ min-width: 0;311+ text-shadow: 0 1px 8px #06121a;312+}313+#run-mode {314+ font-size: 11px;315+ color: #d3e4e2;316+ border-left: 1px solid #cfece333;317+ padding-left: 18px;318+ white-space: nowrap;319+}320+#seed {321+ font-size: 10px;322+ color: #c0d4d3;323+ max-width: min(25vw, 25ch);324+ overflow: hidden;325+ text-overflow: ellipsis;326+ white-space: nowrap;327+}328+.run-actions {329+ display: flex;330+ gap: 8px;331+ flex-shrink: 0;332+}333+#hud button {334+ border: 1px solid #d4f1e22e;335+ border-radius: 9px;336+ background: #0d1c29b8;337+ backdrop-filter: blur(16px);338+ color: #f1f2e8;339+ padding: 10px 13px;340+ font-size: 12px;341+ line-height: 1.2;342+ transition: background .16s, border-color .16s;343+}344+#hud button:hover {345+ background: #28414ded;346+ border-color: #d4f1e277;347+}348+#hud button.primary {349+ background: #a4e9d5ed;350+ color: #162a2c;351+ font-weight: 650;352+ border-color: transparent;353+}354+#hud button.primary:hover {355+ background: #c7f7e6;356+}357+.control-dock {358+ pointer-events: auto;359+ position: absolute;360+ bottom: max(24px, env(safe-area-inset-bottom));361+ left: 50%;362+ transform: translateX(-50%);363+ display: flex;364+ align-items: center;365+ gap: 3px;366+ padding: 6px;367+ border: 1px solid #d4f1e226;368+ border-radius: 14px;369+ background: #0b1824e0;370+ box-shadow: 0 8px 28px #0003;371+ backdrop-filter: blur(18px);372+ white-space: nowrap;373+}374+#hud .control-dock button {375+ background: transparent;376+ border-color: transparent;377+ padding: 10px 12px;378+ font-size: 11px;379+}380+#hud .control-dock button:hover,381+#hud .control-dock button[aria-pressed=true] {382+ background: #a4e9d515;383+ color: var(--mint);384+}385+.dock-divider {386+ width: 1px;387+ height: 20px;388+ background: #cfece320;389+ margin: 0 5px;390+}391+#controls-help {392+ position: relative;393+}394+#controls-help summary {395+ cursor: pointer;396+ display: grid;397+ place-items: center;398+ list-style: none;399+ width: 32px;400+ height: 32px;401+ border: 1px solid #cfece333;402+ border-radius: 50%;403+ color: #afc8c8;404+ font-size: 12px;405+}406+#controls-help summary::-webkit-details-marker {407+ display: none;408+}409+.controls-card {410+ position: absolute;411+ bottom: 57px;412+ right: -6px;413+ width: 290px;414+ padding: 20px;415+ border: 1px solid #d4f1e22e;416+ border-radius: 12px;417+ background: #101f2cf5;418+ box-shadow: 0 14px 35px #0005;419+ white-space: normal;420+}421+.controls-card strong {422+ font-size: 14px;423+}424+.controls-card p {425+ font-size: 12px;426+ color: #aac0c3;427+ line-height: 1.6;428+}429+.controls-card dl {430+ display: grid;431+ grid-template-columns: 90px 1fr;432+ gap: 10px;433+ font-size: 11px;434+ margin: 18px 0 0;435+}436+.controls-card dt {437+ color: var(--mint);438+}439+.controls-card dd {440+ margin: 0;441+ color: #c1d2d2;442+}443+#paused-label {444+ position: absolute;445+ top: 26%;446+ left: 50%;447+ transform: translateX(-50%);448+ font-size: 23px;449+ font-weight: 650;450+ text-align: center;451+ text-shadow: 0 2px 12px #07121b;452+}453+#paused-label span {454+ display: block;455+ margin-top: 8px;456+ font-size: 12px;457+ font-weight: 400;458+ color: #cfdfdf;459+}460+#share-status {461+ position: absolute;462+ top: 72px;463+ left: 50%;464+ transform: translateX(-50%);465+ font-size: 12px;466+ color: var(--mint);467+ background: #0d1c29e8;468+ border-radius: 8px;469+ max-width: calc(100vw - 32px);470+ text-align: center;471+}472+#share-status:not(:empty) {473+ padding: 10px 14px;474+}475+#stats {476+ position: fixed;477+ bottom: 95px;478+ left: 16px;479+ z-index: 10;480+ font: 10px/1.5 ui-monospace, monospace;481+ color: #c8ddde;482+ white-space: pre-wrap;483+ max-width: calc(100vw - 32px);484+}485+#loading {486+ position: fixed;487+ inset: 0;488+ z-index: 30;489+ background: radial-gradient(ellipse at center, #1a343c, #0b1721 75%);490+ display: flex;491+ flex-direction: column;492+ align-items: center;493+ justify-content: center;494+ gap: 14px;495+ text-align: center;496+ padding: 28px;497+}498+.loading-orb {499+ width: 44px;500+ height: 44px;501+ border-radius: 50%;502+ background: radial-gradient(circle at 30% 25%, #fff2dc, #f7ae85 48%, #ab596b);503+ box-shadow: 0 10px 35px #f7ae8522;504+ animation: art-float 1.5s ease-in-out infinite;505+ margin-bottom: 15px;506+}507+#loading strong {508+ font-size: 18px;509+ font-weight: 600;510+}511+#loading > span {512+ color: var(--muted);513+ font-size: 12px;514+}515+#loading.failed {516+ pointer-events: none;517+ background: #0b1721e8;518+ z-index: 9;519+}520+@media (max-width: 900px) {521+ .menu-layout {522+ gap: 24px;523+ grid-template-columns: 1fr 1.2fr;524+ }525+ #seed {526+ display: none;527+ }528+}529+@media (max-width: 640px) {530+ #menu {531+ padding: 22px 22px 24px;532+ }533+ .menu-layout {534+ display: block;535+ padding: 32px 0 28px;536+ min-height: auto;537+ }538+ .menu-art {539+ display: none;540+ }541+ .menu-panel {542+ max-width: 420px;543+ }544+ #menu h1 {545+ font-size: 68px;546+ }547+ .run-topbar {548+ top: max(14px, env(safe-area-inset-top));549+ left: 14px;550+ right: 14px;551+ gap: 8px;552+ }553+ .run-identity {554+ gap: 8px;555+ flex-wrap: wrap;556+ }557+ .run-identity .wordmark {558+ font-size: 18px;559+ }560+ #run-mode {561+ display: none;562+ }563+ #hud .run-actions button {564+ padding: 10px;565+ font-size: 11px;566+ }567+ .control-dock {568+ bottom: max(17px, env(safe-area-inset-bottom));569+ gap: 0;570+ padding: 5px;571+ max-width: calc(100vw - 20px);572+ }573+ #hud .control-dock button {574+ padding: 10px 7px;575+ font-size: 10px;576+ }577+ .dock-divider {578+ margin: 0 3px;579+ }580+ #fullscreen {581+ display: none;582+ }583+ #controls-help summary {584+ width: 27px;585+ height: 27px;586+ }587+ .controls-card {588+ right: -5px;589+ width: min(290px, calc(100vw - 28px));590+ }591+ #paused-label {592+ width: 90%;593+ }594+ #paused-label span {595+ font-size: 11px;596+ }597+}598+@media (max-height: 800px) and (min-width: 641px) {599+ #menu {600+ padding-top: 20px;601+ }602+ .menu-layout {603+ padding: 16px 0;604+ gap: 35px;605+ }606+ #menu h1 {607+ font-size: 65px;608+ }609+ #menu fieldset {610+ margin-bottom: 18px;611+ }612+ .seed-field {613+ margin-bottom: 16px;614+ }615+}616+@media (prefers-reduced-motion: reduce) {617+ *,618+ *::before,619+ *::after {620+ animation: none ;621+ transition: none ;622+ }623+}624+/* Sharing takes priority in the notification area. */625+body:has(#share-status:not(:empty)) #milestone {626+ visibility: hidden;627+}628+629+@media (max-height: 800px) and (min-width: 641px) {630+ .track-art {631+ max-height: 68svh;632+ }633+ #menu fieldset {634+ margin-bottom: 14px;635+ }636+ .menu-advanced {637+ margin-top: 12px;638+ }639+}