- commit
- fd6d0fca15e30265bfc14c2641bee7f9b1937294
- parent
- 9453935d5eba6e081052183c49f2997a8dd22209
- author
- Dami <dami.etoile@gmail.com>
| M | progress.md | 16 | ++++++ |
| M | src/ | 78 | +++++++++++++++++++++++++++--- |
| M | src/ | 34 | ++++++++++++-- |
| M | src/ | 4 | +- |
| M | src/ | 6 | ++- |
| M | src/ | 20 | ++++++-- |
| M | src/ | 5 | ++ |
| M | src/ | 32 | ++++++++++-- |
| M | src/ | 3 | + |
| M | tests/ | 41 | ++++++++++++++++ |
@@ -812,6 +812,22 @@ zones (icy needs a visible surface treatment first — zones must READ).
812812 813813 **DONE this round (post-M12, user-directed):**814814 815+- **PIPE COLOR IS PER ISLAND** (user refinement of the round below): in816+ endless, every Placement carries a `chapter` (the most recent island's817+ world-unique id at its cells — world.ts stamps it via the window→818+ placement span walk; pads too). The client draws one color per chapter819+ from rngFamily(seed, `pipe-color/${islandId}`) and swaps that820+ placement's shared colormap materials for cached clones over the821+ derived texture (assets.applyPipeColor; one texture+material set per822+ (variation, color), LRU 16, evicted together). The journey reads as823+ color chapters that turn over at each island anchor; two chapters are824+ often visible at once across the void. Loop mode keeps its per-seed825+ draw. Scenario: every track placement chaptered, chapters826+ non-decreasing, ≥2 chapters emerge early.827+- **MARBLE SHRUNK 50%** (user): MARBLE_RADIUS 0.2→0.1, kit mesh scaled by828+ MARBLE_SCALE 0.5 in both modes (channel ≈ 7 marble diameters now — the829+ full slalom). All physics gates re-validated green with the small ball;830+ 6-sim-min soak 0 deaths, 0 errors.815831 - **PIPE COLOR VARIATION** (user: "the pipe color should be able to816832 change"): the track's plastic body color is now a per-seed axis.817833 Measured the pipe-body swatch texel per colormap variation (straight/@@ -30,6 +30,13 @@ export interface Assets {
3030 index: 0 | 1 | 2,3131 pipeColor?: readonly [number, number, number] | null,3232 ): void;33+ /** Recolor ONE cloned piece's pipe swatch (endless per-island34+ * chapters): swaps its shared colormap materials for cached clones35+ * whose texture has the pipe texels repainted. Null is a no-op. */36+ applyPipeColor(37+ root: THREE.Object3D,38+ color: readonly [number, number, number] | null,39+ ): void;3340 }3441 3542 function parseGLB(loader: GLTFLoader, buf: ArrayBuffer): Promise<THREE.Group> {@@ -149,12 +156,18 @@ export async function loadAssets(): Promise<Assets> {
149156 [255, 192, 68], // variation-b150157 [134, 139, 161], // variation-c151158 ];152- const derived = new Map<string, THREE.Texture>();159+ /** Derived pipe-colored colormaps + material clones, one entry per160+ * (variation, color) — chapters share entries; LRU-bounded, texture161+ * and materials evicted together. */162+ const derived = new Map<163+ string,164+ { tex: THREE.Texture; mats: THREE.MeshStandardMaterial[] | null }165+ >();153166 154- function pipeTexture(167+ function pipeEntry(155168 index: 0 | 1 | 2,156169 color: readonly [number, number, number],157- ): THREE.Texture {170+ ): { tex: THREE.Texture; mats: THREE.MeshStandardMaterial[] | null } {158171 const cacheKey = `${index}/${color.join(",")}`;159172 const hit = derived.get(cacheKey);160173 if (hit) return hit;@@ -178,23 +191,69 @@ export async function loadAssets(): Promise<Assets> {
178191 const tex = new THREE.CanvasTexture(canvas);179192 tex.flipY = false;180193 tex.colorSpace = THREE.SRGBColorSpace;181- // Bound the cache (one texture per visited seed otherwise).182- if (derived.size >= 8) {194+ // Match the base map's UV transform once, up front — the derived195+ // texture is shared by material clones that never pass through196+ // applyPalette.197+ const base0 = baseMaps[0];198+ if (base0) {199+ tex.wrapS = base0.wrapS;200+ tex.wrapT = base0.wrapT;201+ tex.offset.copy(base0.offset);202+ tex.repeat.copy(base0.repeat);203+ tex.center.copy(base0.center);204+ tex.rotation = base0.rotation;205+ }206+ // Bound the cache (chapters accumulate over an endless journey; the207+ // live render window only ever needs a handful).208+ if (derived.size >= 16) {183209 const oldest = derived.keys().next().value;184210 if (oldest !== undefined) {185- derived.get(oldest)?.dispose();211+ const entry = derived.get(oldest);212+ entry?.tex.dispose();213+ for (const m of entry?.mats ?? []) m.dispose();186214 derived.delete(oldest);187215 }188216 }189- derived.set(cacheKey, tex);190- return tex;217+ const made = { tex, mats: null };218+ derived.set(cacheKey, made);219+ return made;220+ }221+222+ let currentIndex: 0 | 1 | 2 = 0;223+224+ function applyPipeColor(225+ root: THREE.Object3D,226+ color: readonly [number, number, number] | null,227+ ): void {228+ if (!color) return;229+ const entry = pipeEntry(currentIndex, color);230+ if (!entry.mats) {231+ entry.mats = marbleMaterials.map((m) => {232+ const clone = m.clone();233+ clone.map = entry.tex;234+ clone.needsUpdate = true;235+ return clone;236+ });237+ }238+ root.traverse((obj: THREE.Object3D) => {239+ const mesh = obj as THREE.Mesh;240+ if (!mesh.isMesh) return;241+ const swap = (m: THREE.Material): THREE.Material => {242+ const at = marbleMaterials.indexOf(m as THREE.MeshStandardMaterial);243+ return at >= 0 ? entry.mats![at] : m;244+ };245+ mesh.material = Array.isArray(mesh.material)246+ ? mesh.material.map(swap)247+ : swap(mesh.material);248+ });191249 }192250 193251 function applyPalette(194252 index: 0 | 1 | 2,195253 pipeColor: readonly [number, number, number] | null = null,196254 ): void {197- const tex = pipeColor ? pipeTexture(index, pipeColor) : palettes[index];255+ currentIndex = index;256+ const tex = pipeColor ? pipeEntry(index, pipeColor).tex : palettes[index];198257 if (!tex) return;199258 marbleMaterials.forEach((mat, i) => {200259 const base = baseMaps[i];@@ -235,5 +294,6 @@ export async function loadAssets(): Promise<Assets> {
235294 natureIds: natureManifest.pieces.map((p: ManifestPiece) => p.id),236295 getPiece,237296 applyPalette,297+ applyPipeColor,238298 };239299 }@@ -22,7 +22,11 @@ import { type Cameras, type CamMode, createCameras } from "./camera.ts";
2222 import { EndlessWorld, KEEP_SEGMENTS } from "../gen/endless/world.ts";2323 import { TRACK_SCALE } from "../gen/endless/scale.ts";2424 import { ExitAnim, SpawnAnim } from "./streamAnim.ts";25-import { PhysicsEngine, type PhysicsModule } from "./physics/engine.ts";25+import {26+ MARBLE_SCALE,27+ PhysicsEngine,28+ type PhysicsModule,29+} from "./physics/engine.ts";2630 import {2731 gapSegmentsOf,2832 hasAltBranch,@@ -207,7 +211,7 @@ class Session {
207211 /** Cumulative curve arc lengths, kept for every index ever built. */208212 private readonly sMeta = new Map<number, { start: number; len: number }>();209213 private readonly pivot: THREE.Group;210- private radius = 0.2;214+ private radius = 0.1;211215 private statsClock = 0;212216 213217 readonly atmosphere: Atmosphere;@@ -217,6 +221,22 @@ class Session {
217221 /** Active diamond branch per segment — respawns alternate it, so a218222 * fall's retry may ride the OTHER branch (respawn variety). */219223 private readonly branchOf = new Map<number, "main" | "alt">();224+ /** Pipe color per island chapter (drawn once, cached). */225+ private readonly chapterColors = new Map<226+ number,227+ readonly [number, number, number] | null228+ >();229+230+ private chapterColor(231+ chapter: number,232+ ): readonly [number, number, number] | null {233+ let c = this.chapterColors.get(chapter);234+ if (c === undefined) {235+ c = drawPipeColor(this.seed, chapter);236+ this.chapterColors.set(chapter, c);237+ }238+ return c;239+ }220240 private nearest: { seg: number; idx: number } = { seg: 0, idx: 0 };221241 deaths = 0;222242 /** Stall net: no rail progress for a while → respawn. Progress must@@ -249,7 +269,9 @@ class Session {
249269 this.seed = seed;250270 if (physics) this.engine = new PhysicsEngine(physics);251271 this.world = new EndlessWorld(seed, catalog);252- assets.applyPalette(this.world.style.paletteIndex, drawPipeColor(seed));272+ // Base palette stays stock — pipe color is PER ISLAND CHAPTER (user273+ // rule), applied per placement at build time.274+ assets.applyPalette(this.world.style.paletteIndex);253275 this.root.name = `endless:${seed}`;254276 ctx.scene.add(this.root);255277 this.atmosphere = new Atmosphere(ctx, stars, drawZone(seed, 0));@@ -261,10 +283,13 @@ class Session {
261283 this.marbleObj.add(this.pivot);262284 const mesh = assets.getPiece(MARBLE_PIECE);263285 if (mesh) {286+ // Half the kit's authored size (user: "shrink the ball 50%") —287+ // matches the physics MARBLE_RADIUS.288+ mesh.scale.setScalar(MARBLE_SCALE);264289 const box = new THREE.Box3().setFromObject(mesh);265290 const center = box.getCenter(new THREE.Vector3());266291 const size = box.getSize(new THREE.Vector3());267- this.radius = Math.max(size.x, size.y, size.z) / 2 || 0.2;292+ this.radius = Math.max(size.x, size.y, size.z) / 2 || 0.1;268293 this.pivot.position.copy(center);269294 mesh.position.sub(center);270295 this.pivot.add(mesh);@@ -355,6 +380,7 @@ class Session {
355380 group,356381 spinners,357382 missing,383+ (p) => p.chapter === undefined ? null : this.chapterColor(p.chapter),358384 );359385 // Islands render INSTANCED with per-instance color variation (strata,360386 // moss/rust accents, per-island foliage lean) — islandRender.ts.@@ -46,8 +46,10 @@ export function createMarble(
4646 obj.add(pivot);4747 4848 const mesh = assets.getPiece(MARBLE_PIECE);49- let radius = 0.2;49+ let radius = 0.1;5050 if (mesh) {51+ // Half the kit's authored size (user: "shrink the ball 50%").52+ mesh.scale.setScalar(0.5);5153 // Recenter the grounded mesh inside the pivot, then lift the pivot by the5254 // same offset so the sphere's contact point stays at the object's origin.5355 const box = new THREE.Box3().setFromObject(mesh);@@ -63,7 +63,11 @@ export interface MarbleState {
6363 quat: [number, number, number, number];6464 }6565 66-export const MARBLE_RADIUS = 0.2;66+/** Physics marble radius — HALF the kit mesh's (user: "shrink the ball67+ * 50%"); the render mesh scales by MARBLE_SCALE to match. */68+export const MARBLE_RADIUS = 0.1;69+/** Visual scale applied to the kit's marble piece. */70+export const MARBLE_SCALE = 0.5;6771 /** Default marble restitution (shim spawn value); shock zones override. */6872 export const MARBLE_RESTITUTION = 0.35;6973 /** Marble Blast's gravity at our scale (MBHaxe: _gravity = 20) — adopted@@ -10,11 +10,21 @@ import { rngFamily } from "../gen/rng.ts";
1010 1111 export type Rgb = readonly [number, number, number];1212 13-/** Pipe body color for a seed, or null to keep the kit's stock swatch.14- * Pastel-to-rich range: saturation and lightness bounded so the channel15- * stays readable against every atmosphere. */16-export function drawPipeColor(seed: string): Rgb | null {17- const rng = rngFamily(seed, "pipe-color");13+/** Pipe body color, or null to keep the kit's stock swatch. Loop mode14+ * draws once per seed; endless draws PER ISLAND CHAPTER (user rule: the15+ * pipe color is per island — the line launched from an island carries16+ * its color until the next anchor). Pastel-to-rich range: saturation and17+ * lightness bounded so the channel stays readable against every18+ * atmosphere. */19+export function drawPipeColor(20+ seed: string,21+ chapter: number | null = null,22+): Rgb | null {23+ if (chapter !== null && chapter < 0) return null; // before the home island24+ const rng = rngFamily(25+ seed,26+ chapter === null ? "pipe-color" : `pipe-color/${chapter}`,27+ );1828 if (rng.chance(0.22)) return null; // classic kit look1929 const h = rng.float();2030 const s = 0.35 + rng.float() * 0.4;@@ -28,6 +28,8 @@ export function instantiatePlacements(
2828 group: THREE.Group,2929 spinners: Spinner[],3030 missing: Set<string>,31+ /** Per-placement pipe color (endless island chapters); null = stock. */32+ pipeColorFor?: (p: Placement) => readonly [number, number, number] | null,3133 ): THREE.Material[] {3234 const goalMaterials: THREE.Material[] = [];3335 for (const [index, p] of placements.entries()) {@@ -38,6 +40,9 @@ export function instantiatePlacements(
3840 }3941 // Inspect mode resolves picked meshes back to their placement.4042 obj.userData.placementIndex = index;43+ if (!p.goal && pipeColorFor) {44+ assets.applyPipeColor(obj, pipeColorFor(p));45+ }4146 obj.position.set(p.pos[0], p.pos[1], p.pos[2]);4247 obj.rotation.y = p.rotation * Math.PI / 2;4348 const s = p.scale ?? 1;@@ -304,6 +304,18 @@ export class EndlessWorld {
304304 }305305 result.tx.commit();306306 const { plan, next, windows, skeleton, elevation } = result;307+ // Pipe-color chapters (user rule: the pipe color is PER ISLAND): a308+ // cell belongs to the most recent island's chapter — islands opened309+ // in this segment take over from their run start; earlier cells310+ // carry the chapter the line entered with.311+ const entryChapter = this.state.islandCount - 1;312+ const cellChapter = (idx: number): number => {313+ let ch = entryChapter;314+ for (const c of plan.crossings) {315+ if (c.start <= idx && c.id > ch) ch = c.id;316+ }317+ return ch;318+ };307319 this.state = next;308320 const assembled = assemble(skeleton, elevation, this.style, windows, {309321 open: true,@@ -356,11 +368,11 @@ export class EndlessWorld {
356368 // what the eye sees. Placements are emitted in window order, so a357369 // window's span runs from its placementIndex to the next window's.358370 const dropped = new Set<number>();371+ const chapterOf = new Array<number>(assembled.placements.length).fill(372+ entryChapter,373+ );359374 const loopSegs = assembled.loop.segments;360375 for (let wi = 0; wi < loopSegs.length; wi++) {361- if (windowMods[wi] !== "jump" && windowMods[wi] !== "propeller") {362- continue;363- }364376 const startIdx = loopSegs[wi].placementIndex;365377 if (startIdx < 0) continue;366378 let end = assembled.placements.length;@@ -370,7 +382,13 @@ export class EndlessWorld {
370382 break;371383 }372384 }373- for (let k = startIdx; k < end; k++) dropped.add(k);385+ const ch = cellChapter(windows[wi].start);386+ for (let k = startIdx; k < end; k++) {387+ chapterOf[k] = ch;388+ if (windowMods[wi] === "jump" || windowMods[wi] === "propeller") {389+ dropped.add(k);390+ }391+ }374392 }375393 376394 // Zone pad visuals (M11): set-pieces must READ on screen. Banners@@ -409,6 +427,7 @@ export class EndlessWorld {
409427 pos: [lip.x, lip.baseY + 0.5, lip.z],410428 rotation: ((1 - lip.dirIn + 4) % 4) as Dir,411429 role: "track",430+ chapter: cellChapter(m.start - 1),412431 });413432 }414433 } else if (m.kind === "boost") {@@ -426,6 +445,7 @@ export class EndlessWorld {
426445 pos: [c.x, c.baseY + 0.5, c.z],427446 rotation: ((1 - c.dirIn + 4) % 4) as Dir,428447 role: "track",448+ chapter: cellChapter(idx),429449 });430450 break;431451 }@@ -436,7 +456,9 @@ export class EndlessWorld {
436456 // track pieces at ×TRACK_SCALE (position and mesh scale) while islands437457 // and decor stay 1×. The centerline scales with the track.438458 const placements = [439- ...assembled.placements.filter((_, idx) => !dropped.has(idx)),459+ ...assembled.placements460+ .map((p, idx): Placement => ({ ...p, chapter: chapterOf[idx] }))461+ .filter((_, idx) => !dropped.has(idx)),440462 ...pads,441463 ].map((p): Placement => ({442464 ...p,@@ -43,6 +43,9 @@ export interface Placement {
4343 spin?: { axis: Vec3; radPerSec: number };4444 /** Lap-line gate: renderer restyles this placement distinctly. */4545 goal?: boolean;46+ /** Endless pipe-color chapter (the island id whose reach this track47+ * piece is in) — the client recolors the pipe swatch per chapter. */48+ chapter?: number;4649 }4750 4851 /** A pierced bridge deck: the marble MAY drop through onto the channel@@ -429,6 +429,47 @@ Deno.test("zone banners: only on plain straight windows (never curves/tunnels)",
429429 assert(banners > 0, "no banners emerged across the corpus");430430 });431431 432+Deno.test("pipe chapters: every track placement carries its island's chapter", () => {433+ // USER RULE: the pipe color is PER ISLAND. Every track placement in an434+ // endless segment carries a chapter (the most recent island's id at its435+ // cells); chapters never decrease along a segment's windows, step only436+ // at island run starts, and the stream shows ≥2 chapters early (colors437+ // actually change).438+ const seeds = ["det-seed", "atmo-seed", "jump-scan-3"];439+ const chaptersSeen = new Set<number>();440+ for (const seed of seeds) {441+ const w = new EndlessWorld(seed, catalog);442+ let prevSegLast = -1;443+ for (let i = 0; i < 4; i++) {444+ const seg = w.get(i);445+ let last = prevSegLast;446+ for (const p of seg.placements) {447+ if (p.role !== "track") continue;448+ assert(449+ p.chapter !== undefined,450+ `${seed} seg ${i}: track placement ${p.pieceId} missing chapter`,451+ );452+ chaptersSeen.add(p.chapter!);453+ }454+ // Windows walk forward: chapter per window start must be455+ // non-decreasing and only ever step to an island id in this stream.456+ for (const r of seg.windowRanges) {457+ let ch = prevSegLast;458+ for (const c of seg.plan.crossings) {459+ if (c.start <= r.start && c.id > ch) ch = c.id;460+ }461+ assert(ch >= last, `${seed} seg ${i}: chapter decreased`);462+ last = ch;463+ }464+ prevSegLast = last;465+ }466+ }467+ assert(468+ chaptersSeen.size >= 2,469+ "streams never changed pipe chapter — per-island color is dead",470+ );471+});472+432473 Deno.test("island verticality: sloped runs terrace, every cell anchored at its height", () => {433474 // M12: island runs may embed ONE slope window. Rules: terrace steps are434475 // bounded by the slope vocab (≤0.5 per cell along the profile); every