Files
music-video-gen/flow-state/src/venues/dungeon.js
T
Dejvino 6a2af2e45f Dungeon themes: per-song visual identity (crypt/overgrown/ember/flooded/windswept)
One album must not render the same dungeon twice. dungeonTheme() scores
the measured summary per theme — pace terms gate on tempoConfidence so
doubled detectors never read fast, crypt is penalised for blazing
masters — plus small seeded jitter so ambiguous songs vary across seeds
while decided races stay decided. The plan carries the theme name;
dressing mix, planting/timber/puddle densities follow it, and the facade
builds all materials (stone/floor multipliers, torch + light color, wood,
fog tint) from the same record.

Calibrated bank spread is 4/4/4/4/1 (windswept holds runner); corners
stable across seeds; plan reshaping, facade wiring, and murk-vs-clear
gates covered by tests.
2026-10-08 22:20:04 +02:00

1034 lines
51 KiB
JavaScript

// 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 <emissivemap_fragment>',
`#include <emissivemap_fragment>
{
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;