1/**2 * Asset loading: two GLB kits + their manifests + the palette variation maps.3 *4 * Parsed scenes are kept as TEMPLATES and never added to the render scene —5 * `getPiece` hands out clones (geometry/material are shared, so clones are6 * cheap). Nature-kit ids are namespaced with the `nature-kit:` prefix to match7 * the generator's decor placements.8 */9import * as THREE from "three";10import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";11import type { ManifestPiece, PackManifest } from "../shared/types.ts";1213export const MARBLE_PACK = "marble-kit";14export const NATURE_PACK = "nature-kit";15/** Every decor pack the client streams (template keys "<slug>:<id>").16 * The marble kit stays special (bare keys, palette machinery). */17export const DECOR_PACKS = [18 NATURE_PACK,19 // M18a theme packs.20 "holiday-kit",21 "graveyard-kit",22 "pirate-kit",23 "survival-kit",24 // M18b void decor.25 "space-kit",26 // Island mass families (block voxel bodies).27 "platformer-kit",28] as const;2930export interface Assets {31 /** pieceId → template object (marble-kit bare, decor packs prefixed). */32 templates: Map<string, THREE.Object3D>;33 marbleManifest: PackManifest;34 natureManifest: PackManifest;35 /** Every decor-pack manifest by slug (nature-kit included). */36 manifests: Map<string, PackManifest>;37 /** Ordered ids as they appear in each manifest. */38 marbleIds: string[];39 natureIds: string[];40 /** Clone of a template with shadows enabled, or null for unknown ids. */41 getPiece(id: string): THREE.Object3D | null;42 /** Swap the marble kit's shared colormap to variation a/b/c; an43 * optional pipe color recolors the track-body swatch (per-seed44 * variation — null keeps the kit's stock color). */45 applyPalette(46 index: 0 | 1 | 2,47 pipeColor?: readonly [number, number, number] | null,48 ): void;49 /** Recolor ONE cloned piece's pipe swatch (endless per-island50 * chapters): swaps its shared colormap materials for cached clones51 * whose texture has the pipe texels repainted. Null is a no-op. */52 applyPipeColor(53 root: THREE.Object3D,54 color: readonly [number, number, number] | null,55 ): void;56}5758function parseGLB(loader: GLTFLoader, buf: ArrayBuffer): Promise<THREE.Group> {59 return new Promise((resolve, reject) => {60 loader.parse(61 buf,62 "",63 (gltf: { scene: THREE.Group }) => resolve(gltf.scene),64 reject,65 );66 });67}6869async function fetchBuffer(url: string): Promise<ArrayBuffer> {70 const res = await fetch(url);71 if (!res.ok) throw new Error(`${url}: ${res.status} ${res.statusText}`);72 return await res.arrayBuffer();73}7475async function fetchJSON<T>(url: string): Promise<T> {76 const res = await fetch(url);77 if (!res.ok) throw new Error(`${url}: ${res.status} ${res.statusText}`);78 return await res.json() as T;79}8081function loadTexture(url: string): Promise<THREE.Texture> {82 return new THREE.TextureLoader().loadAsync(url).then((tex: THREE.Texture) => {83 tex.flipY = false;84 tex.colorSpace = THREE.SRGBColorSpace;85 tex.needsUpdate = true;86 return tex;87 });88}8990/** Materials in a subtree that carry a colormap, deduplicated. */91function collectMappedMaterials(92 root: THREE.Object3D,93): THREE.MeshStandardMaterial[] {94 const out = new Set<THREE.MeshStandardMaterial>();95 root.traverse((obj: THREE.Object3D) => {96 const mesh = obj as THREE.Mesh;97 if (!mesh.isMesh) return;98 const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];99 for (const m of mats) {100 const std = m as THREE.MeshStandardMaterial;101 if (std && std.map) out.add(std);102 }103 });104 return [...out];105}106107export async function loadAssets(): Promise<Assets> {108 const loader = new GLTFLoader();109110 const [111 marbleBuf,112 marbleManifest,113 texA,114 texB,115 texC,116 ...decorLoads117 ] = await Promise.all([118 fetchBuffer("/assets/marble-kit.glb"),119 fetchJSON<PackManifest>("/assets/marble-kit.manifest.json"),120 loadTexture("/assets/textures/variation-a.png"),121 loadTexture("/assets/textures/variation-b.png"),122 loadTexture("/assets/textures/variation-c.png"),123 ...DECOR_PACKS.flatMap((slug) => [124 fetchBuffer(`/assets/${slug}.glb`),125 fetchJSON<PackManifest>(`/assets/${slug}.manifest.json`),126 ]),127 ] as const) as [128 ArrayBuffer,129 PackManifest,130 THREE.Texture,131 THREE.Texture,132 THREE.Texture,133 ...(ArrayBuffer | PackManifest)[],134 ];135136 const manifests = new Map<string, PackManifest>();137 const decorScenes: THREE.Group[] = [];138 const marbleScene = await parseGLB(loader, marbleBuf);139 for (let i = 0; i < DECOR_PACKS.length; i++) {140 const buf = decorLoads[i * 2] as ArrayBuffer;141 const manifest = decorLoads[i * 2 + 1] as PackManifest;142 manifests.set(DECOR_PACKS[i], manifest);143 decorScenes.push(await parseGLB(loader, buf));144 }145 const natureManifest = manifests.get(NATURE_PACK)!;146147 const templates = new Map<string, THREE.Object3D>();148 for (const child of marbleScene.children) {149 if (child.name) templates.set(child.name, child);150 }151 for (let i = 0; i < DECOR_PACKS.length; i++) {152 for (const child of decorScenes[i].children) {153 if (child.name) templates.set(`${DECOR_PACKS[i]}:${child.name}`, child);154 }155 }156157 // Kenney GLTFs declare baseColorFactor with NO metallicFactor, and the158 // glTF spec default is metallic = 1.0 — GLTFLoader dutifully builds159 // full-metal materials, which render BLACK wherever direct sun doesn't160 // reach (we have no environment map; metals ignore hemisphere diffuse).161 // The kits are authored for flat diffuse — normalize on load.162 for (const scene of [marbleScene, ...decorScenes]) {163 scene.traverse((obj: THREE.Object3D) => {164 const mesh = obj as THREE.Mesh;165 if (!mesh.isMesh) return;166 const mats = Array.isArray(mesh.material)167 ? mesh.material168 : [mesh.material];169 for (const m of mats) {170 const std = m as THREE.MeshStandardMaterial;171 if (std.isMeshStandardMaterial) std.metalness = 0;172 }173 });174 }175176 // The marble kit shares one colormap material across all 162 pieces; grab it177 // once so palette swaps are a single `.map` assignment.178 const palettes: THREE.Texture[] = [texA, texB, texC];179 const marbleMaterials = collectMappedMaterials(marbleScene);180 const baseMaps = marbleMaterials.map((m) => m.map);181182 // Pipe-body swatch color per variation (measured: straight/curve/slant183 // UVs land on exactly this texel color in each colormap). Per-seed pipe184 // colors repaint every matching texel on a canvas copy — a swatch-level185 // recolor, not a blanket material tint.186 const PIPE_SWATCH: readonly (readonly [number, number, number])[] = [187 [207, 83, 79], // variation-a188 [255, 192, 68], // variation-b189 [134, 139, 161], // variation-c190 ];191 /** Derived pipe-colored colormaps + material clones, one entry per192 * (variation, color) — chapters share entries; LRU-bounded, texture193 * and materials evicted together. */194 const derived = new Map<195 string,196 { tex: THREE.Texture; mats: THREE.MeshStandardMaterial[] | null }197 >();198199 function pipeEntry(200 index: 0 | 1 | 2,201 color: readonly [number, number, number],202 ): { tex: THREE.Texture; mats: THREE.MeshStandardMaterial[] | null } {203 const cacheKey = `${index}/${color.join(",")}`;204 const hit = derived.get(cacheKey);205 if (hit) return hit;206 const src = palettes[index].image as HTMLImageElement;207 const canvas = document.createElement("canvas");208 canvas.width = src.width;209 canvas.height = src.height;210 const ctx = canvas.getContext("2d")!;211 ctx.drawImage(src, 0, 0);212 const img = ctx.getImageData(0, 0, canvas.width, canvas.height);213 const [pr, pg, pb] = PIPE_SWATCH[index];214 const d = img.data;215 for (let i = 0; i < d.length; i += 4) {216 if (d[i] === pr && d[i + 1] === pg && d[i + 2] === pb) {217 d[i] = color[0];218 d[i + 1] = color[1];219 d[i + 2] = color[2];220 }221 }222 ctx.putImageData(img, 0, 0);223 const tex = new THREE.CanvasTexture(canvas);224 tex.flipY = false;225 tex.colorSpace = THREE.SRGBColorSpace;226 // Match the base map's UV transform once, up front — the derived227 // texture is shared by material clones that never pass through228 // applyPalette.229 const base0 = baseMaps[0];230 if (base0) {231 tex.wrapS = base0.wrapS;232 tex.wrapT = base0.wrapT;233 tex.offset.copy(base0.offset);234 tex.repeat.copy(base0.repeat);235 tex.center.copy(base0.center);236 tex.rotation = base0.rotation;237 }238 // Bound the cache (chapters accumulate over an endless journey; the239 // live render window only ever needs a handful).240 if (derived.size >= 16) {241 const oldest = derived.keys().next().value;242 if (oldest !== undefined) {243 const entry = derived.get(oldest);244 entry?.tex.dispose();245 for (const m of entry?.mats ?? []) m.dispose();246 derived.delete(oldest);247 }248 }249 const made = { tex, mats: null };250 derived.set(cacheKey, made);251 return made;252 }253254 let currentIndex: 0 | 1 | 2 = 0;255256 function applyPipeColor(257 root: THREE.Object3D,258 color: readonly [number, number, number] | null,259 ): void {260 if (!color) return;261 const entry = pipeEntry(currentIndex, color);262 if (!entry.mats) {263 entry.mats = marbleMaterials.map((m) => {264 const clone = m.clone();265 clone.map = entry.tex;266 clone.needsUpdate = true;267 return clone;268 });269 }270 root.traverse((obj: THREE.Object3D) => {271 const mesh = obj as THREE.Mesh;272 if (!mesh.isMesh) return;273 const swap = (m: THREE.Material): THREE.Material => {274 const at = marbleMaterials.indexOf(m as THREE.MeshStandardMaterial);275 return at >= 0 ? entry.mats![at] : m;276 };277 mesh.material = Array.isArray(mesh.material)278 ? mesh.material.map(swap)279 : swap(mesh.material);280 });281 }282283 function applyPalette(284 index: 0 | 1 | 2,285 pipeColor: readonly [number, number, number] | null = null,286 ): void {287 currentIndex = index;288 const tex = pipeColor ? pipeEntry(index, pipeColor).tex : palettes[index];289 if (!tex) return;290 marbleMaterials.forEach((mat, i) => {291 const base = baseMaps[i];292 // Preserve the source map's UV transform (the kit uses293 // KHR_texture_transform) — the variations share its UV layout.294 if (base) {295 tex.wrapS = base.wrapS;296 tex.wrapT = base.wrapT;297 tex.offset.copy(base.offset);298 tex.repeat.copy(base.repeat);299 tex.center.copy(base.center);300 tex.rotation = base.rotation;301 }302 mat.map = tex;303 mat.needsUpdate = true;304 });305 }306307 function getPiece(id: string): THREE.Object3D | null {308 const tpl = templates.get(id);309 if (!tpl) return null;310 const obj = tpl.clone(true);311 obj.traverse((child: THREE.Object3D) => {312 const mesh = child as THREE.Mesh;313 if (mesh.isMesh) {314 mesh.castShadow = true;315 mesh.receiveShadow = true;316 }317 });318 return obj;319 }320321 return {322 templates,323 marbleManifest,324 natureManifest,325 manifests,326 marbleIds: marbleManifest.pieces.map((p: ManifestPiece) => p.id),327 natureIds: natureManifest.pieces.map((p: ManifestPiece) => p.id),328 getPiece,329 applyPalette,330 applyPipeColor,331 };332}