1/**2 * Main menu — a TITLE SCREEN, not a web ui (user rule): the menu is its3 * own page (opaque, with a track illustration; the game never renders4 * behind it). Native buttons and a form support touch, Tab and Enter.5 *6 * 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 efficiency10 * are NOT here — they are pilot personality traits (autopilot.ts11 * pilotFor), not user settings.12 *13 * Routing contract: the menu owns a BARE url (no search, no hash) and14 * ?menu=1; every shared seed link and harness url (hash and/or params15 * present) boots straight into the game, so shared URLs and the CUA16 * soaks keep working. START always writes an explicit `endless` param,17 * so the post-start url is never bare. Choices persist in localStorage18 * as prefill for the next bare visit.19 */2021/** Settings the menu writes into the url (read by main.ts / endless.ts). */22export interface MenuChoices {23 endless: boolean;24 peers: number;25 physics: boolean;26 autopilot: boolean;27 debug: boolean;28 gallery: boolean;29}3031export const MENU_DEFAULTS: MenuChoices = {32 endless: true,33 peers: 5,34 physics: true,35 autopilot: true,36 debug: false,37 gallery: false,38};3940const STORE_KEY = "marbles-menu";4142function loadStored(): Partial<MenuChoices> {43 try {44 return JSON.parse(localStorage.getItem(STORE_KEY) ?? "{}");45 } catch {46 return {};47 }48}4950/** Prefill: url params (a ?menu=1 revisit keeps the session's settings)51 * override stored choices override defaults. */52function prefill(params: URLSearchParams): MenuChoices {53 const stored = { ...MENU_DEFAULTS, ...loadStored() };54 const num = (key: string, fallback: number): number => {55 const v = Number.parseFloat(params.get(key) ?? "");56 return Number.isFinite(v) ? v : fallback;57 };58 return {59 endless: params.has("endless")60 ? params.get("endless") !== "0"61 : stored.endless,62 peers: Math.max(0, Math.min(9998, Math.round(num("peers", stored.peers)))),63 physics: params.has("phys") ? params.get("phys") !== "0" : stored.physics,64 autopilot: params.has("autopilot")65 ? params.get("autopilot") !== "0"66 : stored.autopilot,67 debug: params.has("debug") ? params.get("debug") === "1" : stored.debug,68 gallery: params.has("gallery")69 ? params.get("gallery") === "1"70 : stored.gallery,71 };72}7374function urlFor(c: MenuChoices, seed: string): string {75 const q = new URLSearchParams();76 // Always explicit — a bare url means "show the menu".77 q.set("endless", c.endless ? "1" : "0");78 if (c.endless) {79 if (c.peers !== MENU_DEFAULTS.peers) q.set("peers", String(c.peers));80 if (!c.physics) q.set("phys", "0");81 if (!c.autopilot) q.set("autopilot", "0");82 }83 if (c.debug) q.set("debug", "1");84 if (c.gallery) q.set("gallery", "1");85 const hash = seed === "" ? "" : `#${encodeURIComponent(seed)}`;86 return `${location.pathname}?${q.toString()}${hash}`;87}8889/** Current seed from the hash (returning via ?menu=1 keeps it). */90function currentSeed(): string {91 try {92 return decodeURIComponent(location.hash.slice(1));93 } catch {94 return location.hash.slice(1);95 }96}9798/** A full-screen title menu. Shared seed URLs still boot directly into play. */99export function maybeShowMenu(): boolean {100 const params = new URLSearchParams(location.search);101 const bare = location.search === "" && location.hash === "";102 if (!bare && params.get("menu") !== "1") return false;103104 const c = prefill(params);105 const root = document.createElement("main");106 root.id = "menu";107 root.innerHTML = `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>137 </div>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">149 </div>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>159 </div>160 `;161 document.body.appendChild(root);162 const el = <T extends HTMLElement>(id: string): T =>163 root.querySelector(`#${id}`) as T;164 const seedInput = el<HTMLInputElement>("menu-seed");165 const racersInput = el<HTMLInputElement>("menu-racers");166 const classicInput = el<HTMLInputElement>("menu-classic");167 seedInput.value = currentSeed();168 if (!c.endless) {169 root.querySelector<HTMLDetailsElement>(".menu-advanced")!.open = true;170 }171 const sync = () => {172 classicInput.checked = !c.endless;173 if (document.activeElement !== racersInput) {174 racersInput.value = [1, 6, 32, 128].includes(c.peers + 1)175 ? ""176 : String(c.peers + 1);177 }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 );185 }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;193 }194 (window as unknown as Record<string, unknown>).render_game_to_text = () =>195 JSON.stringify({ mode: "menu", choices: c, seed: seedInput.value });196 };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));213 }214 sync();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 }236 const start = () => {237 try {238 localStorage.setItem(STORE_KEY, JSON.stringify(c));239 } catch { /* URL carries the choices when storage is unavailable. */ }240 location.replace(urlFor(c, seedInput.value));241 };242 el<HTMLFormElement>("menu-form").addEventListener("submit", (e) => {243 e.preventDefault();244 start();245 });246 addEventListener("keydown", (e) => {247 const target = e.target as HTMLElement | null;248 if (249 target?.closest("input, button, summary, textarea, select") ||250 e.isComposing251 ) return;252 if (e.key === "Enter") {253 e.preventDefault();254 start();255 }256 });257 sync();258 return true;259}