diff --git a/flow-state/src/venues/chamber.js b/flow-state/src/venues/chamber.js index 9d16025..38283a1 100644 --- a/flow-state/src/venues/chamber.js +++ b/flow-state/src/venues/chamber.js @@ -1,21 +1,181 @@ -// VENUE: Chamber — derelict room full of memories. +// VENUE: Chamber — derelict room full of junk furniture. // // A sparse, still place. Window-light + floor reflections carry the viz -// tint; scattered memories (primitives via actorToGeometry) populate it -// via stageNode. Population density + light/fog evolve per Story act -// (setup sparse/distant, climax one large memory at centre, resolution dim). -// Primitives only in V1 — no glTF kit. +// tint; procedurally built junk (tables, chairs, cupboards, shelves with +// books, plants, box stacks) populates it. Population density + light/fog +// evolve per Story act (setup sparse, climax bright, resolution dim); the +// arrangement salt re-hangs the room on story jumps. Primitives only — +// no glTF kit. -import { actorToGeometry } from '../actors/meshes.js'; import { buildSky, paintSky } from './sky.js'; +// --- procedural junk furniture ------------------------------------------------ +// Primitives only, palette-lit, seeded. Every builder returns { group, radius } +// with the group origin at floor level (+y up); radius is the footprint used +// by the repulsion passes. Deterministic in (rng): same draws, same junk. + +function mulberry32(a) { + let s = a >>> 0; + return function () { + s |= 0; s = (s + 0x6D2B79F5) | 0; + let z = Math.imul(s ^ (s >>> 15), 1 | s); + z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z; + return ((z ^ (z >>> 14)) >>> 0) / 4294967296; + }; +} + +function junkMat(T, cc, rough = 0.7) { + // Faint emissive in the piece's own colour: in the darkest rooms (darkest + // songs pick the chamber by design) lit-only junk goes black and the + // traverse moves the camera through nothing visible. Kept low — these are + // dusty things, not lamps. + return new T.MeshStandardMaterial({ + color: new T.Color(cc[0], cc[1], cc[2]), + emissive: new T.Color(cc[0] * 0.14, cc[1] * 0.14, cc[2] * 0.14), + emissiveIntensity: 1.0, + roughness: rough, + metalness: 0.06, + }); +} + +function junkBox(T, parent, mat, w, h, d, x, y, z, ry = 0, rz = 0) { + const m = new T.Mesh(new T.BoxGeometry(w, h, d), mat); + m.position.set(x, y, z); + m.rotation.y = ry; + m.rotation.z = rz; + m.castShadow = false; + m.receiveShadow = false; + parent.add(m); + return m; +} + +function buildTable(T, rng, pal) { + const g = new T.Group(); + const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.75); + const dark = junkMat(T, pal[(rng() * pal.length) | 0], 0.85); + junkBox(T, g, main, 1.5, 0.09, 0.95, 0, 0.72, 0); + for (const sx of [-0.65, 0.65]) for (const sz of [-0.38, 0.38]) { + junkBox(T, g, dark, 0.08, 0.72, 0.08, sx, 0.36, sz); + } + return { group: g, radius: 0.95 }; +} + +function buildChair(T, rng, pal) { + const g = new T.Group(); + const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.7); + junkBox(T, g, main, 0.52, 0.07, 0.5, 0, 0.45, 0); + junkBox(T, g, main, 0.52, 0.6, 0.07, 0, 0.78, -0.245); + for (const sx of [-0.21, 0.21]) for (const sz of [-0.2, 0.2]) { + junkBox(T, g, main, 0.06, 0.45, 0.06, sx, 0.225, sz); + } + return { group: g, radius: 0.5 }; +} + +function buildCupboard(T, rng, pal) { + const g = new T.Group(); + const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.8); + const trim = junkMat(T, pal[(rng() * pal.length) | 0], 0.6); + junkBox(T, g, main, 1.15, 2.0, 0.55, 0, 1.0, 0); + junkBox(T, g, trim, 1.21, 0.08, 0.61, 0, 0.04, 0); + junkBox(T, g, trim, 1.21, 0.07, 0.61, 0, 2.03, 0); + for (const sx of [-0.27, 0.27]) { + junkBox(T, g, trim, 0.5, 1.7, 0.04, sx, 1.05, 0.28); + const knob = new T.Mesh(new T.SphereGeometry(0.035, 10, 8), trim); + knob.position.set(sx * 0.25, 1.05, 0.32); + g.add(knob); + } + return { group: g, radius: 0.75 }; +} + +function buildBooks(T, g, rng, pal, y, width) { + // One shelf-board of books: upright row, seeded widths/heights/colours, + // sometimes a leaner, sometimes a gap with a flat stack. + let x = -width / 2 + 0.08; + const end = width / 2 - 0.08; + while (x < end - 0.05) { + const r = rng(); + if (r < 0.18 && x < end - 0.35) { + // Flat stack of 2-3. + const n = 2 + ((rng() * 2) | 0); + let sy = y; + for (let k = 0; k < n; k++) { + const bw = 0.3 + rng() * 0.12, bh = 0.055 + rng() * 0.02; + junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.85), + bw, bh, 0.24, x + bw / 2, sy + bh / 2, 0, (rng() - 0.5) * 0.2); + sy += bh; + } + x += 0.48; + } else { + const bw = 0.05 + rng() * 0.05, bh = 0.28 + rng() * 0.14; + const lean = (rng() < 0.12 && x < end - 0.2) ? -0.16 : 0; + junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.85), + bw, bh, 0.24, x + bw / 2, y + bh / 2, 0, 0, lean); + x += bw + 0.012 + (lean ? 0.06 : 0); + } + } +} + +function buildShelf(T, rng, pal) { + const g = new T.Group(); + const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.8); + junkBox(T, g, main, 0.06, 1.9, 0.42, -0.72, 0.95, 0); + junkBox(T, g, main, 0.06, 1.9, 0.42, 0.72, 0.95, 0); + junkBox(T, g, main, 1.5, 1.9, 0.03, 0, 0.95, -0.2); + const boards = [0.12, 0.72, 1.3, 1.87]; + for (const y of boards) junkBox(T, g, main, 1.44, 0.06, 0.4, 0, y, 0); + for (const y of boards.slice(0, 3)) buildBooks(T, g, rng, pal, y + 0.03, 1.32); + return { group: g, radius: 0.85 }; +} + +function buildPlant(T, rng, pal) { + const g = new T.Group(); + const cc = pal[(rng() * pal.length) | 0]; + const pot = junkMat(T, [cc[0] * 0.6 + 0.08, cc[1] * 0.6 + 0.05, cc[2] * 0.6 + 0.04], 0.9); + // Foliage reads leafy in any palette: palette hue pulled toward green. + const leaf = junkMat(T, + [cc[0] * 0.35 + 0.1, cc[1] * 0.35 + 0.3, cc[2] * 0.35 + 0.1], 0.95); + const potMesh = new T.Mesh(new T.CylinderGeometry(0.24, 0.17, 0.42, 12), pot); + potMesh.position.y = 0.21; + g.add(potMesh); + const trunk = new T.Mesh(new T.CylinderGeometry(0.03, 0.045, 0.55, 8), pot); + trunk.position.y = 0.65; + g.add(trunk); + const blobs = 2 + ((rng() * 2) | 0); + for (let i = 0; i < blobs; i++) { + const r = 0.3 + rng() * 0.2; + const b = new T.Mesh(new T.IcosahedronGeometry(r, 0), leaf); + b.position.set((rng() - 0.5) * 0.5, 1.0 + i * 0.32 + rng() * 0.15, (rng() - 0.5) * 0.5); + b.rotation.set(rng() * 3, rng() * 3, rng() * 3); + g.add(b); + } + return { group: g, radius: 0.55 }; +} + +function buildBoxes(T, rng, pal) { + const g = new T.Group(); + const n = 2 + ((rng() * 2) | 0); + let y = 0; + for (let i = 0; i < n; i++) { + const s = 0.55 + rng() * 0.3 - i * 0.06; + const h = 0.4 + rng() * 0.25; + junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.9), + s, h, s * (0.8 + rng() * 0.4), + (rng() - 0.5) * 0.16, y + h / 2, (rng() - 0.5) * 0.16, + (rng() - 0.5) * 0.6); + y += h; + } + return { group: g, radius: 0.6 }; +} + +const JUNK_BUILDERS = [buildTable, buildChair, buildCupboard, buildShelf, buildPlant, buildBoxes]; + export const chamber = { name: 'Chamber', archetype: 'chamber', family: 'minimal', kind: 'model', venue: true, - consumes: ['form', 'ink', 'staging'], + consumes: ['ink', 'staging'], traits: ['shape', 'space', 'style'], texture: 0.35, // How much viz bloom this room can take before its highlights clip: the @@ -218,9 +378,9 @@ export const chamber = { const sky = buildSky(T, { radius: 90 }); scene.add(sky.mesh); - // Population container — memories + // Population container — junk furniture const popGroup = new T.Group(); - popGroup.name = 'memories'; + popGroup.name = 'furniture'; scene.add(popGroup); return { @@ -337,9 +497,9 @@ export const chamber = { // may rebuild mid-song: no per-section seeds in this key. const arrangement = venueArrangement ?? instance.venueArrangement ?? 0; instance.venueArrangement = arrangement; - const popKey = vs ? `${vs.seed}:${vs.population ? vs.population.count : 0}:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}` : `no-vs:${instance.seed}:a${arrangement}`; + const popKey = vs ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${arrangement}`; const needRebuild = instance.builtKey !== popKey || !instance.popCount; - if (needRebuild && identity && actorSpec && actorSpec.form) { + if (needRebuild) { for (const child of [...instance.popGroup.children]) { instance.popGroup.remove(child); child.traverse((obj) => { @@ -355,51 +515,55 @@ export const chamber = { const count = vs && vs.population ? Math.max(2, Math.min(12, vs.population.count | 0)) : 5; const spread = vs && vs.population ? vs.population.spread || 0.7 : 0.7; const jitter = vs && vs.population ? vs.population.jitter || 0.25 : 0.25; - // One actor, furnished as a roomful: the first build makes the - // track's monolith body (the same geometry the viz hero uses), and - // each memory unwraps one of its parts. Unwrapping per part rather - // than cloning the whole body matters — cloning stamped the same - // ~4-part composite at 0.2 scale, so a radial assembly read as grey - // noise. Parts are the limbs; a room of limbs reads as furniture. - let bodyParts = null; - try { - const body = actorToGeometry(actorSpec, identity, T); - bodyParts = body.children.slice(); - } catch { bodyParts = null; } - if (!bodyParts || !bodyParts.length) { - const g = new T.BoxGeometry(0.3, 0.3, 0.3); - const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff })); - m.userData.partIndex = 0; - bodyParts = [m]; + // Junk dealt from a shuffled deck: the first slots always land + // distinct types (table, chair, cupboard, shelf, plant, boxes), + // then the deck cycles. Seeded by (seed, arrangement), so a + // re-hang deals different junk in different places — still + // deterministic per key, stable across seeks. + const rng = mulberry32(((instance.seed >>> 0) + ^ Math.imul(arrangement + 1, 0x9E3779B9)) >>> 0); + const deck = JUNK_BUILDERS.map((_, i) => i); + for (let i = deck.length - 1; i > 0; i--) { + const j = (rng() * (i + 1)) | 0; + const tmp = deck[i]; deck[i] = deck[j]; deck[j] = tmp; + } + // Build pieces first — each reports its footprint radius, which + // the placement passes below need before positioning. + const pieces = []; + for (let i = 0; i < count; i++) { + const built = JUNK_BUILDERS[deck[i % deck.length]](T, rng, pal); + built.group.userData.index = i; + pieces.push(built); } // Deterministic positions — golden-angle spiral + separation passes. - // The old sin-scatter clustered near the centre, and at furniture - // scale (1+ units wide) neighbours interpenetrated. The spiral gives - // even coverage with an open middle for the camera; the relaxation - // below enforces a minimum gap so no two memories touch. + // The spiral gives even coverage with an open middle for the + // camera; the relaxation below enforces per-pair gaps from the + // pieces' own footprint radii, so a cupboard never swallows a + // chair. Arrangement salt rotates the whole spiral, so a re-hang + // is a visibly new layout — deterministic per (seed, + // arrangement), stable across seeks. const seeds = []; for (let i = 0; i < count; i++) { const frac = (i + 0.5) / Math.max(1, count); - // Arrangement salt rotates the whole spiral, so a re-hang is a - // visibly new layout built from the same objects — deterministic - // per (seed, arrangement), stable across seeks. const ang = i * 2.39996 + (instance.seed % 628) * 0.01 + arrangement * 2.39996 * 3; - const rad = (2.4 + 5.6 * Math.sqrt(frac)) * (0.7 + spread * 0.45); + const rad = (2.6 + 5.8 * Math.sqrt(frac)) * (0.7 + spread * 0.45); let rx = Math.cos(ang) * rad + Math.sin(i * 78.233) * jitter * 1.2; let rz = Math.sin(ang) * rad * 0.78 - 1.2 + Math.cos(i * 43.12) * jitter; rx = Math.max(-9, Math.min(9, rx)); - // Never in front of the room: the camera pushes as close as z≈1.3, - // so anything at z>-0.5 is at the lens and reads as a bar across - // the viz. Furniture lives in the back two-thirds, toward the window. - rz = Math.max(-8.5, Math.min(-0.5, rz)); + // Never in front of the room: the camera pushes as close as z≈0.4, + // and at furniture scale (1+ units wide) anything nearer than + // z≈-1.5 is at the lens and reads as a bar across the viz. + // Furniture lives in the back two-thirds, toward the window. + rz = Math.max(-8.5, Math.min(-1.5, rz)); seeds.push([rx, rz]); } - // Repulsion passes: push overlapping pairs apart. Deterministic, + // Repulsion passes: push overlapping pairs apart. Minimum gap from + // the pair's footprints — big pieces need more room. Deterministic, // analytic, no wall-clock — same inputs always give same layout. - const minDist = 2.9; - for (let pass = 0; pass < 10; pass++) { + for (let pass = 0; pass < 12; pass++) { for (let a = 0; a < count; a++) { for (let b = a + 1; b < count; b++) { + const minDist = (pieces[a].radius + pieces[b].radius) * 0.7 + 0.6; const dx = seeds[b][0] - seeds[a][0]; const dz = seeds[b][1] - seeds[a][1]; const d = Math.hypot(dx, dz); @@ -407,72 +571,33 @@ export const chamber = { const push = (minDist - d) * 0.5; const nx = dx / d, nz = dz / d; seeds[a][0] = Math.max(-9, Math.min(9, seeds[a][0] - nx * push)); - seeds[a][1] = Math.max(-8.5, Math.min(-0.5, seeds[a][1] - nz * push)); + seeds[a][1] = Math.max(-8.5, Math.min(-1.5, seeds[a][1] - nz * push)); seeds[b][0] = Math.max(-9, Math.min(9, seeds[b][0] + nx * push)); - seeds[b][1] = Math.max(-8.5, Math.min(-0.5, seeds[b][1] + nz * push)); + seeds[b][1] = Math.max(-8.5, Math.min(-1.5, seeds[b][1] + nz * push)); } } } for (let i = 0; i < count; i++) { - const rxClamped = seeds[i][0]; - const rzClamped = seeds[i][1]; - const ry = -1.15 + 0.15 + Math.sin(i * 5.5) * 0.25; - - const src = bodyParts[i % bodyParts.length]; - const m = new T.Mesh( - src.geometry, - new T.MeshStandardMaterial({ color: 0xffffff }), - ); - m.userData.partIndex = src.userData.partIndex ?? (i % bodyParts.length); - const palIdx = vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i % pal.length; - const cc = pal[palIdx % pal.length] || pal[0]; - // Faint emissive in the furniture's own colour: in the darkest - // rooms (darkest songs pick the chamber by design) lit-only - // limbs go black and the traverse moves the camera through - // nothing visible. Kept low — these are memories, not lamps. - m.material = new T.MeshStandardMaterial({ - color: new T.Color(cc[0], cc[1], cc[2]), - emissive: new T.Color(cc[0] * 0.14, cc[1] * 0.14, cc[2] * 0.14), - emissiveIntensity: 1.0, - roughness: 0.45 + styleWeight * 0.12, - metalness: 0.08, - transparent: opacity < 0.999, - opacity, - }); - m.castShadow = false; - m.receiveShadow = false; - const g = new T.Group(); - g.add(m); - // Small furniture, not a second hero: part geometry is already - // ~0.5–1.0 units in radius, and the camera walks among it, so a - // unit-scale limb at 3 units fills the frame like a monument. - // Memories stay well under half scale; elementScale only nudges - // within that — a huge-form track gets noticeably bigger - // memories, not a room of heroes. - const s = (0.22 + elementScale * 0.28) * (1 - (i / Math.max(1, count)) * 0.25); - g.scale.setScalar(s * (0.9 + shapeSides * 0.015)); - g.position.set(rxClamped, ry, rzClamped); - g.rotation.y = shapeTilt + i * 0.9 + Math.sin(i) * 0.5; - g.rotation.x = Math.sin(i * 1.3) * 0.22; - g.userData.baseY = ry; - g.userData.index = i; + const g = pieces[i].group; + g.position.set(seeds[i][0], -1.4, seeds[i][1]); + g.rotation.y = rng() * Math.PI * 2; + g.userData.baseY = -1.4; instance.popGroup.add(g); } instance.builtKey = popKey; instance.popCount = count; } - // Analytic animation — bob + slow yaw, no integration + // Furniture stands still — it is junk, not actors. The room's life is + // the camera walk, the story-driven emissive breathe below, and the + // re-hang on arrangement jumps. Positions are never integrated, only + // set at build: a seek lands on the same room as playback. for (const g of instance.popGroup.children) { const idx = g.userData.index || 0; - const baseY = g.userData.baseY || 0; - g.position.y = baseY + Math.sin(t * 0.35 + idx * 1.1) * 0.06; - g.rotation.y += 0; // keep deterministic: set rather than accumulate - const yaw = shapeTilt + idx * 0.9 + Math.sin(t * 0.18 + idx) * 0.28; - g.rotation.y = yaw; - // Per-section popMul would modulate visible/count — V1 keeps all visible - { const popMul = vState ? vState.popMul : 1; const showIndex = g.userData.index / Math.max(1, instance.popCount); g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75)); } - for (const mesh of g.children) { + // Per-section popMul gates how much of the room is dressed — V1 + // keeps the gating the memories had. + { const popMul = vState ? vState.popMul : 1; const showIndex = idx / Math.max(1, instance.popCount); g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75)); } + g.traverse((mesh) => { if (mesh.isMesh) { mesh.material.opacity = opacity; mesh.material.transparent = opacity < 0.999; @@ -482,7 +607,7 @@ export const chamber = { mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0; } } - } + }); } // Subtle room drift from camera trait