clone

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

Pipe color per seed (swatch-level recolor), banner straight-window rule, island-block-in-channel regression scenario; seed freeze dropped (user)

Dami committed 9453935
commit
9453935d5eba6e081052183c49f2997a8dd22209
parent
3c23b4607a9ca3f1d9c5dc8c53ebedfeb13de4ee
author
Dami <dami.etoile@gmail.com>
6 files changed, +184 -12
Mprogress.md42++++++++++++++++---
Msrc/client/assets.ts66++++++++++++++++++++++++++++--
Msrc/client/endless.ts3+-
Asrc/client/pipeColor.ts41+++++++++++++++++++
Msrc/client/track.ts3+-
Mtests/scenario_test.ts41+++++++++++++++++++
M progress.md +36 -6
@@ -802,12 +802,42 @@ console errors); terraces read on screen. Gravity rotation / bouncers /
802802 icy: deferred to M13 backlog (icy needs a visible surface treatment first803803 — zones must READ, per the set-piece rule).804804 805-**M13 — Seed freeze + release polish (the old M6, moved to the end because the806-slalom rescale and physics change everything before it).** Goldens permanent807-INCLUDING endless stream goldens (pin segment hashes for fixed seeds); banking808-lean; ambient audio; remaining old backlog (curve-wide scenery macro, tee-spur,809-takeable spur branches, wide bridges, hill-interval constraint, per-branch810-diamond supports, distance-graded ambient decor).805+**M13 — Release polish. SEED FREEZE IS OFF (user, 2026-08-08: "lets not806+seed freeze. its a limitation for no reason yet") — goldens stay807+regenerable-on-change indefinitely.** Remaining polish backlog: banking808+lean; ambient audio; curve-wide scenery macro, tee-spur, takeable spur809+branches, wide bridges, hill-interval constraint, per-branch diamond810+supports, distance-graded ambient decor; gravity rotation / bouncers / icy811+zones (icy needs a visible surface treatment first — zones must READ).812+813+**DONE this round (post-M12, user-directed):**814+815+- **PIPE COLOR VARIATION** (user: "the pipe color should be able to816+ change"): the track's plastic body color is now a per-seed axis.817+ Measured the pipe-body swatch texel per colormap variation (straight/818+ curve/slant UVs → a=(207,83,79), b=(255,192,68), c=(134,139,161));819+ applyPalette gained an optional pipe color that repaints exactly those820+ texels on a canvas copy (swatch-level recolor, NOT a blanket tint —821+ interiors/rails/decor untouched; small LRU of derived CanvasTextures).822+ Color drawn client-side from rngFamily(seed, "pipe-color")823+ (src/client/pipeColor.ts): 22% stock, else HSL h∈[0,1) s 0.35–0.75824+ l 0.45–0.67. ZERO generator impact (goldens/genVersion untouched). Both825+ modes wired (endless Session + loop buildTrack). Verified: det-seed826+ crimson, abs-1 mint, jump-scan-7 varied; 0 console errors.827+- **BANNER RULE** (user: "flags cannot go on curved pieces or in the same828+ spot as a tunnel piece"): zone banners only place on windows whose829+ family is "straight" (excludes waves/wiggles — F-move but the channel830+ swings away from the posts — and tunnel arches). EndlessSegment gained831+ windowRanges so tests map cells→windows; the jump-lip banner assertion832+ is now conditional (banner ⟺ lip realized plain straight); new corpus833+ rule test pins every banner to a straight window.834+- **REGRESSION island-block-in-channel** (user screenshot, seed835+ hctqaix2hl seg 25: mass blocks stood in the channel and stopped the836+ marble): this was the M12 envelope corridor-blindness bug — already837+ fixed in the M12 commit (same-segment claims transparent only on838+ run/branch cells); the user's browser ran a pre-fix bundle. Verified 0839+ block-vs-channel intersections through seg 30 on hctqaix2hl + 6 more840+ seeds; pinned as a permanent placement-level named scenario.811841 812842 ### Absorbed M5-polish backlog (fold into milestones above, grab813843
M src/client/assets.ts +62 -4
@@ -23,8 +23,13 @@ export interface Assets {
2323 natureIds: string[];2424 /** Clone of a template with shadows enabled, or null for unknown ids. */2525 getPiece(id: string): THREE.Object3D | null;26- /** Swap the marble kit's shared colormap to variation a/b/c. */27- applyPalette(index: 0 | 1 | 2): void;26+ /** Swap the marble kit's shared colormap to variation a/b/c; an27+ * optional pipe color recolors the track-body swatch (per-seed28+ * variation — null keeps the kit's stock color). */29+ applyPalette(30+ index: 0 | 1 | 2,31+ pipeColor?: readonly [number, number, number] | null,32+ ): void;2833 }2934 3035 function parseGLB(loader: GLTFLoader, buf: ArrayBuffer): Promise<THREE.Group> {
@@ -135,8 +140,61 @@ export async function loadAssets(): Promise<Assets> {
135140 const marbleMaterials = collectMappedMaterials(marbleScene);136141 const baseMaps = marbleMaterials.map((m) => m.map);137142 138- function applyPalette(index: 0 | 1 | 2): void {139- const tex = palettes[index];143+ // Pipe-body swatch color per variation (measured: straight/curve/slant144+ // UVs land on exactly this texel color in each colormap). Per-seed pipe145+ // colors repaint every matching texel on a canvas copy — a swatch-level146+ // recolor, not a blanket material tint.147+ const PIPE_SWATCH: readonly (readonly [number, number, number])[] = [148+ [207, 83, 79], // variation-a149+ [255, 192, 68], // variation-b150+ [134, 139, 161], // variation-c151+ ];152+ const derived = new Map<string, THREE.Texture>();153+154+ function pipeTexture(155+ index: 0 | 1 | 2,156+ color: readonly [number, number, number],157+ ): THREE.Texture {158+ const cacheKey = `${index}/${color.join(",")}`;159+ const hit = derived.get(cacheKey);160+ if (hit) return hit;161+ const src = palettes[index].image as HTMLImageElement;162+ const canvas = document.createElement("canvas");163+ canvas.width = src.width;164+ canvas.height = src.height;165+ const ctx = canvas.getContext("2d")!;166+ ctx.drawImage(src, 0, 0);167+ const img = ctx.getImageData(0, 0, canvas.width, canvas.height);168+ const [pr, pg, pb] = PIPE_SWATCH[index];169+ const d = img.data;170+ for (let i = 0; i < d.length; i += 4) {171+ if (d[i] === pr && d[i + 1] === pg && d[i + 2] === pb) {172+ d[i] = color[0];173+ d[i + 1] = color[1];174+ d[i + 2] = color[2];175+ }176+ }177+ ctx.putImageData(img, 0, 0);178+ const tex = new THREE.CanvasTexture(canvas);179+ tex.flipY = false;180+ tex.colorSpace = THREE.SRGBColorSpace;181+ // Bound the cache (one texture per visited seed otherwise).182+ if (derived.size >= 8) {183+ const oldest = derived.keys().next().value;184+ if (oldest !== undefined) {185+ derived.get(oldest)?.dispose();186+ derived.delete(oldest);187+ }188+ }189+ derived.set(cacheKey, tex);190+ return tex;191+ }192+193+ function applyPalette(194+ index: 0 | 1 | 2,195+ pipeColor: readonly [number, number, number] | null = null,196+ ): void {197+ const tex = pipeColor ? pipeTexture(index, pipeColor) : palettes[index];140198 if (!tex) return;141199 marbleMaterials.forEach((mat, i) => {142200 const base = baseMaps[i];
M src/client/endless.ts +2 -1
A src/client/pipeColor.ts +41 -0
@@ -0,0 +1,41 @@
1+/**2+ * Per-seed pipe color (user request): the track's plastic body color is a3+ * variation axis, not a fixed kit palette. Drawn from its own RNG family4+ * (`pipe-color`) so it's deterministic per seed and touches NOTHING in5+ * the generator — placements, goldens, and genVersion are unaffected; the6+ * recolor happens at the texture level in the client (assets.ts swaps the7+ * pipe swatch texels of the active colormap).8+ */9+import { rngFamily } from "../gen/rng.ts";10+11+export type Rgb = readonly [number, number, number];12+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");18+ if (rng.chance(0.22)) return null; // classic kit look19+ const h = rng.float();20+ const s = 0.35 + rng.float() * 0.4;21+ const l = 0.45 + rng.float() * 0.22;22+ return hslToRgb(h, s, l);23+}24+25+function hslToRgb(h: number, s: number, l: number): Rgb {26+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;27+ const p = 2 * l - q;28+ const channel = (t: number): number => {29+ if (t < 0) t += 1;30+ if (t > 1) t -= 1;31+ if (t < 1 / 6) return p + (q - p) * 6 * t;32+ if (t < 1 / 2) return q;33+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;34+ return p;35+ };36+ return [37+ Math.round(channel(h + 1 / 3) * 255),38+ Math.round(channel(h) * 255),39+ Math.round(channel(h - 1 / 3) * 255),40+ ];41+}
M src/client/track.ts +2 -1
M tests/scenario_test.ts +41 -0
@@ -352,6 +352,47 @@ Deno.test("bump lips: bumps realize ONLY fully inside a boost zone", () => {
352352 assert(bumps > 0, "no bump lips emerged across the corpus");353353 });354354 355+Deno.test("regression island-block-in-channel (hctqaix2hl): mass never blocks a co-live channel", () => {356+ // USER REPORT (screenshot, seed hctqaix2hl seg 25): island mass blocks357+ // stood inside the channel and stopped the marble dead — the envelope358+ // loop was blind to same-segment channel claims (the island's own359+ // approach corridor). Placement-level check: no island block may360+ // intersect any co-live channel's clearance band.361+ const blockH = (id: string) =>362+ id.includes("Quarter") ? 0.25 : id.includes("Half") ? 0.5 : 1;363+ for (const seed of ["hctqaix2hl", "det-seed"]) {364+ const w = new EndlessWorld(seed, catalog);365+ const chans: { x: number; z: number; y: number; seg: number }[] = [];366+ for (let i = 0; i <= 26; i++) {367+ const seg = w.get(i);368+ for (const c of seg.plan.cells) {369+ chans.push({ x: c.x, z: c.z, y: c.baseY, seg: i });370+ }371+ for (const isle of seg.islands) {372+ for (const p of isle.placements) {373+ if (p.role !== "support") continue;374+ const h = blockH(p.pieceId);375+ const [bx, by, bz] = p.pos;376+ for (const ch of chans) {377+ if (ch.seg <= i - KEEP_SEGMENTS) continue;378+ if (379+ Math.abs(ch.x * TRACK_SCALE - bx) > 1.01 ||380+ Math.abs(ch.z * TRACK_SCALE - bz) > 1.01381+ ) continue;382+ const y0 = ch.y * TRACK_SCALE;383+ const y1 = y0 + 1.98;384+ assert(385+ !(by < y1 - 1e-6 && y0 < by + h - 1e-6),386+ `${seed} seg ${i}: island ${isle.id} block at (${p.pos}) ` +387+ `intersects seg ${ch.seg} channel (${ch.x},${ch.z})`,388+ );389+ }390+ }391+ }392+ }393+ }394+});395+355396 Deno.test("zone banners: only on plain straight windows (never curves/tunnels)", () => {356397 // USER RULE: flags/banners cannot go on curved pieces (waves/wiggles357398 // swing the channel away from the posts) or share a cell with a tunnel