clone

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

M6 goal 1: spawn/despawn animation for the endless stream

Dami committed 78448d1
Segments and islands assemble into the void as a staggered drop-and-settle
wave flowing along the travel direction (per-placement easeOutBack, delay =
projection onto heading + deterministic position jitter) and sink away
before disposal (segment lingers in an exiting list until its wave ends).
Initial session fill appears whole; seed-change teardown is immediate.
Visual-only wall-clock easing - generation untouched.

Validated rule-based (user direction: scenarios over time-fuzz): CUA stream
state exposes spawningSegments/exitingSegments + sceneNodes; gate script
asserts spawn wave starts with every build, exit waves run and settle, and
scene nodes stay bounded (2.9k-4.2k over 4 sim-min, zero console errors).
commit
78448d19d35504a11bd0735b1d5805f0f4491346
parent
1c7fd1d0cc7b902b8dd296557056eb0bc7cf4d4b
author
Dami <dami.etoile@gmail.com>
3 files changed, +249 -15
Mprogress.md31++++--
Msrc/client/endless.ts91++++++++++++++++++-
Asrc/client/streamAnim.ts142++++++++++++++++++++++++++++++
M progress.md +20 -11
@@ -489,17 +489,26 @@ problematic).
489489 490490 ### Milestone sequence (each gates the next)491491 492-**M6 — Living stream: spawn/despawn animation + texture variation.** Placements493-animate IN as the stream builds (transform-based rise/scale settle, staggered494-per placement by distance from segment entry; island blocks bottom-up, decor495-after) and animate OUT before disposal (segment lingers one window while496-exiting). Visual-only wall-clock easing — generation untouched, determinism497-unaffected. Textures: biome-tinted cliff materials (shared material clone PER498-BIOME, not per block), per-theme accents, the checkered goal-gate texture (old499-backlog), and evaluate extra Kenney texture packs (e.g. Prototype Textures as an500-"abstract" biome row). Validation: headless build/dispose cycle asserting501-renderer.info counts return to baseline (no leaks), animation state-machine unit502-tests, screenshot review.492+**M6 — Living stream: spawn/despawn animation + texture variation.** [GOAL 1493+DONE: spawn/despawn waves — src/client/streamAnim.ts. Per-placement494+drop-in-and-settle (easeOutBack, stagger 0.045s/unit along travel dir +495+deterministic position jitter), sink-out on disposal (segment lingers in496+`exiting` until its wave finishes); initial session fill appears whole; session497+teardown immediate. Validated rule-based via CUA stream state498+(spawningSegments/exitingSegments lifecycle: saw spawn on build, saw exit, all499+settled; sceneNodes bounded 2.9k–4.2k over 4 sim-min; zero console errors) +500+screenshots. REMAINING in M6: texture variation (biome-tinted cliffs, checkered501+goal gate, prototype-texture biome row evaluation), InstancedMesh for island502+blocks (mind the per-instance animation matrices).] Placements animate IN as the503+stream builds (transform-based rise/scale settle, staggered per placement by504+distance from segment entry; island blocks bottom-up, decor after) and animate505+OUT before disposal (segment lingers one window while exiting). Visual-only506+wall-clock easing — generation untouched, determinism unaffected. Textures:507+biome-tinted cliff materials (shared material clone PER BIOME, not per block),508+per-theme accents, the checkered goal-gate texture (old backlog), and evaluate509+extra Kenney texture packs (e.g. Prototype Textures as an "abstract" biome row).510+Validation: headless build/dispose cycle asserting renderer.info counts return511+to baseline (no leaks), animation state-machine unit tests, screenshot review.503512 504513 **M7 — Scenario validation harness (the pre-physics gate).** Demote time-based505514 soaks to smoke tests. Build: (a) transition-matrix rule tests — enumerate
M src/client/endless.ts +87 -4
@@ -20,6 +20,7 @@ import { instantiatePlacements, type Spinner } from "./track.ts";
2020 import { buildOpenCurve } from "./centerline.ts";2121 import { type Cameras, type CamMode, createCameras } from "./camera.ts";2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";23+import { ExitAnim, SpawnAnim } from "./streamAnim.ts";2324 import type { DecorCatalog } from "../gen/decor.ts";2425 import { genVersion } from "../gen/version.ts";2526 import { installCua } from "./cua.ts";
@@ -44,6 +45,8 @@ interface BuiltSegment {
4445 length: number;4546 spinners: Spinner[];4647 islands: number;48+ /** Entry pose, kept for the exit animation's wave direction. */49+ entry: { x: number; y: number; z: number; dir: number };4750 }4851 4952 interface UiRefs {
@@ -130,6 +133,12 @@ export function bootEndless(
130133 islandsSeen: session.islandsSeen,131134 occupancyCells: session.world.occupancySize,132135 theme: session.world.style.theme,136+ // Leak gate: total scene nodes must stay bounded over any drive —137+ // an exit wave that never finishes would show up here.138+ sceneNodes: countNodes(ctx.scene),139+ // Animation lifecycle, for rule-based verification.140+ spawningSegments: session.spawningCount,141+ exitingSegments: session.exitingCount,133142 }134143 : null,135144 }));
@@ -146,6 +155,12 @@ class Session {
146155 147156 private readonly root = new THREE.Group();148157 private readonly built = new Map<number, BuiltSegment>();158+ /** Segments assembling in (index → wave) and sinking out. */159+ private readonly spawning = new Map<number, SpawnAnim>();160+ private exiting: { group: THREE.Group; anim: ExitAnim }[] = [];161+ /** The session's first fill builds instantly — the opening view should162+ * exist, not assemble around the spawning marble. */163+ private initialBuild = true;149164 /** Cumulative curve arc lengths, kept for every index ever built. */150165 private readonly sMeta = new Map<number, { start: number; len: number }>();151166 private readonly pivot: THREE.Group;
@@ -190,6 +205,7 @@ class Session {
190205 ctx.scene.add(this.marbleObj);191206 192207 this.ensure();208+ this.initialBuild = false;193209 const first = this.built.get(0)!;194210 first.curve.getPointAt(0, this.point);195211 this.marbleObj.position.copy(this.point);
@@ -214,6 +230,14 @@ class Session {
214230 return [Math.min(...keys), Math.max(...keys)];215231 }216232 233+ get spawningCount(): number {234+ return this.spawning.size;235+ }236+237+ get exitingCount(): number {238+ return this.exiting.length;239+ }240+217241 /** World point at global arc length s (clamped to the built window). */218242 samplePoint(s: number, out: THREE.Vector3): boolean {219243 const seg = this.segmentAt(s);
@@ -284,17 +308,48 @@ class Session {
284308 length,285309 spinners,286310 islands: data.islands.length,311+ entry: {312+ x: data.plan.entry.x,313+ y: data.plan.entry.y,314+ z: data.plan.entry.z,315+ dir: data.plan.entry.dir,316+ },287317 };288318 this.built.set(i, seg);289319 this.islandsSeen += data.islands.length;320+ // Streamed-in segments assemble as a wave flowing along the travel321+ // direction; the session's opening fill appears whole.322+ if (!this.initialBuild) {323+ const entry = data.plan.entry;324+ const step = [[1, 0], [0, 1], [-1, 0], [0, -1]][entry.dir];325+ this.spawning.set(326+ i,327+ new SpawnAnim(328+ seg.group,329+ new THREE.Vector3(entry.x, entry.y, entry.z),330+ new THREE.Vector3(step[0], 0, step[1]),331+ ),332+ );333+ }290334 return seg;291335 }292336 293337 private disposeSegment(seg: BuiltSegment): void {294- this.root.remove(seg.group);295- // Piece clones share kit resources; nothing per-segment to dispose.296- seg.group.clear();297338 this.built.delete(seg.index);339+ this.spawning.delete(seg.index);340+ // Sink away rather than pop out; the group leaves the scene when the341+ // exit wave finishes. (Occupancy claims release much later — the342+ // generator's keep window — so nothing can build into a sinking spot.)343+ const entry = seg.entry;344+ const step = [[1, 0], [0, 1], [-1, 0], [0, -1]][entry.dir];345+ this.exiting.push({346+ group: seg.group,347+ anim: new ExitAnim(348+ seg.group,349+ new THREE.Vector3(entry.x, entry.y, entry.z),350+ new THREE.Vector3(step[0], 0, step[1]),351+ ),352+ });298353 }299354 300355 /** Keep the render window [cur−BEHIND, cur+AHEAD] built. */
@@ -366,6 +421,17 @@ class Session {
366421 }367422 }368423 424+ // Stream animations: assembly waves ahead, sink-out behind.425+ for (const [idx, anim] of [...this.spawning]) {426+ if (anim.update(dt)) this.spawning.delete(idx);427+ }428+ this.exiting = this.exiting.filter(({ group, anim }) => {429+ if (!anim.update(dt)) return true;430+ this.root.remove(group);431+ group.clear();432+ return false;433+ });434+369435 // The follow camera reads marble.s off the proxy we handed it.370436 this.syncCameraProxy();371437
@@ -393,7 +459,18 @@ class Session {
393459 }394460 395461 dispose(): void {396- for (const seg of [...this.built.values()]) this.disposeSegment(seg);462+ // Session teardown is immediate — no exit waves on a seed change.463+ for (const seg of [...this.built.values()]) {464+ this.root.remove(seg.group);465+ seg.group.clear();466+ }467+ this.built.clear();468+ this.spawning.clear();469+ for (const { group } of this.exiting) {470+ this.root.remove(group);471+ group.clear();472+ }473+ this.exiting = [];397474 this.ctx.scene.remove(this.root);398475 this.ctx.scene.remove(this.marbleObj);399476 this.cams.retargetPath(null, null);
@@ -409,6 +486,12 @@ class Session {
409486 }410487 }411488 489+function countNodes(scene: THREE.Scene): number {490+ let n = 0;491+ scene.traverse(() => n++);492+ return n;493+}494+412495 function makeStarfield(): THREE.Points {413496 const N = 1600;414497 const positions = new Float32Array(N * 3);
A src/client/streamAnim.ts +142 -0
@@ -0,0 +1,142 @@
1+/**2+ * Stream animations: segments and islands ASSEMBLE into the void and SINK3+ * away, instead of popping. Purely visual, wall-clock-eased, per-placement:4+ * a wave of construction flows along the direction of travel (each child's5+ * delay is its projection onto the segment's heading, plus a deterministic6+ * per-position jitter). Generation and physics are untouched — the marble7+ * only ever rides segments whose spawn wave finished long before it arrives8+ * (the render window builds ~3 segments = ~75 units ahead).9+ */10+import * as THREE from "three";11+12+/** Seconds of stagger per unit of distance along the travel direction. */13+const STAGGER_PER_UNIT = 0.045;14+/** Extra deterministic per-piece jitter (seconds). */15+const JITTER = 0.12;16+const SPAWN_DURATION = 0.45;17+const SPAWN_DROP = 2.0;18+const EXIT_DURATION = 0.4;19+const EXIT_SINK = 1.6;20+21+interface Entry {22+ obj: THREE.Object3D;23+ baseY: number;24+ baseScale: THREE.Vector3;25+ delay: number;26+ t: number;27+}28+29+function easeOutBack(t: number): number {30+ const c1 = 1.70158;31+ const c3 = c1 + 1;32+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);33+}34+35+function easeInCubic(t: number): number {36+ return t * t * t;37+}38+39+/** Cheap deterministic hash of a world position → [0, 1). Visual only. */40+function posJitter(x: number, y: number, z: number): number {41+ let h = Math.imul((x * 4 + 1024) | 0, 374761393) ^42+ Math.imul((y * 4 + 2048) | 0, 668265263) ^43+ Math.imul((z * 4 + 4096) | 0, 2246822519);44+ h = Math.imul(h ^ (h >>> 13), 3266489917);45+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;46+}47+48+function collectEntries(49+ group: THREE.Group,50+ origin: THREE.Vector3,51+ heading: THREE.Vector3,52+): Entry[] {53+ const out: Entry[] = [];54+ for (const obj of group.children) {55+ const proj = (obj.position.x - origin.x) * heading.x +56+ (obj.position.z - origin.z) * heading.z;57+ out.push({58+ obj,59+ baseY: obj.position.y,60+ baseScale: obj.scale.clone(),61+ delay: Math.max(0, proj) * STAGGER_PER_UNIT +62+ posJitter(obj.position.x, obj.position.y, obj.position.z) * JITTER,63+ t: 0,64+ });65+ }66+ return out;67+}68+69+export class SpawnAnim {70+ private entries: Entry[];71+72+ constructor(73+ group: THREE.Group,74+ origin: THREE.Vector3,75+ heading: THREE.Vector3,76+ ) {77+ this.entries = collectEntries(group, origin, heading);78+ // First frame state: everything hidden above its target, tiny.79+ for (const e of this.entries) {80+ e.obj.position.y = e.baseY + SPAWN_DROP;81+ e.obj.scale.copy(e.baseScale).multiplyScalar(0.001);82+ }83+ }84+85+ /** Advance; returns true when every piece has settled. */86+ update(dt: number): boolean {87+ let done = true;88+ for (const e of this.entries) {89+ e.t += dt;90+ const k = (e.t - e.delay) / SPAWN_DURATION;91+ if (k <= 0) {92+ done = false;93+ continue;94+ }95+ if (k >= 1) {96+ e.obj.position.y = e.baseY;97+ e.obj.scale.copy(e.baseScale);98+ continue;99+ }100+ done = false;101+ const f = easeOutBack(k);102+ e.obj.position.y = e.baseY + SPAWN_DROP * (1 - f);103+ const s = Math.max(0.001, Math.min(1, k * 1.6));104+ e.obj.scale.copy(e.baseScale).multiplyScalar(s);105+ }106+ return done;107+ }108+}109+110+export class ExitAnim {111+ private entries: Entry[];112+113+ constructor(114+ group: THREE.Group,115+ origin: THREE.Vector3,116+ heading: THREE.Vector3,117+ ) {118+ this.entries = collectEntries(group, origin, heading);119+ }120+121+ /** Advance; returns true when every piece has sunk away. */122+ update(dt: number): boolean {123+ let done = true;124+ for (const e of this.entries) {125+ e.t += dt;126+ const k = (e.t - e.delay) / EXIT_DURATION;127+ if (k <= 0) {128+ done = false;129+ continue;130+ }131+ if (k >= 1) {132+ e.obj.visible = false;133+ continue;134+ }135+ done = false;136+ const f = easeInCubic(k);137+ e.obj.position.y = e.baseY - EXIT_SINK * f;138+ e.obj.scale.copy(e.baseScale).multiplyScalar(Math.max(0.001, 1 - f));139+ }140+ return done;141+ }142+}