clone

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

Player-visibility round: marble trail, calmer banking, endless default, dev HUD behind tilde; fan spin axis fix

Dami committed 73a6248
- Trail (src/client/trail.ts): camera-facing tapered ribbon (RGBA vertex
  colors, 96 samples) anchored to the ball's pivot center in both modes;
  resets on respawn teleports. Width = 0.6 * marble radius.
- Camera banking halved: clamp 0.32→0.16 rad, gain 0.1+0.035v→0.05+0.018v.
- Endless is the default mode; loop mode via ?endless=0 (mode button
  navigates accordingly).
- Dev HUD (src/client/devui.ts): stats, seed, mode switch, audio and cam
  buttons hidden until backquote — same key as loop-mode inspect, so
  ` means "dev view" everywhere. M/C/P shortcuts still work while hidden.
- Fan blades: table fans (fan-two/-four/-simple-*) have their blade disc
  in x/z and must spin about local +y, not the planner's [0,0,1]. The
  spinner setup now derives the hub axis from the blade bbox's thinnest
  dimension (fixes blades tumbling through the housing); axis stays
  client-side so placement data and goldens are untouched.
- index.html: inline SVG marble favicon (kills the 404 on every load).
commit
73a6248131e4bc224e272b91681acf8067405237
parent
af9705825108cfd4a759a516c84352495070b15d
author
Dami <dami.etoile@gmail.com>
7 files changed, +183 -7
Msrc/client/camera.ts6+-
Asrc/client/devui.ts22++++++
Msrc/client/endless.ts13++++
Msrc/client/main.ts18++++-
Msrc/client/track.ts21+++++-
Asrc/client/trail.ts109++++++++++++++++++++++++++++++
Mstatic/index.html1+
M src/client/camera.ts +3 -3
A src/client/devui.ts +22 -0
M src/client/endless.ts +13 -0
@@ -18,6 +18,7 @@ import type { Assets } from "./assets.ts";
1818 import { initSeed, newSeed } from "./seed.ts";1919 import { instantiatePlacements, type Spinner } from "./track.ts";2020 import { buildOpenCurve } from "./centerline.ts";21+import { createTrail } from "./trail.ts";2122 import { type Cameras, type CamMode, createCameras } from "./camera.ts";2223 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";2324 import { TRACK_SCALE } from "../gen/endless/scale.ts";
@@ -314,6 +315,8 @@ class Session {
314315 private wasSurge = false;315316 private focus: { center: THREE.Vector3; radius: number } | null = null;316317 318+ private readonly trail = createTrail();319+ private readonly trailPoint = new THREE.Vector3();317320 private readonly point = new THREE.Vector3();318321 private readonly tangent = new THREE.Vector3();319322 private readonly axis = new THREE.Vector3();
@@ -359,7 +362,9 @@ class Session {
359362 mesh.position.sub(center);360363 this.pivot.add(mesh);361364 }365+ this.trail.setWidth(this.radius * 0.6);362366 ctx.scene.add(this.marbleObj);367+ ctx.scene.add(this.trail.obj);363368 // Ring size is session config, not seed material: ?peers=N (0 turns364369 // the ring off, capped at 24; default DEFAULT_PEER_COUNT).365370 const peersParam = new URLSearchParams(location.search).get("peers");
@@ -846,6 +851,12 @@ class Session {
846851 }847852 848853 private finishFrame(dt: number): void {854+ // Trail follows the ball's center (the pivot), not the group origin855+ // that sits on the deck.856+ this.trail.update(857+ this.pivot.getWorldPosition(this.trailPoint),858+ this.ctx.camera,859+ );849860 // The marble's rolling voice follows its speed.850861 this.audio.setSpeed(this.speed);851862
@@ -915,6 +926,8 @@ class Session {
915926 this.toast.dispose();916927 this.ctx.scene.remove(this.root);917928 this.ctx.scene.remove(this.marbleObj);929+ this.ctx.scene.remove(this.trail.obj);930+ this.trail.dispose();918931 this.cams.retargetPath(null, null);919932 }920933
M src/client/main.ts +16 -2
@@ -22,11 +22,13 @@ import { installCua } from "./cua.ts";
2222 import { installInspect } from "./inspect.ts";2323 import { installPhotoKey } from "./photo.ts";2424 import { bootEndless } from "./endless.ts";25+import { createTrail } from "./trail.ts";26+import { installDevUi } from "./devui.ts";2527 2628 const params = new URLSearchParams(location.search);2729 const GALLERY = params.get("gallery") === "1";2830 const DEBUG = params.get("debug") === "1";29-const ENDLESS = params.get("endless") === "1";31+const ENDLESS = params.get("endless") !== "0";3032 3133 const container = document.getElementById("app")!;3234 const seedEl = document.getElementById("seed");
@@ -43,11 +45,13 @@ const modeBtn = document.getElementById("mode-toggle") as
4345 if (modeBtn) {4446 modeBtn.textContent = ENDLESS ? "loop mode" : "endless mode";4547 modeBtn.addEventListener("click", () => {46- const search = ENDLESS ? "" : "?endless=1";48+ const search = ENDLESS ? "?endless=0" : "";4749 location.href = location.pathname + search + location.hash;4850 });4951 }5052 53+installDevUi();54+5155 const ctx = createScene(container);5256 5357 function setStats(text: string): void {
@@ -181,6 +185,9 @@ function boot(assets: Assets): void {
181185 let debugLine: THREE.Line | null = null;182186 let lastTrack: Track | null = null;183187 let lastSeed = "";188+ const trail = createTrail();189+ const trailPoint = new THREE.Vector3();190+ ctx.scene.add(trail.obj);184191 185192 const cams = createCameras(ctx, null, null);186193 // P = live photo export (shared with endless mode).
@@ -211,6 +218,11 @@ function boot(assets: Assets): void {
211218 }212219 }213220 marble?.update(dt);221+ if (marble) {222+ // Ball center = the pivot child, not the group origin on the deck.223+ const center = marble.obj.children[0] ?? marble.obj;224+ trail.update(center.getWorldPosition(trailPoint), ctx.camera);225+ }214226 cams.update(dt);215227 });216228
@@ -224,6 +236,7 @@ function boot(assets: Assets): void {
224236 ctx.scene.remove(marble.obj);225237 marble = null;226238 }239+ trail.reset();227240 if (debugLine) {228241 ctx.scene.remove(debugLine);229242 debugLine.geometry.dispose();
@@ -261,6 +274,7 @@ function boot(assets: Assets): void {
261274 })),262275 );263276 ctx.scene.add(marble.obj);277+ trail.setWidth(marble.radius * 0.6);264278 265279 if (DEBUG) {266280 debugLine = centerline.debugLine();
M src/client/track.ts +19 -2
A src/client/trail.ts +109 -0
@@ -0,0 +1,109 @@
1+import * as THREE from "three";2+3+// Camera-facing ribbon of recent ball-center positions: thick at the head,4+// tapering to nothing at the tail (THREE.Line can't do width, so this is a5+// triangle strip whose side vectors face the camera each frame).6+const MAX_POINTS = 96;7+const MIN_STEP = 0.06; // world units between samples8+const JUMP_RESET = 4; // a respawn/warp teleports the marble; don't streak9+const HEAD_ALPHA = 0.85;10+11+export interface Trail {12+ obj: THREE.Mesh;13+ setWidth(w: number): void;14+ update(pos: THREE.Vector3, camera: THREE.Camera): void;15+ reset(): void;16+ dispose(): void;17+}18+19+export function createTrail(color: THREE.ColorRepresentation = 0x7fd4ff): Trail {20+ const positions = new Float32Array(MAX_POINTS * 2 * 3);21+ const colors = new Float32Array(MAX_POINTS * 2 * 4);22+ const index: number[] = [];23+ for (let i = 0; i < MAX_POINTS - 1; i++) {24+ const a = i * 2;25+ index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);26+ }27+ const geometry = new THREE.BufferGeometry();28+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));29+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 4));30+ geometry.setIndex(index);31+ geometry.setDrawRange(0, 0);32+ const material = new THREE.MeshBasicMaterial({33+ vertexColors: true,34+ transparent: true,35+ depthWrite: false,36+ side: THREE.DoubleSide,37+ });38+ const mesh = new THREE.Mesh(geometry, material);39+ mesh.name = "marble-trail";40+ mesh.frustumCulled = false;41+ mesh.renderOrder = 5;42+43+ const tint = new THREE.Color(color);44+ const history: THREE.Vector3[] = []; // newest first45+ let headWidth = 0.2;46+47+ const camPos = new THREE.Vector3();48+ const dir = new THREE.Vector3();49+ const view = new THREE.Vector3();50+ const side = new THREE.Vector3();51+52+ function rewrite(camera: THREE.Camera): void {53+ camera.getWorldPosition(camPos);54+ const last = history.length - 1;55+ for (let i = 0; i < history.length; i++) {56+ const p = history[i];57+ // Central difference along the trail; degenerate at a single point.58+ dir.copy(history[Math.max(0, i - 1)]).sub(history[Math.min(last, i + 1)]);59+ view.copy(p).sub(camPos);60+ side.crossVectors(dir, view);61+ if (side.lengthSq() > 1e-10) side.normalize();62+ else side.set(0, 1, 0);63+ const fade = i === last ? 0 : 1 - i / (MAX_POINTS - 1);64+ side.multiplyScalar(headWidth * fade * 0.5);65+ positions[i * 6] = p.x + side.x;66+ positions[i * 6 + 1] = p.y + side.y;67+ positions[i * 6 + 2] = p.z + side.z;68+ positions[i * 6 + 3] = p.x - side.x;69+ positions[i * 6 + 4] = p.y - side.y;70+ positions[i * 6 + 5] = p.z - side.z;71+ const alpha = HEAD_ALPHA * fade * fade;72+ for (const v of [i * 8, i * 8 + 4]) {73+ colors[v] = tint.r;74+ colors[v + 1] = tint.g;75+ colors[v + 2] = tint.b;76+ colors[v + 3] = alpha;77+ }78+ }79+ geometry.attributes.position.needsUpdate = true;80+ geometry.attributes.color.needsUpdate = true;81+ geometry.setDrawRange(0, Math.max(0, (history.length - 1) * 6));82+ }83+84+ return {85+ obj: mesh,86+ setWidth(w: number): void {87+ headWidth = w;88+ },89+ update(pos: THREE.Vector3, camera: THREE.Camera): void {90+ const head = history[0];91+ if (head && pos.distanceTo(head) > JUMP_RESET) history.length = 0;92+ if (!history[0] || pos.distanceToSquared(history[0]) >= MIN_STEP * MIN_STEP) {93+ history.unshift(pos.clone());94+ if (history.length > MAX_POINTS) history.length = MAX_POINTS;95+ } else {96+ history[0].copy(pos);97+ }98+ rewrite(camera);99+ },100+ reset(): void {101+ history.length = 0;102+ geometry.setDrawRange(0, 0);103+ },104+ dispose(): void {105+ geometry.dispose();106+ material.dispose();107+ },108+ };109+}
M static/index.html +1 -0