// VENUE: Dungeon — a walk through halls, rooms, forest and castle walls. // // A delving party (the track's monolith body as the torch-bearer, actor // companions trailing) walks a planned loop of corridors and open ground. // The viz plays on puddles (floor reflections), window slits and the one // planned portal. Camera works three modes per shot — first-person walk, // follow, and a planted portal tableau. Per-section evolution via venueState // {light,fog,popMul,reachMul} from VenueSpec.perSection, interpolated by the // ArcDriver (never stepped). // // One venue = one place (this dungeon). Primitives + actorToGeometry only — // no glTF kit. import { actorToGeometry } from '../actors/meshes.js'; import { generateActor } from '../actors/ActorGenerator.js'; import { buildSky, paintSky } from './sky.js'; import { Rng } from '../engine/rng.js'; import { genDungeon, THEMES, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from './dungeon/map.js'; import { PROP_BUILDERS, PROP_TINTS, buildPortal, animateDressing } from './dungeon/props.js'; import { HAZARD_BUILDERS, HAZARD_TINTS, updateHazards, avoidanceOffset } from './dungeon/hazards.js'; const FLOOR_Y = -1.15; const EYE_Y = 0.45; // Enemy kind → actor-pool archetype: lurkers walk (walker), the sentry // stands (structure), critters swarm (swarm) — a slow song's enemies read // slow because its walker does. Wisps need no body (glow only). const CAST_ARCHETYPE = { lurker: 'walker', sentry: 'structure', critter: 'swarm', wisp: null }; /** * Spherical viz sampling for stone and water, so puddles mirror their own * video like wet rock. Same analytic trick as the ride bodywork (see * ride.js vizEnvMaterial): reflects the view-space normal about the view * direction, maps to sphere UVs, adds to emissive — diffuse stays untouched. * Analytic in (camera, normal): same frame always renders the same * reflection, so seek === playback holds. Texture starts null and * bindVizTexture fills it from the compositor's vizTarget every frame. */ function vizEnvMaterial(mat, instance, strength) { mat.userData.vizEnv = { value: null }; mat.userData.vizEnvBase = strength; mat.userData.vizEnvStrength = { value: strength }; mat.onBeforeCompile = (shader) => { shader.uniforms.u_vizEnv = mat.userData.vizEnv; shader.uniforms.u_vizEnvStrength = mat.userData.vizEnvStrength; shader.fragmentShader = ( 'uniform sampler2D u_vizEnv;\nuniform float u_vizEnvStrength;\n' + shader.fragmentShader ).replace( '#include ', `#include { vec3 vDir = normalize( vViewPosition ); vec3 rDir = reflect( vDir, normalize( normal ) ); float sU = atan( rDir.z, rDir.x ) * 0.15915494 + 0.5 + 0.25; sU = 1.0 - abs( 1.0 - 2.0 * fract( sU ) ); vec2 sUv = vec2( sU, asin( clamp( rDir.y, -1.0, 1.0 ) ) * 0.31830988 + 0.5 ); totalEmissiveRadiance += texture2D( u_vizEnv, sUv ).rgb * u_vizEnvStrength; }` ); }; mat.customProgramCacheKey = () => 'dungeon-vizenv'; if (instance) (instance.envMats || (instance.envMats = [])).push(mat); return mat; } function wrapS(s, span) { return (((s % span) + span) % span); } export const dungeon = { name: 'Dungeon', archetype: 'dungeon', family: 'structural', kind: 'model', venue: true, // This venue drives its own camera rig outright (see the camera block at // the end of update()). The shared rig is ignored for this layer — kept // for every other model layer. Analytic f(timeline, seed, framing, // venueState) like everything else, so seek === playback still holds. directCamera: true, consumes: ['form', 'ink', 'staging'], traits: ['shape', 'space', 'camera', 'style'], texture: 0.4, // Puddles, windows and the portal are small bright surfaces in dark // stone — the venue earns the blaze about as often as the stage wall. reach: 0.6, params: { density: { type: 'float', range: [0.2, 0.9], default: 0.5, uniform: 'u_density', bias: 'density' }, speed: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_speed', bias: 'motion' }, light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' }, palette: { type: 'palette', count: 4 }, }, reactive: {}, // Venue RTT: the compositor calls this with the finished viz texture after // compositing the viz stack into vizTarget. Windows and the portal disc // sample it as emissive maps, so the viz plays ON the dungeon surfaces // rather than composited over the venue — and the puddles sample the same // texture spherically (see vizEnvMaterial above). bindVizTexture(instance, texture) { if (!instance || !texture) return; if (instance.windowMat && instance.windowMat.emissiveMap !== texture) { instance.windowMat.emissiveMap = texture; instance.windowMat.needsUpdate = true; } if (instance.portalDiscMat && instance.portalDiscMat.emissiveMap !== texture) { instance.portalDiscMat.emissiveMap = texture; instance.portalDiscMat.needsUpdate = true; } for (const m of instance.envMats || []) { if (m.userData.vizEnv && m.userData.vizEnv.value !== texture) { m.userData.vizEnv.value = texture; } } if (instance.skyMat) { if (instance.skyMat.uniforms.u_viz.value !== texture) { instance.skyMat.uniforms.u_viz.value = texture; } if (instance.skyMat.uniforms.u_vizAmount.value <= 0) { instance.skyMat.uniforms.u_vizAmount.value = 0.55; } } }, // Torch-glow wash for the viz composite — arcs with venueState.light. // See Layer.washColor. washColor(instance) { const st = (instance && instance.venueState) || null; const light = st ? st.light : 0.7; const glow = 0.08 + light * 0.1; return [glow * 1.1, glow * 0.95, glow * 0.8]; }, build({ scene, seed, params, actorSpec, venueSpec, THREE }) { const T = THREE; const rng = new Rng((seed >>> 0) || 1); const vsDun = (venueSpec && venueSpec.dungeon) || null; // The map IS the dungeon: grid first, everything built from it. const map = (vsDun && vsDun.map) || genDungeon({ seed, rng }); const L = map.route.totalLen; // Visual identity for this song: the theme record tints every // material below, so two songs never share the same dungeon. const theme = THEMES[map.theme] ?? THEMES.crypt; // Stone + flame shared materials (tinted per frame in update). const stoneMat = new T.MeshStandardMaterial({ color: new T.Color(0.3, 0.3, 0.33), roughness: 0.9, metalness: 0.03, emissive: new T.Color(0.04, 0.04, 0.05), emissiveIntensity: 1.0, }); const floorMat = new T.MeshStandardMaterial({ color: new T.Color(0.22, 0.22, 0.25), roughness: 0.85, metalness: 0.05, emissive: new T.Color(0.03, 0.03, 0.04), emissiveIntensity: 1.0, }); const flameMat = new T.MeshStandardMaterial({ color: new T.Color(0.1, 0.05, 0.02), emissive: new T.Color(theme.torch[0], theme.torch[1], theme.torch[2]), emissiveIntensity: 2.2, roughness: 0.4, }); const windowMat = new T.MeshStandardMaterial({ color: new T.Color(0.02, 0.02, 0.03), emissive: new T.Color(1, 1, 1), emissiveIntensity: 1.1, roughness: 0.5, metalness: 0.1, }); // Timber: theme wood for door frames and wall beams. Static colour — // wood reads as wood on every palette; torchlight does the rest. const woodMat = new T.MeshStandardMaterial({ color: new T.Color(theme.wood[0], theme.wood[1], theme.wood[2]), roughness: 0.8, metalness: 0.0, emissive: new T.Color(theme.wood[0] * 0.24, theme.wood[1] * 0.24, theme.wood[2] * 0.24), emissiveIntensity: 1.0, }); // Floor: one continuous slab under the whole grid. A plane cannot // have holes, so the walk always has ground; walls sit on top. const spanX = map.w * CELL + 8, spanZ = map.h * CELL + 8; const ground = new T.Mesh(new T.PlaneGeometry(spanX, spanZ), floorMat); ground.rotation.x = -Math.PI / 2; ground.position.y = FLOOR_Y; ground.name = 'ground'; scene.add(ground); // Shell: merged wall runs + exact ceiling slabs, all derived from // cell occupancy — every open edge is walled, every indoor rect is // roofed, by construction. Runs are axis aligned, so boxes need no // yaw and joints meet exactly (runs overlap 0.2 into each other). const shell = new T.Group(); shell.name = 'shell'; for (const run of map.wallRuns) { const lenX = run.x1 - run.x0, lenZ = run.z1 - run.z0; const alongX = lenX >= lenZ; const len = (alongX ? lenX : lenZ) + 0.4; const wall = new T.Mesh( alongX ? new T.BoxGeometry(len, run.h, 0.4) : new T.BoxGeometry(0.4, run.h, len), stoneMat); wall.position.set((run.x0 + run.x1) / 2, FLOOR_Y + run.h / 2, (run.z0 + run.z1) / 2); shell.add(wall); } for (const cr of map.ceilRects) { const ceil = new T.Mesh( new T.BoxGeometry(cr.x1 - cr.x0, 0.3, cr.z1 - cr.z0), stoneMat); ceil.position.set((cr.x0 + cr.x1) / 2, cr.y, (cr.z0 + cr.z1) / 2); shell.add(ceil); } scene.add(shell); // Timber framing: oak door frames on the gates, posts + plates on // the walls. One shared material, static boxes from the plan. for (const tb of map.items.timbers || []) { const beam = new T.Mesh(new T.BoxGeometry(tb.sx, tb.sy, tb.sz), woodMat); beam.position.set(tb.x, tb.y, tb.z); beam.rotation.y = tb.yaw || 0; scene.add(beam); } // Dressing, all seated once from map cells — static architecture, so // nothing here ever moves; per-frame work is glow only (see update). // Torches burn on the walls the route passes; where the route runs // open ground they stand on iron posts instead of floating. const dressing = []; for (const tc of map.items.torches) { const torch = PROP_BUILDERS.torch(T, rng, theme.torch); torch.position.set(tc.x, 0, tc.z); torch.rotation.y = tc.yaw; if (tc.post) { const post = new T.Mesh( new T.CylinderGeometry(0.05, 0.07, 1.9, 6), new T.MeshStandardMaterial({ color: new T.Color(0.08, 0.08, 0.09), roughness: 0.5, metalness: 0.6 })); post.position.set(0, FLOOR_Y + 0.95, 0.1); torch.add(post); } scene.add(torch); dressing.push(torch); } // Window slits: the viz glowing through indoor stone. for (const w of map.items.windows) { const win = new T.Mesh(new T.PlaneGeometry(0.5, 1.2), windowMat); win.position.set(w.x, FLOOR_Y + 1.6, w.z); win.rotation.y = w.yaw; win.name = 'window'; scene.add(win); } // Puddles: still floor water mirroring the viz. The reflection feed // registers on the instance (see inst.envMats below). const puddleMat = vizEnvMaterial(new T.MeshStandardMaterial({ color: new T.Color(0.03, 0.035, 0.045), emissive: new T.Color(0.02, 0.02, 0.03), roughness: 0.15, metalness: 0.85, }), null, 0.6); const puddles = new T.Group(); puddles.name = 'puddles'; for (const pd of map.items.puddles) { const disc = new T.Mesh(new T.CircleGeometry(pd.r, 20), puddleMat); disc.rotation.x = -Math.PI / 2; disc.position.set(pd.x, FLOOR_Y + 0.02, pd.z); disc.scale.x = 1.4; puddles.add(disc); } scene.add(puddles); // Room props: furniture and rubble off the walked line, torches // already placed above. Static for the whole video. const propRng = rng.fork('dungeon:dress'); for (const pr of map.items.props) { const build = PROP_BUILDERS[pr.type] ?? PROP_BUILDERS.rubble; const item = build(T, propRng, PROP_TINTS[pr.type] ?? [0.45, 0.44, 0.46]); item.position.set(pr.x, 0, pr.z); item.rotation.y = pr.yaw; scene.add(item); dressing.push(item); } // Boundary planting: bushes and trees where an outdoor wall would // stand — the outside stays outside, behind a green edge. const vegRng = rng.fork('dungeon:veg'); for (const vg of map.items.vegetation) { const build = PROP_BUILDERS[vg.kind] ?? PROP_BUILDERS.bush; const item = build(T, vegRng, PROP_TINTS[vg.kind] ?? PROP_TINTS.bush); item.position.set(vg.x, 0, vg.z); item.rotation.y = vegRng.next() * Math.PI * 2; item.scale.setScalar(vg.s || 1); scene.add(item); } // Hazards: gates at room entries, gaps and patrols along the walk. // Seated from map cells; per-frame life via updateHazards. const hazards = new T.Group(); hazards.name = 'hazards'; const hazardItems = []; const hazardGates = []; const hzRng = rng.fork('dungeon:haz'); const placeHaz = (list, type) => { for (const e of list) { const item = HAZARD_BUILDERS[type](T, hzRng, HAZARD_TINTS[type]); item.userData.home = { x: e.x, z: e.z, yaw: e.yaw }; if (typeof e.s === 'number') { item.userData.hazardS = e.s; hazardGates.push({ s: e.s }); } item.userData.hazardType = type; hazards.add(item); hazardItems.push(item); dressing.push(item); } }; placeHaz(map.items.gates, 'gate'); placeHaz(map.items.gaps, 'gap'); placeHaz(map.items.patrols, 'patrol'); scene.add(hazards); // Cast: entities that live on the map (enemies, critters, a wisp). // Bodies are grown from the SONG's actor pool: the venue only ever // receives actors.monolith through the Layer, so the rest of the // pool (walker/structure/swarm for lurker/sentry/critter) is // generated here from the track's summary/personality/identity — // the same character the viz marches, darkened hostile — on first // update. Ember eyes set into the face the geometry actually has // (bounding-box placed). Per-frame posing is analytic (see // entityPose) — the map owns what they are, update owns where they // are. Grown once per video, never rebuilt. // Kind→archetype: lurkers walk (walker), the sentry stands // (structure), critters swarm (swarm) — a slow song's enemies read // slow because its walker does. const cast = []; const eyeRed = [1.0, 0.25, 0.1]; const eyeMat = () => new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.02, 0.01), emissive: new T.Color(eyeRed[0], eyeRed[1], eyeRed[2]), emissiveIntensity: 1.6, roughness: 0.4, }); const CAST_HEIGHT = { lurker: 1.4, sentry: 1.2, critter: 0.5, wisp: 0 }; for (const e of map.items.entities) { const g = new T.Group(); g.name = `entity-${e.kind}`; const eyes = []; const bodyMats = []; let baseY = 0; if (e.kind === 'sentry') { // Spear + lantern are set dressing, not cast: built now. const iron = new T.MeshStandardMaterial({ color: new T.Color(0.08, 0.08, 0.09), roughness: 0.5, metalness: 0.6 }); const spear = new T.Mesh(new T.CylinderGeometry(0.03, 0.03, 1.8, 6), iron); spear.position.set(0.5, 0.9, 0); g.add(spear); const lampMat = new T.MeshStandardMaterial({ color: new T.Color(0.06, 0.06, 0.06), emissive: new T.Color(0.95, 0.75, 0.4), emissiveIntensity: 1.2, roughness: 0.5, }); const lamp = new T.Mesh(new T.SphereGeometry(0.11, 10, 8), lampMat); lamp.position.set(-0.45, 1.25, 0); g.add(lamp); eyes.push(lampMat); } else if (e.kind === 'wisp') { // Glow only, no body — needs no cast to build. const glowMat = new T.MeshStandardMaterial({ color: new T.Color(0.1, 0.12, 0.14), emissive: new T.Color(0.65, 0.85, 1.0), emissiveIntensity: 2.0, roughness: 0.4, }); const glow = new T.Mesh(new T.SphereGeometry(0.15, 12, 10), glowMat); glow.position.y = 0; g.add(glow); eyes.push(glowMat); baseY = 1.1; } g.position.set(e.x, baseY, e.z); g.rotation.y = e.yaw; g.visible = e.kind === 'wisp'; scene.add(g); cast.push({ spec: e, group: g, eyes, bodyMats, baseY, grown: e.kind === 'wisp', phase: (e.phase || 0) * 1.7, height: CAST_HEIGHT[e.kind] || 1.0, }); } // Loot worth stopping for: chests, relics, hoards. Their glow mats // pulse when the hero nears (see update); halts pause the walk there. const lootGroups = []; const goldMat = () => new T.MeshStandardMaterial({ color: new T.Color(0.2, 0.12, 0.03), emissive: new T.Color(1.0, 0.62, 0.12), emissiveIntensity: 1.0, roughness: 0.35, metalness: 0.6, }); for (const l of map.items.loot) { const g = new T.Group(); g.name = `loot-${l.kind}`; const glow = []; if (l.kind === 'cache') { const boxMat = new T.MeshStandardMaterial({ color: new T.Color(0.16, 0.12, 0.08), roughness: 0.8 }); const chest = new T.Mesh(new T.BoxGeometry(0.7, 0.4, 0.5), boxMat); chest.position.y = FLOOR_Y + 0.2; g.add(chest); const lid = new T.Mesh(new T.BoxGeometry(0.7, 0.1, 0.5), boxMat); lid.position.set(0, FLOOR_Y + 0.48, -0.14); lid.rotation.x = -0.5; g.add(lid); const gm = goldMat(); const slit = new T.Mesh(new T.PlaneGeometry(0.5, 0.08), gm); slit.position.set(0, FLOOR_Y + 0.32, 0.26); g.add(slit); glow.push({ mat: gm, base: 1.0 }); } else if (l.kind === 'relic') { const ped = new T.Mesh(new T.CylinderGeometry(0.22, 0.3, 0.9, 8), new T.MeshStandardMaterial({ color: new T.Color(0.2, 0.2, 0.23), roughness: 0.8 })); ped.position.y = FLOOR_Y + 0.45; g.add(ped); const gm = new T.MeshStandardMaterial({ color: new T.Color(0.1, 0.08, 0.16), emissive: new T.Color(0.6, 0.4, 1.0), emissiveIntensity: 1.4, roughness: 0.3, }); const shard = new T.Mesh(new T.OctahedronGeometry(0.18, 0), gm); shard.position.y = FLOOR_Y + 1.25; shard.name = 'float'; g.add(shard); glow.push({ mat: gm, base: 1.4 }); } else { // hoard const gm = goldMat(); [[0, 0.16, 0], [0.3, 0.12, 0.15], [-0.28, 0.1, -0.1]].forEach(([ox, oy, oz], i) => { const orb = new T.Mesh(new T.SphereGeometry(0.2 - i * 0.03, 10, 8), gm); orb.position.set(ox, FLOOR_Y + oy, oz); g.add(orb); }); glow.push({ mat: gm, base: 1.0 }); } g.position.set(l.x, 0, l.z); g.rotation.y = l.yaw; scene.add(g); lootGroups.push({ spec: l, group: g, glow }); } // Portal: the map's off-route ring, facing the walk. Static — the // party reaches it on the odometer and walks through. const portal = buildPortal({ THREE: T, palette: null }); if (map.portal) { portal.group.position.set(map.portal.x, 0, map.portal.z); portal.group.rotation.y = map.portal.yaw; } scene.add(portal.group); // Lights: dim ambient + cool key + the hero's torch (follows). const ambient = new T.AmbientLight(0xffffff, 0.42); scene.add(ambient); const key = new T.DirectionalLight(0xbdd0ff, 0.9); key.position.set(3.5, 5.0, 6.0); key.castShadow = false; scene.add(key); const torch = new T.PointLight(new T.Color(theme.torch[0], theme.torch[1], theme.torch[2]), 2.0, 12); scene.add(torch); // Lantern fill: a warm light riding with the camera (see update). const camFill = new T.PointLight(0xffe0b8, 0.7, 20); scene.add(camFill); scene.background = null; scene.fog = new T.Fog(0x07070c, 12, 42); scene.userData.transparentBackground = true; const sky = buildSky(T, { radius: 90 }); scene.add(sky.mesh); // The party: torch-bearer + companions, grown on first update when // identity arrives (same pattern as the ride crew). const hero = new T.Group(); hero.name = 'hero'; scene.add(hero); const party = new T.Group(); party.name = 'party'; scene.add(party); const inst = { map, shell, stoneMat, floorMat, flameMat, windowMat, woodMat, puddleMat, puddles, portal, portalDiscMat: portal.discMat, hero, party, torch, dressing, hazards, hazardItems, hazardGates, cast, lootGroups, ambient, key, camFill, skyMesh: sky.mesh, skyMat: sky.mat, // Gait constant per video (from the sampled pace at build): the // odometer never modulates live, so halts stay exact and the walk // can hold or advance but never retreat. Pace dynamics come from // halts and encounters, not from volume surges. cruise: (0.35 + ((params && params.speed) || 0.7) * 0.75) * 1.6, L, seed, venueSpec: venueSpec || null, builtKey: null, popCount: 0, watchRoom: -2, castGrown: false, bodyMeshes: [], envMats: null, }; // Re-home the puddle reflection registration onto the instance. inst.envMats = [puddleMat]; return inst; }, update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) { const T = THREE; const t = timeline.time; const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]]; const identity = personality ? personality.identity : null; const space = personality ? personality.space : null; const style = personality ? personality.style : null; const vs = venueSpec || instance.venueSpec; const vState = venueState || instance.venueState || null; if (vState) instance.venueState = vState; const arrangement = venueArrangement ?? instance.venueArrangement ?? 0; instance.venueArrangement = arrangement; const L = instance.L; const c0 = pal[0]; const fieldLight = vState ? vState.light : 0.7; // Murk from the song's character: dark songs dim the whole venue // and pull the fog close. The hero's torch stays brightest — gloom // with one warm light reads dungeon, not black screen. const murk = (instance.map.character && instance.map.character.murk) || 0; const murkDim = 1 - 0.45 * murk; // --- walk clock: the interrupted odometer. Gait is constant per // video (see build); halts pause it at loot. Pure f(t). const heroS = odometer(instance.cruise, instance.map.items.halts, L, t); const shot = framing && framing.venueShot ? framing.venueShot : null; const isJump = framing && framing.venueJump !== undefined && framing.venueJump !== null; // Watch resolution, once per frame for placement AND camera: room and // portal shots plant in a room with a watch cell, everything else is // first-person. Resolved here (not in the camera) so the hero hides // in exactly the frames the camera stands where he is. const roomTarget = shot && !isJump && (shot.target === 'room' || shot.target === 'portal'); let watchId = -1; if (roomTarget) { if (shot.target === 'portal' && instance.map.portal) { watchId = instance.map.portal.roomId; } else { const ahead = roomAt(instance.map, heroS + 6); watchId = ahead >= 0 ? ahead : roomAt(instance.map, heroS); } if (!(watchId >= 0 && instance.map.watch[watchId])) watchId = -1; } instance.watchRoom = watchId; // First-person whenever no room plant holds — including the unmarked // walk, where the bearer would otherwise stand inside the lens. const firstPerson = watchId < 0; // Local atmosphere follows the map: indoor stone closes the fog in, // open courtyards open it out. Arc'd by the section light as always. const localZone = zoneAt(instance.map, heroS); const localIndoor = localZone === 'hall' || localZone === 'room'; const zoneLightMul = localIndoor ? 0.85 : 1.1; const zoneFogMul = localIndoor ? 0.9 : 1.25; // Stone takes the palette; torch flames arc with the section light. // Generous emissive floor (chamber-junk pattern): on the darkest // songs lit-only stone goes black and the walk moves through nothing. // Stone takes the palette times the theme: the palette keeps songs // distinct, the theme keeps dungeons distinct — multiplicative, so // neither can wash the other out. const tintM = 0.45 + fieldLight * 0.75; const theme = THEMES[instance.map.theme] ?? THEMES.crypt; const sm = theme.stoneMul, fm = theme.floorMul; instance.stoneMat.color.setRGB((c0[0] * 0.42 * tintM + 0.07) * sm[0], (c0[1] * 0.42 * tintM + 0.07) * sm[1], (c0[2] * 0.46 * tintM + 0.08) * sm[2]); instance.stoneMat.emissive.setRGB(c0[0] * 0.16 * sm[0], c0[1] * 0.16 * sm[1], c0[2] * 0.18 * sm[2]); instance.floorMat.color.setRGB((c0[0] * 0.3 * tintM + 0.05) * fm[0], (c0[1] * 0.3 * tintM + 0.05) * fm[1], (c0[2] * 0.34 * tintM + 0.06) * fm[2]); instance.floorMat.emissive.setRGB(c0[0] * 0.16 * fm[0], c0[1] * 0.16 * fm[1], c0[2] * 0.18 * fm[2]); if (instance.key) instance.key.intensity = (1.0 + fieldLight * 1.2) * murkDim; if (instance.ambient) instance.ambient.intensity = (0.6 + fieldLight * 0.55) * murkDim; // Lantern fill riding with the camera: nearby stone always reads, // whichever way the key falls. Positioned from the camera, which is // itself analytic f(t) — seek === playback still holds. if (instance.camFill) { instance.camFill.position.set(camera.position.x, 1.2, camera.position.z + 1.5); instance.camFill.intensity = (0.8 + fieldLight * 0.6) * murkDim; } // Song sky + matched fog, same contract as chamber/stage/ride. paintSky(instance.skyMat, { palette: pal, light: fieldLight * zoneLightMul * murkDim, style, seed: instance.seed }); if (scene.fog) { const fogMul = (vState ? vState.fog : 0.35) * zoneFogMul; const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45) * (1 - 0.35 * murk); scene.fog.near = fogDepth; scene.fog.far = fogDepth + 30; const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null; if (mid) scene.fog.color.copy(mid).multiply(new T.Color(theme.fogTint[0], theme.fogTint[1], theme.fogTint[2])); else scene.fog.color.setRGB(c0[0] * 0.05 * theme.fogTint[0], c0[1] * 0.05 * theme.fogTint[1], c0[2] * 0.07 * theme.fogTint[2]).multiplyScalar(1 - 0.3 * murk); } // Window slits + portal disc glow with the section light; the IMAGE // is the bound viz texture. if (instance.windowMat) { instance.windowMat.emissiveIntensity = (params.light || 0.85) * (0.7 + fieldLight * 0.7); } if (instance.portalDiscMat) { instance.portalDiscMat.emissiveIntensity = (params.light || 0.85) * (0.8 + fieldLight * 0.8); } if (instance.envMats) { const k = (params.light || 0.85) * (0.55 + fieldLight * 0.55); for (const m of instance.envMats) { if (m.userData.vizEnvStrength) m.userData.vizEnvStrength.value = m.userData.vizEnvBase * k; } } // --- party (built once per arrangement; grown when identity lands) --- const popKey = vs ? `${vs.seed}:dungeon:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}` : `no-vs:${instance.seed}:a${arrangement}`; if ((instance.builtKey !== popKey || !instance.popCount) && identity && actorSpec && actorSpec.form) { for (const m of instance.bodyMeshes) { if (m.parent) m.parent.remove(m); m.traverse((o) => { if (o.isMesh) { if (o.geometry) o.geometry.dispose(); if (o.material) { const mats = Array.isArray(o.material) ? o.material : [o.material]; // The hero's flame shares the venue flame material — // never dispose shared mats here. mats.forEach((mm) => { if (mm !== instance.flameMat) mm.dispose(); }); } } }); } instance.bodyMeshes = []; while (instance.hero.children.length) instance.hero.remove(instance.hero.children[0]); while (instance.party.children.length) instance.party.remove(instance.party.children[0]); const rng = new Rng((((vs ? vs.seed : instance.seed) >>> 0) ^ Math.imul(arrangement + 1, 0x9e3779b9)) >>> 0); const cc = (i) => pal[(vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i) % pal.length] || pal[0]; let bodyParts = null; try { const built = actorToGeometry(actorSpec, identity, T); bodyParts = built.children.slice(); } catch { bodyParts = null; } const count = vs && vs.population ? Math.max(2, Math.min(4, vs.population.count | 0)) : 2; const mkFigure = (i, torchBearer) => { let m; if (bodyParts && bodyParts.length) { const src = bodyParts[(i + arrangement) % bodyParts.length]; const col = cc(i + 1); m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({ color: new T.Color(col[0], col[1], col[2]), emissive: new T.Color(col[0] * 0.5, col[1] * 0.5, col[2] * 0.5), emissiveIntensity: 1.2, roughness: 0.5, metalness: 0.08, transparent: opacity < 0.999, opacity, })); m.userData.partIndex = src.userData.partIndex ?? 0; } else { // Figures are the subject and must pop out of their own // dungeon even in a monochrome palette (chamber pattern: // higher emissive for actors than for junk). const col = cc(i + 1); m = new T.Mesh( new T.SphereGeometry(0.24, 12, 10), new T.MeshStandardMaterial({ color: new T.Color(col[0], col[1], col[2]), emissive: new T.Color(col[0] * 0.55, col[1] * 0.55, col[2] * 0.55), emissiveIntensity: 1.2, roughness: 0.5, })); } const g = new T.Group(); g.add(m); g.scale.setScalar(torchBearer ? 1.0 : 0.92); g.userData.index = i; g.userData.phase = rng.next() * Math.PI * 2; return g; }; // Torch-bearer: the hero, first body part, carrying the flame. const heroFig = mkFigure(0, true); const pole = new T.Mesh( new T.CylinderGeometry(0.035, 0.035, 1.5, 6), new T.MeshStandardMaterial({ color: new T.Color(0.08, 0.07, 0.06), roughness: 0.8 })); pole.position.set(0.42, 0.75, 0); heroFig.add(pole); const flame = new T.Mesh(new T.SphereGeometry(0.12, 10, 8), instance.flameMat); flame.position.set(0.42, 1.6, 0); flame.scale.y = 1.5; heroFig.add(flame); heroFig.userData.flame = flame; instance.hero.add(heroFig); instance.bodyMeshes.push(heroFig); for (let i = 1; i < count; i++) { const g = mkFigure(i, false); instance.party.add(g); instance.bodyMeshes.push(g); } instance.builtKey = popKey; instance.popCount = count; instance.needsCompile = true; } // Hero + party placement on the walked route. The hero sidesteps // gates AND pacing enemies (their home arclengths join the // avoidance list — all analytic, so dodges are seek-stable). const heroBend = pathAt(instance.map, heroS); let avoid = 0; try { const dodge = (instance.hazardGates || []).concat( (instance.cast || []) .filter((en) => en.spec.kind === 'lurker' || en.spec.kind === 'sentry') .map((en) => ({ s: en.spec.s }))); if (dodge.length) avoid = avoidanceOffset(heroS, dodge, t); } catch { avoid = 0; } { const walking = true; // the odometer never freezes instance.hero.position.set(heroBend.x + avoid, Math.abs(Math.sin(t * 3.4)) * 0.05, heroBend.z); instance.hero.rotation.y = heroBend.yaw; // The bearer never renders from his own eyes: first-person hides // him (a body at the lens clips); the party leads ahead carrying // lanterns — far enough ahead and aside that no companion fills // the lens even where the route bends (chords run shorter than // arcs). Planted shots show the whole party trailing. instance.hero.visible = opacity > 0.01 && !firstPerson; const kids = instance.party.children; for (let k = 0; k < kids.length; k++) { const off = firstPerson ? (6.0 + k * 2.0) : -(1.9 + k * 1.7); const cs = wrapS(heroS + off, L); const cp = pathAt(instance.map, cs); const aside = firstPerson ? (k % 2 === 0 ? -0.9 : 0.9) : 0; kids[k].position.set( cp.x + avoid * 0.5 + aside + Math.sin(t * 0.5 + k * 2.1) * 0.15, Math.abs(Math.sin(t * 3.4 + k * 1.4)) * 0.04, cp.z); kids[k].rotation.y = cp.yaw + Math.sin(t * 0.4 + k) * 0.12; kids[k].visible = opacity > 0.01; // Route position feeds the portcullis lift (see updateHazards): // the gate holds until the last walker clears it. kids[k].userData.routeS = cs; } } // Bodies grow from the song's actor pool on first update (same // pattern as the party): the Layer hands the venue one ActorSpec // (monolith) plus the track summary/personality/identity, so the // remaining pool is generated here, seeded, and cached — enemy i // wears the same character the viz marches, darkened hostile, eyes // set into the face the geometry actually has. if (identity && actorSpec && actorSpec.form && !instance.castGrown) { // One ActorSpec per enemy kind from the track's own summary — // walker/structure/swarm via generateActor (the same pool and // the same audio tilt as the viz cast), forked off the venue // seed so the pool never shifts the map/personality streams. // Seeded once, cached on the instance, grown once. if (!instance.enemySpecs) { const eRng = new Rng(((instance.seed >>> 0) || 1) ^ 0xe1e7); const summary = venueSpec && venueSpec.summary ? venueSpec.summary : null; instance.enemySpecs = {}; for (const kind of ['lurker', 'sentry', 'critter']) { const arch = CAST_ARCHETYPE[kind]; try { instance.enemySpecs[kind] = generateActor({ summary, rng: eRng.fork(`enemy:${kind}`), archetype: arch, personality, identity, }); } catch { instance.enemySpecs[kind] = null; } } } const eyeGeo = new T.SphereGeometry(0.05, 8, 6); let pi = 0; for (const en of instance.cast || []) { if (en.grown) continue; let body = null; const srcSpec = instance.enemySpecs && instance.enemySpecs[en.spec.kind]; if (srcSpec && srcSpec.form) { let src = null; try { const grp = actorToGeometry(srcSpec, identity, T); const kids = grp.children.slice(); if (kids.length) src = kids[(pi++) % kids.length]; } catch { src = null; } if (src) { const specEntry = srcSpec.paletteMap ? srcSpec.paletteMap[(src.userData.partIndex ?? 0) % srcSpec.paletteMap.length] : null; const col = (Number.isInteger(specEntry) ? pal[specEntry % pal.length] : null) || pal[(pi + 1) % pal.length] || pal[0]; const bm = new T.MeshStandardMaterial({ color: new T.Color(col[0] * 0.32, col[1] * 0.32, col[2] * 0.34), emissive: new T.Color(col[0] * 0.12, col[1] * 0.12, col[2] * 0.13), emissiveIntensity: 1.0, roughness: 0.7, metalness: 0.08, transparent: opacity < 0.999, opacity, }); body = new T.Mesh(src.geometry, bm); body.userData.partIndex = src.userData.partIndex ?? 0; body.userData.kind = en.spec.kind; en.bodyMats.push(bm); en.palEntry = (pi + 1) % pal.length; } } if (!body) { // Fallback blob: something must stand there even when the // cast fails to grow (headless edge cases, bad forms). body = new T.Mesh( new T.SphereGeometry(0.3, 10, 8), new T.MeshStandardMaterial({ color: new T.Color(0.09, 0.09, 0.11), emissive: new T.Color(0.08, 0.08, 0.1), roughness: 0.8, })); } // Normalize to stance height off the geometry's own bounding // box (pure geometry math — same every frame), feet planted // at the floor regardless of the part's extent or origin. const geo = body.geometry; if (!geo.boundingBox) geo.computeBoundingBox(); const bb = geo.boundingBox; const h = Math.max(1e-3, bb.max.y - bb.min.y); const k = en.height / h; body.scale.setScalar(k); body.position.y = -bb.min.y * k; en.group.add(body); en.bodyMesh = body; // Eyes into the face: top-front of the box, spread by width. const spread = Math.min(0.15, (bb.max.x - bb.min.x) * k * 0.22); const eyeY = body.position.y + bb.max.y * k * 0.88; const frontZ = bb.max.z * k + 0.03; for (const side of [-1, 1]) { const m = new T.Mesh(eyeGeo, new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.02, 0.01), emissive: new T.Color(1.0, 0.25, 0.1), emissiveIntensity: 1.6, roughness: 0.4, })); m.position.set(side * spread, eyeY, frontZ); en.group.add(m); en.eyes.push(m.material); } en.group.visible = opacity > 0.01; en.grown = true; } instance.castGrown = true; instance.needsCompile = true; } let proxMax = 0; for (const en of instance.cast || []) { if (!en.grown) continue; const pose = entityPose(en.spec, t, heroS, instance.hero.position.x, instance.hero.position.z, L); const bobY = en.spec.kind === 'wisp' ? Math.sin(t * 1.3 + en.phase) * 0.25 : 0; en.group.position.set(pose.x, en.baseY + bobY, pose.z); en.group.rotation.y = pose.yaw; const hostile = en.spec.kind === 'lurker' || en.spec.kind === 'sentry'; proxMax = Math.max(proxMax, pose.alert * (hostile ? 1 : 0.4)); // Song colours, darkened hostile: the things in the dark wear // this track's palette the way the party does. for (const bm of en.bodyMats) { const c = pal[(en.palEntry || 0) % pal.length] || pal[0]; bm.color.setRGB(c[0] * 0.3 * tintM + 0.02, c[1] * 0.3 * tintM + 0.02, c[2] * 0.32 * tintM + 0.02); bm.emissive.setRGB(c[0] * 0.12, c[1] * 0.12, c[2] * 0.13); } for (const m of en.eyes) { if (m.emissiveIntensity !== undefined) { m.emissiveIntensity = (en.spec.kind === 'wisp' ? 2.0 : 1.2) + pose.alert * 2.2; } } } for (const lg of instance.lootGroups || []) { const dd = Math.abs(heroS - lg.spec.s) % L; const d = Math.min(dd, L - dd); const notice = d < 6 ? (1 - d / 6) * (1 - d / 6) : 0; for (const g of lg.glow) { g.mat.emissiveIntensity = g.base * (0.75 + notice * 1.6); } const fl = lg.group.getObjectByName('float'); if (fl) { fl.rotation.y = t * 0.8; fl.position.y += Math.sin(t * 1.7) * 0.002; } } // The torch follows the bearer and breathes with the section light — // and burns higher when something out there notices him back. Murk // dims it only slightly: the hero's flame is the one light a dark // dungeon keeps. instance.torch.position.set( instance.hero.position.x + 0.42, 1.5, instance.hero.position.z); instance.torch.intensity = (1.6 + fieldLight * 2.0) * (0.9 + 0.1 * Math.sin(t * 9.3)) * (1 + proxMax * 0.9) * (1 - 0.2 * murk); if (instance.flameMat) { instance.flameMat.emissiveIntensity = 2.2 * (0.4 + 0.6 * fieldLight) * (0.88 + 0.12 * Math.sin(t * 9.3)); } // Sconce + dressing glow: flames breathe, banners sway. Positions // are fixed map seats; only light animates, as pure f(t). for (const item of instance.dressing || []) { animateDressing(item, t, fieldLight); } // Hazards live their analytic life (portcullis lift, swings, embers). // The gate lift keys on the route positions of the whole procession // (hero + companions), settled above — never the hero alone. if (instance.hazards) { const partyS = [heroS]; for (const kid of instance.party.children) { if (kid.userData && typeof kid.userData.routeS === 'number') { partyS.push(kid.userData.routeS); } } updateHazards({ hazards: { items: instance.hazardItems }, t, light: fieldLight, opacity, heroS, partyS, }); } // Gap damage wears the floor's colours: shards share the floor // diffuse/emissive (tilted geometry still catches its own light), // cracks are dim stone with the section-light breath. Recomputed // from the live floor values so the break always matches its // surroundings on every song. for (const item of instance.hazardItems || []) { const gm = item.userData && item.userData.gapMats; if (!gm) continue; const fc = instance.floorMat.color, fe = instance.floorMat.emissive; gm.shardMat.color.copy(fc); gm.shardMat.emissive.copy(fe); gm.crackMat.color.setRGB(fc.r * 0.7 + 0.01, fc.g * 0.7 + 0.01, fc.b * 0.7 + 0.01); gm.crackMat.emissive.copy(instance.stoneMat.emissive); } // Opacity fades for the shared surfaces (transitions dissolve). for (const mt of [instance.stoneMat, instance.floorMat, instance.windowMat, instance.portalDiscMat, instance.puddleMat]) { if (!mt) continue; mt.opacity = opacity; mt.transparent = opacity < 0.999; } // --- camera: this venue's own rig (directCamera), three modes. --- driveDungeonCamera({ instance, camera, timeline, framing, personality, venueState: vState, heroS }); void features; }, }; /** * The dungeon's own camera rig: first-person walk, or a plant inside a room * watching the actor walk (the portal room for portal shots). All analytic * f(time, seed, route) — seek stable. Jump cues cut instantly to the mark. * * Plants come from the map, never from open space: the walk plant stands on * the route, a room plant stands on that room's watch cell (an open cell off * the walked line with line-of-sight to it). The camera can never be inside * a wall. When the actor crosses into a new room mid-shot the plant snaps to * the new room — a doorway crossing reads as a coverage cut. */ function driveDungeonCamera({ instance, camera, timeline, framing, personality, venueState, heroS }) { const t = timeline.time; const frame = framing || { scale: 1, shift: [0, 0] }; const pCam = personality ? personality.camera : null; const motion = personality ? personality.motion : null; const seed = ((instance.seed >>> 0) || 1); const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029); const still = motion ? motion.stillness : 0.5; const churn = motion ? motion.churn : 0.25; const planned = frame && frame.venueShot ? frame.venueShot : null; const hx = Math.sin(t * 0.9 + s1 * 7.0) * 0.5 * (0.4 + churn) + Math.sin(t * 0.23 + s2 * 5.0) * 0.5 * (0.4 + churn); const hy = Math.cos(t * 0.77 + s2 * 9.0) * 0.5 * (0.4 + churn) + Math.sin(t * 0.31 + s1 * 4.0) * 0.5 * (0.4 + churn); const sway = pCam ? pCam.sway : 0.01; const swayRate = pCam ? pCam.swayRate : 0.09; const isJump = frame.venueJump !== undefined && frame.venueJump !== null; const L = instance.L; const map = instance.map; if (planned && isJump) { camera.position.set(planned.pos[0] + hx * 0.05, planned.pos[1] + hy * 0.05, planned.pos[2]); camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; camera.lookAt(planned.look[0], planned.look[1], planned.look[2]); camera.updateMatrixWorld(); return; } const roomShot = planned && !isJump && (planned.target === 'room' || planned.target === 'portal'); // The plant was resolved in update (shared with hero visibility, so the // bearer hides in exactly the frames the camera stands where he is). // A doorway crossing snaps the plant — a coverage cut, not a glide // through walls. if (roomShot) { const w = instance.watchRoom >= 0 ? map.watch[instance.watchRoom] : null; if (w) { camera.position.set( w.x + hx * 0.04, Math.max(0.2, EYE_Y + 1.15 + hy * 0.04), w.z, ); camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; // The actor, live: the plant watches the walk, not a mark. const h = instance.hero.position; camera.lookAt(h.x, 0.9, h.z); camera.updateMatrixWorld(); return; } // No watch cell (untraversed room — shouldn't happen): fall through // to the walk below rather than holding a stale frame. } // Walk (default): first-person on the route, eye at 1.6, looking ~7 units // ahead with a whisper of the mark (0.08) toward the motive, clamped // tight to the walked line (±0.8). Corridors are a cell wide: anything // looser aims at the side wall a metre away, and a frame of flat stone // is a dead shot. The odometer travel already differentiates shots; the // bend only textures it. { const ahead = pathAt(map, wrapS(heroS ?? 0, L)); const lookS = wrapS((heroS ?? 0) + 7, L); const lp = pathAt(map, lookS); const lean = planned && !isJump ? 0.08 : 0; const lx = planned ? Math.max(ahead.x - 0.8, Math.min(ahead.x + 0.8, planned.look[0])) : lp.x; const ly = planned ? planned.look[1] : 0.2; const lzz = planned ? planned.look[2] : lp.z; // Torch-bob: the eye bobs with the step plus the personality sway, // scaled down when the song holds still. const bob = Math.sin(t * 3.4) * 0.035 * (1 - still * 0.6); camera.position.set( ahead.x + Math.sin(t * swayRate) * sway * 2 + hx * 0.05, EYE_Y + bob + Math.cos(t * swayRate * 0.83) * sway + hy * 0.04, ahead.z, ); camera.rotation.z = pCam ? pCam.spin * t * 0.5 : 0; camera.lookAt( lp.x * (1 - lean) + lx * lean, 0.2 * (1 - lean) + ly * lean, lp.z * (1 - lean) + lzz * lean, ); camera.updateMatrixWorld(); } } export default dungeon;