- 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>
| M | src/ | 6 | +- |
| A | src/ | 22 | ++++++ |
| M | src/ | 13 | ++++ |
| M | src/ | 18 | ++++- |
| M | src/ | 21 | +++++- |
| A | src/ | 109 | ++++++++++++++++++++++++++++++ |
| M | static/ | 1 | + |
@@ -242,10 +242,10 @@ export function createCameras(
242242 const dot = d1x * d2x + d1z * d2z;243243 const turn = Math.atan2(crossY, dot);244244 bankTarget = Math.max(245- -0.32,245+ -0.16,246246 Math.min(247- 0.32,248- -turn * (0.1 + activeMarble.speed * 0.035),247+ 0.16,248+ -turn * (0.05 + activeMarble.speed * 0.018),249249 ),250250 );251251 }@@ -0,0 +1,22 @@
1+// Dev-facing HUD elements (stats readout, seed, loop/endless mode switch,2+// audio + camera toggles) stay hidden until the tilde key reveals them —3+// the same key that toggles inspect mode in loop mode, so ` means4+// "dev view" everywhere. The M/C shortcuts still work while hidden.5+const DEV_IDS = ["stats", "seed", "mode-toggle", "audio-toggle", "cam-mode"];6+7+export function installDevUi(): void {8+ const els = DEV_IDS9+ .map((id) => document.getElementById(id))10+ .filter((el): el is HTMLElement => el !== null);11+ let shown = false;12+ const apply = () => {13+ for (const el of els) el.style.display = shown ? "" : "none";14+ };15+ apply();16+ addEventListener("keydown", (e) => {17+ if (e.code === "Backquote") {18+ shown = !shown;19+ apply();20+ }21+ });22+}@@ -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 @@ -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();@@ -82,10 +82,27 @@ export function instantiatePlacements(
8282 obj.traverse((child: THREE.Object3D) => {8383 if (child !== obj && /^fan(_\d+)?$/.test(child.name)) target = child;8484 });85+ // The planner says [0,0,1] for every fan, but only standing fans have86+ // their blade disc in the x/y plane. Table fans (fan-two/-four,87+ // fan-simple-*) lie flat with the disc in x/z and must spin about88+ // local +y. The blade's own bbox tells them apart: its thin dimension89+ // IS the hub axis.90+ const axis = new THREE.Vector3(91+ p.spin.axis[0],92+ p.spin.axis[1],93+ p.spin.axis[2],94+ ).normalize();95+ const blade = target as THREE.Mesh;96+ if (target !== obj && blade.isMesh && blade.geometry) {97+ blade.geometry.computeBoundingBox();98+ const size = blade.geometry.boundingBox!.getSize(new THREE.Vector3());99+ if (size.y <= size.x && size.y <= size.z) axis.set(0, 1, 0);100+ else if (size.z <= size.x && size.z <= size.y) axis.set(0, 0, 1);101+ else axis.set(1, 0, 0);102+ }85103 spinners.push({86104 obj: target,87- axis: new THREE.Vector3(p.spin.axis[0], p.spin.axis[1], p.spin.axis[2])88- .normalize(),105+ axis,89106 radPerSec: p.spin.radPerSec,90107 });91108 }@@ -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+}@@ -4,6 +4,7 @@
44 <meta charset="utf-8" />55 <meta name="viewport" content="width=device-width, initial-scale=1" />66 <title>marbles</title>7+ <link rel="icon" 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" />78 <style>89 html,910 body {