1/**2 * Client entry point.3 *4 * Boot order: load assets → build the decor catalog from the nature manifest →5 * hand the URL hash to `initSeed`, whose callback is the ONE place a track is6 * (re)generated. Two query modes short-circuit that: `?gallery=1` is the asset7 * regression page, `?debug=1` overlays the sampled centerline.8 */9import * as THREE from "three";10import { createScene, fitShadowsTo } from "./scene.ts";11import { type Assets, loadAssets } from "./assets.ts";12import { initSeed, newSeed } from "./seed.ts";13import { buildTrack, type BuiltTrack } from "./track.ts";14import { buildCenterline, type Centerline } from "./centerline.ts";15import { createMarble, type Marble } from "./marble.ts";16import { FanField, fanVolumesOf } from "./fanField.ts";17import { type CamMode, createCameras } from "./camera.ts";18import { generateTrack } from "../gen/generateTrack.ts";19import type { DecorCatalog } from "../gen/decor.ts";20import type { Placement, Track } from "../shared/types.ts";21import { genVersion } from "../gen/version.ts";22import { installCua } from "./cua.ts";23import { installInspect } from "./inspect.ts";24import { installPhotoKey } from "./photo.ts";25import { bootEndless } from "./endless.ts";26import { createTrail } from "./trail.ts";27import { installDevUi } from "./devui.ts";28import { maybeShowMenu } from "./menu.ts";29import { buildTwistDemo } from "./twistDemo.ts";30import { buildPieceBench } from "./pieceBench.ts";31import { installRunControls } from "./runControls.ts";3233const params = new URLSearchParams(location.search);34const GALLERY = params.get("gallery") === "1";35const TWIST_DEMO = params.get("demo") === "twist";36const DEBUG = params.get("debug") === "1";37const ENDLESS = params.get("endless") !== "0";3839const container = document.getElementById("app")!;40const seedEl = document.getElementById("seed");41const statsEl = document.getElementById("stats");42const newTrackBtn = document.getElementById("new-track") as43 | HTMLButtonElement44 | null;45const camBtn = document.getElementById("cam-mode") as HTMLButtonElement | null;46const modeBtn = document.getElementById("mode-toggle") as47 | HTMLButtonElement48 | null;4950// Main menu (settings entry point): owns bare urls and ?menu=1; every51// shared seed link boots straight into the game.52const MENU_ACTIVE = maybeShowMenu();53const loadingEl = document.getElementById("loading")!;54if (MENU_ACTIVE) loadingEl.hidden = true;55document.getElementById("run-mode")!.textContent = ENDLESS56 ? "Endless run"57 : "Classic loop";58for (const el of document.querySelectorAll<HTMLElement>(".endless-help")) {59 el.hidden = !ENDLESS;60}6162// Mode switch is a real navigation (search param changes, hash seed kept).63if (modeBtn) {64 modeBtn.textContent = ENDLESS ? "loop mode" : "endless mode";65 modeBtn.addEventListener("click", () => {66 const q = new URLSearchParams(location.search);67 q.set("endless", ENDLESS ? "0" : "1");68 location.href = `${location.pathname}?${q}${location.hash}`;69 });70}71// Back to the menu, keeping the seed and this session's settings.72const menuBtn = document.getElementById("menu-btn") as73 | HTMLButtonElement74 | null;75menuBtn?.addEventListener("click", () => {76 const q = new URLSearchParams(location.search);77 q.set("menu", "1");78 location.href = `${location.pathname}?${q.toString()}${location.hash}`;79});8081installDevUi();8283// The menu is its own PAGE (title screen): no game scene, no HUD — the84// render loop never starts and START navigates to a game url. The85// non-null trick is safe: every ctx consumer is behind !MENU_ACTIVE.86if (MENU_ACTIVE) {87 const hud = document.getElementById("hud");88 if (hud) hud.hidden = true;89 if (statsEl) statsEl.hidden = true;90}91const ctx = MENU_ACTIVE ? null! : createScene(container);92if (!MENU_ACTIVE) installRunControls(ctx);9394function setStats(text: string): void {95 if (statsEl) statsEl.textContent = text;96}9798function fail(err: unknown): void {99 console.error(err);100 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.";107}108109// ---------------------------------------------------------------------------110// Normal mode111// ---------------------------------------------------------------------------112113/** Catalog over every loaded decor pack (M18a). Finite mode only ever114 * draws the nature rows (pickSetting's `finite` guard), so the extra115 * packs are inert there; endless biomes reach them by name. */116function decorCatalog(assets: Assets): DecorCatalog {117 return {118 packs: [...assets.manifests].map(([slug, m]) => ({119 slug,120 ids: m.pieces.map((p) => p.id),121 })),122 };123}124125function boot(assets: Assets): void {126 const catalog: DecorCatalog = decorCatalog(assets);127128 let built: BuiltTrack | null = null;129 let centerline: Centerline | null = null;130 let marble: Marble | null = null;131 let debugLine: THREE.Line | null = null;132 let lastTrack: Track | null = null;133 let lastSeed = "";134 const trail = createTrail();135 const boostTrail = createTrail(0xff5a24, {136 maxPoints: 28,137 headAlpha: 1,138 additive: true,139 renderOrder: 6,140 });141 boostTrail.obj.name = "boost-trail";142 const trailPoint = new THREE.Vector3();143 ctx.scene.add(trail.obj);144 ctx.scene.add(boostTrail.obj);145 let boostTrailFade = 0;146 let boostTrailActive = false;147148 const cams = createCameras(ctx, null, null);149 // P = live photo export (shared with endless mode).150 installPhotoKey(ctx, () => `marbles-${lastSeed || "loop"}`);151 const inspector = installInspect(ctx, () => ({152 seed: lastSeed,153 track: lastTrack,154 group: built?.group ?? null,155 }));156 const setCamLabel = (mode: CamMode) => {157 if (camBtn) {158 camBtn.textContent = mode === "orbit" ? "Follow cam" : "Orbit cam";159 camBtn.setAttribute("aria-pressed", String(mode === "follow"));160 }161 };162 cams.onModeChange = setCamLabel;163 setCamLabel(cams.mode);164165 if (DEBUG) {166 const axes = new THREE.AxesHelper(2);167 axes.name = "axes";168 ctx.scene.add(axes);169 }170171 ctx.onFrame.add((dt) => {172 if (built) {173 for (const sp of built.spinners) {174 sp.obj.rotateOnAxis(sp.axis, sp.radPerSec * dt);175 }176 }177 marble?.update(dt);178 if (marble) {179 // Ball center = the pivot child, not the group origin on the deck.180 const center = marble.obj.children[0] ?? marble.obj;181 center.getWorldPosition(trailPoint);182 trail.update(trailPoint, ctx.camera);183 if (marble.boosting) {184 if (!boostTrailActive) boostTrail.reset();185 boostTrailActive = true;186 boostTrailFade = 1;187 boostTrail.update(trailPoint, ctx.camera);188 } else {189 boostTrailActive = false;190 boostTrailFade = Math.max(0, boostTrailFade - dt * 4);191 boostTrail.fade(ctx.camera, boostTrailFade);192 }193 }194 cams.update(dt);195 });196197 function regenerate(seed: string): void {198 const t0 = performance.now();199200 inspector.reset();201 built?.dispose();202 built = null;203 if (marble) {204 ctx.scene.remove(marble.obj);205 marble.dispose();206 marble = null;207 }208 trail.reset();209 boostTrail.reset();210 boostTrailFade = 0;211 boostTrailActive = false;212 if (debugLine) {213 ctx.scene.remove(debugLine);214 debugLine.geometry.dispose();215 (debugLine.material as THREE.Material).dispose();216 debugLine = null;217 }218 cams.retarget(null, null);219220 let track;221 try {222 track = generateTrack(seed, catalog);223 } catch (err) {224 fail(err);225 return;226 }227 // M19a: derive the loop's wind capsules once per track (family228 // defaults only — loop fans carry no wind hints, no pulses).229 const loopWindField = (placements: readonly Placement[]): FanField => {230 const field = new FanField();231 field.addSegment(0, fanVolumesOf(placements));232 return field;233 };234235 lastTrack = track;236 lastSeed = seed;237 built = buildTrack(track, assets, ctx.scene);238 centerline = buildCenterline(track.loop);239 marble = createMarble(240 assets,241 centerline.curve,242 centerline.altCurve,243 track.placements244 .filter((p) => p.boost)245 .map((p) => ({246 pos: p.pos as [number, number, number],247 strength: p.boost!,248 })),249 (track.falls ?? []).map((f) => ({250 pos: f.pos as [number, number, number],251 landPos: f.landPos as [number, number, number],252 ...(f.guaranteed ? { guaranteed: true } : {}),253 })),254 // M19a loop backport: the same wind-field law endless uses, derived255 // from the loop's own fan placements — zero generation change.256 loopWindField(track.placements),257 );258 ctx.scene.add(marble.obj);259 trail.setWidth(marble.radius * 0.6);260 boostTrail.setWidth(marble.radius * 1.5);261262 if (DEBUG) {263 debugLine = centerline.debugLine();264 ctx.scene.add(debugLine);265 }266267 fitShadowsTo(ctx.sun, built.box);268 cams.retarget(centerline.curve, marble);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));275276 if (seedEl) seedEl.textContent = seed;277 const s = track.stats;278 loadingEl.hidden = true;279 loadingEl.classList.remove("failed");280 setStats(281 `${s.theme} • ${s.setting} • ${s.pieceCount} pieces • ` +282 `${track.loop.totalLength.toFixed(0)}u • maxY ${s.maxDeckY}` +283 (built.missing.length ? ` • ${built.missing.length} missing ids` : "") +284 ` • ${285 (performance.now() - t0).toFixed(0)286 }ms • ${genVersion()} • \` inspect`,287 );288 }289290 installCua(ctx, () => ({291 mode: "track",292 seed: lastSeed,293 version: genVersion(),294 inspect: inspector.active ? inspector.selection() : undefined,295 camMode: cams.mode,296 marble: marble297 ? {298 s: Number(marble.s.toFixed(2)),299 speed: Number(marble.speed.toFixed(2)),300 pos: marble.obj.position.toArray().map((v: number) =>301 Number(v.toFixed(2))302 ),303 }304 : null,305 stats: lastTrack306 ? {307 theme: lastTrack.stats.theme,308 setting: lastTrack.stats.setting,309 pieces: lastTrack.stats.pieceCount,310 loopLength: Number(lastTrack.loop.totalLength.toFixed(1)),311 maxDeckY: lastTrack.stats.maxDeckY,312 missing: built?.missing.length ?? 0,313 }314 : null,315 }));316317 initSeed(regenerate);318319 newTrackBtn?.addEventListener("click", () => {320 newSeed();321 });322 camBtn?.addEventListener("click", () => {323 cams.toggle();324 });325 seedEl?.addEventListener("click", () => {326 navigator.clipboard?.writeText(location.href).catch(() => {});327 });328}329330// ---------------------------------------------------------------------------331332if (!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 }349 }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}