diff --git a/flow-state/src/venues/chamber.js b/flow-state/src/venues/chamber.js index 38283a1..30875c0 100644 --- a/flow-state/src/venues/chamber.js +++ b/flow-state/src/venues/chamber.js @@ -2,11 +2,13 @@ // // A sparse, still place. Window-light + floor reflections carry the viz // 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. +// books, plants, box stacks) populates it, and the track's actors wander +// the room — visiting furniture, pausing to face it, meeting each other +// at the centre. 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 ------------------------------------------------ @@ -24,14 +26,15 @@ function mulberry32(a) { }; } -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. +// Faint emissive in the piece's own colour: in the darkest rooms (darkest +// songs pick the chamber by design) lit-only shapes go black and the +// traverse moves the camera through nothing visible. Kept low for junk, +// higher for actors — the figures are the subject and must pop out of +// their own room even in a monochrome palette. +function junkMat(T, cc, rough = 0.7, emis = 0.14) { 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), + emissive: new T.Color(cc[0] * emis, cc[1] * emis, cc[2] * emis), emissiveIntensity: 1.0, roughness: rough, metalness: 0.06, @@ -175,7 +178,7 @@ export const chamber = { family: 'minimal', kind: 'model', venue: true, - consumes: ['ink', 'staging'], + consumes: ['form', 'ink', 'staging'], traits: ['shape', 'space', 'style'], texture: 0.35, // How much viz bloom this room can take before its highlights clip: the @@ -414,7 +417,7 @@ export const chamber = { const ink = identity ? identity.ink : null; const lattice = identity ? identity.lattice : null; const elementScale = identity ? identity.lattice.elementScale : 0.35; - void elementScale; void lattice; void shapeSides; + void elementScale; void lattice; void shapeSides; void shapeTilt; void styleWeight; // Resolve venueSpec — prefer passed-in, fallback to instance stored const vs = venueSpec || instance.venueSpec; @@ -497,7 +500,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 ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${arrangement}`; + const protagonistSides = identity && identity.cast ? identity.cast.protagonist.sides : 0; + const actorSeed = actorSpec ? actorSpec.seed || 0 : 0; + const popKey = vs ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:p${protagonistSides}:s${actorSeed}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${arrangement}`; const needRebuild = instance.builtKey !== popKey || !instance.popCount; if (needRebuild) { for (const child of [...instance.popGroup.children]) { @@ -515,6 +520,11 @@ 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; + // Headcount split: 1-3 actors (the track's figures, always + // visible — they take the first indices so popMul gating never + // hides them while junk is still showing), the rest junk. + const actorCount = Math.min(3, Math.max(1, Math.round(count / 3))); + const junkCount = Math.max(1, count - actorCount); // 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 @@ -528,11 +538,14 @@ export const chamber = { 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. + // the placement passes below need before positioning. Actors own + // indices 0..actorCount-1, so junk starts after them and the + // popMul sparse-setup gating keeps the figures while thinning + // the junk. 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; + for (let j = 0; j < junkCount; j++) { + const built = JUNK_BUILDERS[deck[j % deck.length]](T, rng, pal); + built.group.userData.index = actorCount + j; pieces.push(built); } // Deterministic positions — golden-angle spiral + separation passes. @@ -543,8 +556,8 @@ export const chamber = { // 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); + for (let i = 0; i < junkCount; i++) { + const frac = (i + 0.5) / Math.max(1, junkCount); const ang = i * 2.39996 + (instance.seed % 628) * 0.01 + arrangement * 2.39996 * 3; 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; @@ -561,8 +574,8 @@ export const chamber = { // the pair's footprints — big pieces need more room. Deterministic, // analytic, no wall-clock — same inputs always give same layout. for (let pass = 0; pass < 12; pass++) { - for (let a = 0; a < count; a++) { - for (let b = a + 1; b < count; b++) { + for (let a = 0; a < junkCount; a++) { + for (let b = a + 1; b < junkCount; 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]; @@ -577,34 +590,163 @@ export const chamber = { } } } - for (let i = 0; i < count; i++) { + for (let i = 0; i < junkCount; 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; + g.userData.kind = 'junk'; instance.popGroup.add(g); } + // Actors — the track's figures, built from the same monolith the + // viz hero uses (shared part geometries, own palette material per + // figure). Each gets two furniture anchors to visit plus a centre + // meeting point; per-frame motion walks that loop analytically + // below. Feet planted via bounding box; yaw faces whoever or + // whatever the figure is with. + let partGeos = null; + try { + if (identity && actorSpec && actorSpec.form) { + const body = actorToGeometry(actorSpec, identity, T); + partGeos = body.children + .filter((c) => c.geometry) + .map((c) => ({ geo: c.geometry, partIndex: c.userData.partIndex })); + } + } catch { partGeos = null; } + const clampRoom = (x, z) => [ + Math.max(-9, Math.min(9, x)), + Math.max(-8.5, Math.min(-1.5, z)), + ]; + const actorCfg = []; + const actorColors = []; + for (let a = 0; a < actorCount; a++) { + const ag = new T.Group(); + // Distinct palette entries per figure so the cast reads as + // individuals, not clones, even in narrow palettes. + let ci = (rng() * pal.length) | 0; + for (let guard = 0; guard < 4 && actorColors.includes(ci); guard++) { + ci = (rng() * pal.length) | 0; + } + actorColors.push(ci); + const acc = junkMat(T, pal[ci % pal.length], 0.5, 0.3); + if (partGeos && partGeos.length) { + for (const p of partGeos) { + const m = new T.Mesh(p.geo, acc); + m.userData.partIndex = p.partIndex; + m.castShadow = false; + m.receiveShadow = false; + ag.add(m); + } + } else { + // No actor to unwrap (hand-built looks, tests): a plain + // figure from primitives so the room still has its cast. + junkBox(T, ag, acc, 0.5, 0.9, 0.3, 0, 0.75, 0); + const head = new T.Mesh(new T.SphereGeometry(0.18, 12, 10), acc); + head.position.y = 1.38; + ag.add(head); + } + const s = 0.32 + rng() * 0.13; + ag.scale.setScalar(s); + ag.updateMatrixWorld(true); + const bbox = new T.Box3().setFromObject(ag); + const yBase = Number.isFinite(bbox.min.y) ? -1.4 - bbox.min.y : -1.4; + // Two distinct furniture anchors to visit (or the same twice + // when the room holds a single piece). + const kA = (rng() * junkCount) | 0; + let kB = (rng() * junkCount) | 0; + if (junkCount > 1) { + let guard = 0; + while (kB === kA && guard++ < 8) kB = (rng() * junkCount) | 0; + } + const standBeside = (k) => { + const ang = rng() * Math.PI * 2; + const dist = pieces[k].radius + 0.55; + const [sx, sz] = clampRoom( + seeds[k][0] + Math.cos(ang) * dist, + seeds[k][1] + Math.sin(ang) * dist); + return { stand: [sx, sz], anchor: [seeds[k][0], seeds[k][1]] }; + }; + const vA = standBeside(kA), vB = standBeside(kB); + const cx = (rng() - 0.5) * 4; + const cz = Math.max(-8.5, Math.min(-1.5, -3.5 + (rng() - 0.5) * 1.5)); + ag.position.set(vA.stand[0], yBase, vA.stand[1]); + ag.userData.index = a; + ag.userData.kind = 'actor'; + ag.userData.baseY = yBase; + instance.popGroup.add(ag); + actorCfg.push({ + group: ag, yBase, + period: 40 + rng() * 25, + phase: rng() * 40, + standA: vA.stand, anchorA: vA.anchor, + standB: vB.stand, anchorB: vB.anchor, + center: [cx, cz], + partner: (a + 1) % actorCount, + }); + } + instance.actorCfg = actorCfg; instance.builtKey = popKey; instance.popCount = count; } - // 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. + // Actors walk a closed loop — stand at furniture A facing it, meet at + // the centre facing their partner, stand at furniture B facing it, + // back to centre — on a slow seeded period with unsynced phases. + // Smootherstep legs start and end at rest, so figures pause, look, + // then move: visiting, not patrolling. Facing blends toward the next + // leg's target, so turns are smooth. Everything is f(t): positions + // first for all figures, then yaws (a figure faces its partner's + // same-frame position). Junk stands still — it is furniture. + // Nothing here integrates: a seek lands on the same room as playback. + const actors = instance.actorCfg || []; + const smoother = (u) => { const c = Math.max(0, Math.min(1, u)); return c * c * c * (c * (c * 6 - 15) + 10); }; + const actorPos = actors.map((cfg) => { + const u = ((((t + cfg.phase) % cfg.period) + cfg.period) % cfg.period) / cfg.period; + const leg = Math.min(3, Math.floor(u * 4)); + const e = smoother(u * 4 - leg); + const W = [cfg.standA, cfg.center, cfg.standB, cfg.center]; + const p0 = W[leg], p1 = W[(leg + 1) % 4]; + return [p0[0] + (p1[0] - p0[0]) * e, p0[1] + (p1[1] - p0[1]) * e, e, leg]; + }); + actors.forEach((cfg, i) => { + const g = cfg.group; + const px = actorPos[i][0], pz = actorPos[i][1]; + const e = actorPos[i][2], leg = actorPos[i][3]; + g.position.set(px, cfg.yBase + 0.02 + Math.sin(t * 0.9 + cfg.phase) * 0.03, pz); + // Face targets per waypoint: the visited furniture, or the + // partner's live position at the centre (a lone figure watches + // the window instead). Blend across the leg boundary for turns. + const solo = actors.length < 2; + const partnerPos = solo ? [0, -10] : actorPos[cfg.partner].slice(0, 2); + const F = [cfg.anchorA, partnerPos, cfg.anchorB, partnerPos]; + const f0 = F[leg], f1 = F[(leg + 1) % 4]; + const fx = f0[0] + (f1[0] - f0[0]) * e, fz = f0[1] + (f1[1] - f0[1]) * e; + if (Math.hypot(fx - px, fz - pz) > 1e-3) g.rotation.y = Math.atan2(fx - px, fz - pz); + }); for (const g of instance.popGroup.children) { const idx = g.userData.index || 0; - // 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)); } + const isActor = g.userData.kind === 'actor'; + // Actors are the subject: always dressed. Junk thins out in + // sparse setups via popMul, as before. + if (isActor) { + g.visible = opacity > 0.01; + } else { + 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; // The room's population breathes with the story arc: dim in // setup, bright at the climax. Pure f(venueState), no clock. + // Figures breathe brighter — they must read against their + // own room even when the palette runs monochrome. if (vState && mesh.material.emissiveIntensity !== undefined) { - mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0; + mesh.material.emissiveIntensity = isActor + ? 0.9 + vState.light * 1.4 + : 0.5 + vState.light * 1.0; } } });