From c96e810d44013db1c9562ce193f66fa7bfd3dce8 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sun, 4 Oct 2026 22:13:25 +0200 Subject: [PATCH] Dungeon venue: grid-first traversable dungeon with cast, loot and encounters New VenueGenerator 'dungeon' archetype. The floor plan is generated first on a 44x44 grid (rejection-sampled rooms, Kruskal MST + loop edges, L-shaped corridors, BFS-verified connectivity); walls are merged runs built exactly where open meets solid, so the shell has no holes, and the walk route is a closed travelling tour through every room. The camera either walks first-person (hero hidden to avoid clipping) or plants on a per-room watch cell with line-of-sight to the route and tracks the actor. Enemies (walker/structure/swarm ActorSpecs from the track's own summary) lurk, stand sentry, dart and drift; loot caches pause the analytic odometer, which can hold or advance but never retreat. Includes dungeon-debug.html (2D map + timeline stills), 14 map tests plus facade camera tests, and the portcullis-holds-for-the-whole-party regression test. --- flow-state/dungeon-debug.html | 225 +++++ flow-state/src/look/venueShots.js | 87 +- flow-state/src/params/schema.js | 2 +- flow-state/src/venues/VenueGenerator.js | 35 +- flow-state/src/venues/dungeon.js | 1002 ++++++++++++++++++++++ flow-state/src/venues/dungeon/hazards.js | 203 +++++ flow-state/src/venues/dungeon/map.js | 966 +++++++++++++++++++++ flow-state/src/venues/dungeon/props.js | 266 ++++++ flow-state/src/venues/dungeon/shared.js | 58 ++ flow-state/src/venues/registry.js | 5 +- flow-state/test/dungeon-modules.test.js | 355 ++++++++ flow-state/test/venue.test.js | 123 +++ flow-state/tools/new-venue.js | 2 +- 13 files changed, 3309 insertions(+), 20 deletions(-) create mode 100644 flow-state/dungeon-debug.html create mode 100644 flow-state/src/venues/dungeon.js create mode 100644 flow-state/src/venues/dungeon/hazards.js create mode 100644 flow-state/src/venues/dungeon/map.js create mode 100644 flow-state/src/venues/dungeon/props.js create mode 100644 flow-state/src/venues/dungeon/shared.js create mode 100644 flow-state/test/dungeon-modules.test.js diff --git a/flow-state/dungeon-debug.html b/flow-state/dungeon-debug.html new file mode 100644 index 0000000..adfc1e9 --- /dev/null +++ b/flow-state/dungeon-debug.html @@ -0,0 +1,225 @@ + + + + +flow-state · dungeon debug + + + +

flow-state · dungeon debug

+
starting…
+

map —

+
+ +
+
+

stills — song timeline, both camera modes

+
+ + + diff --git a/flow-state/src/look/venueShots.js b/flow-state/src/look/venueShots.js index f6eea1a..18e9652 100644 --- a/flow-state/src/look/venueShots.js +++ b/flow-state/src/look/venueShots.js @@ -67,6 +67,19 @@ export const VENUE_STAGING = { box: { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 }, dolly: { wide: 5.5, normal: 3.4, close: 1.8 }, }, + // The dungeon: the "screen" is the way ahead (the party's lanterns, the + // portal glow down the corridor); the "room" is the stone around the + // walk. Marks mostly steer the facade's own walk/room/portal modes — + // the pos/look bends the look path, the target picks the mode. + dungeon: { + screen: [0, 1.2, -8], + fieldL: [-2.4, 0.2, -4.0], + fieldR: [2.4, 0.2, -4.0], + roomMid: [0, 0.6, -5], + roomSide: 2.6, + box: { x: 4.0, yLo: -0.2, yHi: 2.4, zLo: -6.0, zHi: 6.0 }, + dolly: { wide: 5.5, normal: 3.4, close: 1.8 }, + }, }; /** Shot sizes for the room — same three words as the viz, different numbers. */ @@ -149,19 +162,24 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga sectionSize = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== sectionSize)); } if (sharpCut && prevMark) { - // Flip the look direction outright: screen↔room, so the new - // composition cannot be the old one from a step aside. - // (Passerby/billboard have no opposite — re-draw instead.) + // Flip the look direction outright: screen↔room (walk↔room + // in the dungeon), so the new composition cannot be the old + // one from a step aside. + // (Passerby/billboard/portal have no opposite — re-draw instead.) const pt = prevMark.target; sectionTarget = pt === 'screen' ? 'room' : pt === 'room' ? 'screen' + : pt === 'walk' ? 'room' + : pt === 'room' ? 'walk' + : archetype === 'dungeon' ? (rng.bool(0.5) ? 'walk' : 'room') : (rng.bool(0.5) ? 'screen' : 'room'); } else { sectionTarget = pickTarget(prevMark, energy, story, rng, sectionTargets.map((t) => ({ target: t })), archetype); } // A billboard shot wants car + board in frame: force wide. - if (sectionTarget === 'billboard' && !locked) sectionSize = 'wide'; + // A portal tableau wants the ring + procession in frame: wide too. + if ((sectionTarget === 'billboard' || sectionTarget === 'portal') && !locked) sectionSize = 'wide'; sectionTargets.push(sectionTarget); } const progress = n > 1 ? cue.index / (n - 1) : 0.5; @@ -170,11 +188,13 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga // rejoin the rotation, alternating away from whatever just played so // the cut out of the plant always changes the composition. let cueTarget = sectionTarget; - if (cueTarget === 'passerby' || cueTarget === 'billboard') { + if (cueTarget === 'passerby' || cueTarget === 'billboard' || cueTarget === 'portal') { sectionPlants++; if (sectionPlants > 1) { const pt = marks.length ? marks[marks.length - 1].target : null; - cueTarget = pt === 'screen' ? 'room' : 'screen'; + cueTarget = archetype === 'dungeon' + ? (pt === 'walk' ? 'room' : 'walk') + : (pt === 'screen' ? 'room' : 'screen'); } } const mark = placeMark(staging, sectionSize, cueTarget, jump, rng, progress, gaze, roomSide); @@ -260,6 +280,31 @@ function pickTarget(previous, energy, story, rng, history, archetype = null) { && last2[last2.length - 1] !== 'passerby' && rng.bool(0.3)) { return 'billboard'; } + // Dungeon-only: a portal section plants the camera on the party's path + // and watches it process through the seated portal. Rarest plant (at most + // a couple per video), same breath-only rule, never adjacent to itself — + // two planted tableaux back to back read as stuck. Consumes its draw even + // when blocked, like passerby. + const portalDraw = archetype === 'dungeon' && act !== 'climax' + && (act === 'setup' || act === 'resolution' || energy < 0.42) + && rng.bool(0.3); + if (portalDraw && last2[last2.length - 1] !== 'portal') { + return 'portal'; + } + // Dungeon travel: walk is the neutral tread, room is the watch lean — + // first-person against planted. Same act/energy grammar as screen/room. + if (archetype === 'dungeon') { + let dpick; + if (act === 'setup') dpick = 'walk'; + else if (act === 'resolution') dpick = rng.bool(0.3) ? 'room' : 'walk'; + else if (act === 'climax') dpick = 'room'; + else if (act === 'turn') dpick = rng.bool(0.75) ? 'room' : 'walk'; + else if (energy > 0.66 || tension > 0.7) dpick = 'room'; + else if (energy < 0.36) dpick = 'walk'; + else dpick = rng.bool(0.5) ? 'room' : 'walk'; + if (streak && dpick === streak) dpick = streak === 'room' ? 'walk' : 'room'; + return dpick; + } let pick; if (act === 'setup') pick = 'room'; else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room'; @@ -302,13 +347,28 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null look: [-side * 9.0, 3.0, -20], }; } + if (target === 'portal') { + // Planted tableau: the camera stands off the path while the party + // processes through the seated portal. The facade seats the portal + // ring AT the look point each frame, so pos/look here only need to + // agree with each other — a usable two-shot of ring + approach. + // Side follows the video's roomSide: one consistent side all song. + const side = roomSide || 1; + return { + pos: [side * 2.6, 1.5, 1.0], + look: [-side * 0.5, 1.3, -8], + }; + } + // Dungeon travel modes share the room/screen anchors: walk reads the + // way ahead (screen), room reads the place around the walk (room). + const motive = target === 'walk' ? 'screen' : target === 'room' ? 'room' : target; const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const { box, dolly } = staging; // Room visits alternate sides so consecutive room views face opposite // walls (the screen slides left, then right) instead of both staring down // the middle. Screen visits stay centred — the screen IS the axis. - let anchor = target === 'screen' ? staging.screen : staging.roomMid; - if (target === 'room') { + let anchor = motive === 'screen' ? staging.screen : staging.roomMid; + if (motive === 'room') { // ONE side per video (threaded in as roomSide): alternating walls is a // zigzag (left-right-left reads as churn, measured flat), while one // steady side gives the look path a consistent off-axis home it keeps @@ -336,7 +396,7 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null } // Close on the screen stands nearly centre (it fills the frame anyway); // wides take the sides (parallax over the furniture is the point). - const cx = size === 'close' && target === 'screen' ? x * 0.3 : x; + const cx = size === 'close' && motive === 'screen' ? x * 0.3 : x; // A fraction of the viz gaze relocation: both cameras cut the same way. // Kept small (a third) — full coupling threw the room around per section. const gx = gaze ? gaze[0] * 0.35 : 0; @@ -357,7 +417,7 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null // Consecutive anchors must differ ONLY by motive, never by noise. const screenAnchor = staging.screen; const forward = [0, 0.3, screenAnchor[2]]; - const k = target === 'room' ? 0.9 : 0.55; + const k = motive === 'room' ? 0.9 : 0.55; const look = [ lerp(forward[0], anchor[0], k), lerp(forward[1], anchor[1], k), @@ -396,11 +456,12 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = // the look (the furniture swapped; the path would glide through the old // room). The reported target stays the cue's (HUD motive), the look itself // is where the path is. - // Passerby/billboard hold their plants: the camera is a bystander, and + // Passerby/billboard/portal hold their plants: the camera is a bystander, and // any drift of the look would read as a pan away from the road. driveT // (0→1 across the cue) is the car's cue — the module drives it from far // +z to far -z on it (passerby) or aims at the live panel (billboard). - const look = (cur.target === 'passerby' || cur.target === 'billboard') + const plantedMark = cur.target === 'passerby' || cur.target === 'billboard' || cur.target === 'portal'; + const look = plantedMark ? cur.look.slice() : lookPathAt(marks, progress, cur.jump !== null ? cur : null); const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame); @@ -429,7 +490,7 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = // A planted camera holds its position too — the bystander does not drift // down the road while the car passes. (The look already holds above; the // module adds handheld micro on top.) - const planted = cur.target === 'passerby' || cur.target === 'billboard'; + const planted = cur.target === 'passerby' || cur.target === 'billboard' || cur.target === 'portal'; return { pos: planted ? cur.pos.slice() : [lerp(prev.pos[0], cur.pos[0], e), lerp(prev.pos[1], cur.pos[1], e), lerp(prev.pos[2], cur.pos[2], e)], diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index c19a4ea..8a6f337 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -346,7 +346,7 @@ export function validateModule(module) { if (module.venue && typeof module.venue !== 'boolean') { errors.push(`${id}: \`venue\` must be boolean`); } - if (module.archetype && !['chamber','stage','ride','passage','void'].includes(module.archetype)) { + if (module.archetype && !['chamber','stage','ride','dungeon','passage','void'].includes(module.archetype)) { errors.push(`${id}: unknown venue archetype '${module.archetype}'`); } } diff --git a/flow-state/src/venues/VenueGenerator.js b/flow-state/src/venues/VenueGenerator.js index 4395389..2ddd835 100644 --- a/flow-state/src/venues/VenueGenerator.js +++ b/flow-state/src/venues/VenueGenerator.js @@ -4,6 +4,7 @@ import { signatureWeight } from '../look/Personality.js'; import { venues, ARCHETYPES } from './registry.js'; +import { genDungeon } from './dungeon/map.js'; const clamp01 = (x) => Math.max(0, Math.min(1, x)); @@ -42,6 +43,14 @@ export function generateVenue({ summary = {}, rng, personality = null, director if (v.archetype === 'chamber') w *= 0.7 + stillness * 0.8 + (1 - fast) * 0.35; if (v.archetype === 'stage') w *= 0.6 + fast * 0.6 + noisy * 0.35; if (v.archetype === 'ride') w *= 0.35 + drive * 1.4; + // Dark, slow songs want a crypt: slowness gates the whole tilt, then + // stillness and darkness push inside it. Fast songs barely feel it. + // Tilt, not decide. + if (v.archetype === 'dungeon') { + const slow = clamp01((110 - (s.bpm ?? 120)) / 50); + const dark = 1 - Math.min(1, (s.meanCentroid ?? 0.5) * 1.4); + w *= 0.12 + slow * (0.3 + stillness * 1.0 + dark * 0.5); + } tilt[v.name] = w; weights[i] = w; } @@ -62,19 +71,25 @@ export function generateVenue({ summary = {}, rng, personality = null, director }; // Projection — hybrid per archetype. The ride's screen is the car radio: - // small, bright, watched up close. + // small, bright, watched up close. The dungeon's is the portal stone: + // architectural, bright, approached on foot. const projection = picked.archetype === 'chamber' ? { kind: 'window', uvScale: 1, brightness: 0.55 } : picked.archetype === 'ride' ? { kind: 'radio', uvScale: 1, brightness: 0.9 } - : { kind: 'screen', uvScale: 1, brightness: 0.75 }; + : picked.archetype === 'dungeon' + ? { kind: 'portal', uvScale: 1, brightness: 0.9 } + : { kind: 'screen', uvScale: 1, brightness: 0.75 }; // Population — lattice-like, deterministic count/spread/jitter. The ride - // seats actor passengers (2-4), not a crowd. + // seats actor passengers (2-4), not a crowd; the dungeon walks a delving + // party (2-4), not a crowd either. const latticeKinds = ['grid', 'scatter', 'radial']; const pRng = rng.fork(`venue:pop:${picked.name}`); const population = picked.archetype === 'ride' ? { kind: 'row', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) } + : picked.archetype === 'dungeon' + ? { kind: 'party', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) } : { kind: pRng.pick(latticeKinds), count: pRng.int(picked.archetype === 'chamber' ? 3 : 4, picked.archetype === 'chamber' ? 9 : 12), @@ -153,10 +168,24 @@ export function generateVenue({ summary = {}, rng, personality = null, director } } + // Dungeon sub-plans: the floor plan itself — grid, rooms, walls, route, + // items, portal, watch cells. Pure data, seeded, serialisable; the facade + // builds it. Other archetypes get dungeon:null. Forked so existing draws + // don't move (same rule as venue:pop). + let dungeonPlan = null; + if (picked.archetype === 'dungeon') { + const dRng = rng.fork('dungeon:plan'); + dungeonPlan = { + map: genDungeon({ seed, rng: dRng }), + }; + } + return { archetype: picked.archetype, name: picked.name, seed, + summary: { ...(summary || {}) }, + dungeon: dungeonPlan, shell, projection, population, diff --git a/flow-state/src/venues/dungeon.js b/flow-state/src/venues/dungeon.js new file mode 100644 index 0000000..fb65cbf --- /dev/null +++ b/flow-state/src/venues/dungeon.js @@ -0,0 +1,1002 @@ +// 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, 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; + + // 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(1.0, 0.55, 0.18), 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, + }); + + // 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); + + // 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, PROP_TINTS.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(0xff9a3c, 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, + 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; + + // --- 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. + const tintM = 0.45 + fieldLight * 0.75; + instance.stoneMat.color.setRGB(c0[0] * 0.42 * tintM + 0.07, c0[1] * 0.42 * tintM + 0.07, c0[2] * 0.46 * tintM + 0.08); + instance.stoneMat.emissive.setRGB(c0[0] * 0.16, c0[1] * 0.16, c0[2] * 0.18); + instance.floorMat.color.setRGB(c0[0] * 0.3 * tintM + 0.05, c0[1] * 0.3 * tintM + 0.05, c0[2] * 0.34 * tintM + 0.06); + instance.floorMat.emissive.setRGB(c0[0] * 0.16, c0[1] * 0.16, c0[2] * 0.18); + if (instance.key) instance.key.intensity = 1.0 + fieldLight * 1.2; + if (instance.ambient) instance.ambient.intensity = 0.6 + fieldLight * 0.55; + // 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; + } + // Song sky + matched fog, same contract as chamber/stage/ride. + paintSky(instance.skyMat, { palette: pal, light: fieldLight * zoneLightMul, 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); + 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); + else scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07); + } + // 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. 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 ? (4.5 + 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.7 : 0.7) : 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. + 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); + 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; diff --git a/flow-state/src/venues/dungeon/hazards.js b/flow-state/src/venues/dungeon/hazards.js new file mode 100644 index 0000000..4f7d8e6 --- /dev/null +++ b/flow-state/src/venues/dungeon/hazards.js @@ -0,0 +1,203 @@ +// Hazards submodule — gates and set-pieces the walk appears to negotiate. +// +// Visual only: the facade adds avoidanceOffset() to the hero's lateral x. +// Pure + headless-safe: no renderer, no wall-clock, no Math.random — seeded Rng +// only, all motion analytic f(t) so seek === playback and preview === export. +// Type is a discrete plan-time choice; footprints stay narrow so a hazard never +// swallows the path. The world is STATIC (the camera walks), so hazard marks +// sit at fixed arclength and the hero is compared against them directly. +// +// Contract (exact export names/signatures): +// HAZARD_BUILDERS: registry object { gate, gap, patrol } +// planHazards({ seed, rng, sections, span }) -> per-section [{s, type}] +// buildHazards({ THREE, sectionPlan, seed }) -> { group, items } +// updateHazards({ hazards, sectionPlan, t, light, opacity, pathPlan }) -> void +// avoidanceOffset(heroS, sectionPlan, t) -> lateral nudge, |x| <= 1.0 + +import { smooth01 } from './shared.js'; + +const FLOOR_Y = -1.15; + +function std(THREE, color, opts = {}) { + return new THREE.MeshStandardMaterial({ + color: new THREE.Color(color[0], color[1], color[2]), + roughness: opts.roughness ?? 0.6, + metalness: opts.metalness ?? 0.1, + emissive: new THREE.Color( + (opts.emissive ?? [0, 0, 0])[0], + (opts.emissive ?? [0, 0, 0])[1], + (opts.emissive ?? [0, 0, 0])[2] + ), + emissiveIntensity: opts.emissiveIntensity ?? 1, + }); +} + +function stone(THREE, tint) { + return std(THREE, tint, { roughness: 0.9, metalness: 0.02, emissive: tint, emissiveIntensity: 0.12 }); +} + +function gate(THREE, rng, tint) { + // Portcullis: two jambs + lintel + hanging bars. The walk passes through + // the middle — the hero sidesteps via avoidanceOffset, never through iron. + const g = new THREE.Group(); + const mat = stone(THREE, tint); + const iron = std(THREE, [0.1, 0.1, 0.12], { roughness: 0.45, metalness: 0.7 }); + for (const side of [-1, 1]) { + const jamb = new THREE.Mesh(new THREE.BoxGeometry(0.36, 3.4, 0.5), mat); + jamb.position.set(side * 1.85, FLOOR_Y + 1.7, 0); + g.add(jamb); + } + const lintel = new THREE.Mesh(new THREE.BoxGeometry(4.1, 0.5, 0.5), mat); + lintel.position.set(0, FLOOR_Y + 3.6, 0); + g.add(lintel); + const n = 5; + const bars = new THREE.Group(); + for (let k = 0; k < n; k++) { + const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.6, 6), iron); + bar.position.set(-1.2 + k * (2.4 / (n - 1)), FLOOR_Y + 2.1, 0); + bars.add(bar); + } + g.add(bars); + // Lifted per frame by updateHazards: the gate opens as the hero nears + // and falls behind them — the walk passes through iron, never through + // bars. Analytic f(heroS), so a seek lands on the same opening. + g.userData.liftBars = bars; + return g; +} + +function gap(THREE, rng, tint) { + // Broken floor: tilted flagstone shards in the FLOOR's own colours with + // one dim stone crack breathing between them — damage in place, not a + // black square. Pure visual — the hero steps around it via + // avoidanceOffset. The facade re-tints all shard/crack materials per + // frame from the floor (see the gapMats registry below) so the break + // always matches its surroundings; the tints passed here are only the + // build-time seed visible in headless checks. + const g = new THREE.Group(); + const shardMat = stone(THREE, tint); + for (let k = 0; k < 5; k++) { + const w = 0.4 + rng.next() * 0.35; + const shard = new THREE.Mesh(new THREE.BoxGeometry(w, 0.07, 0.3 + rng.next() * 0.25), shardMat); + const a = (k / 5) * Math.PI * 2 + rng.next() * 0.5; + shard.position.set(Math.cos(a) * (0.55 + rng.next() * 0.25), FLOOR_Y + 0.045, Math.sin(a) * (0.65 + rng.next() * 0.3)); + shard.rotation.y = -a + (rng.next() - 0.5) * 0.6; + shard.rotation.z = (rng.next() - 0.5) * 0.35; + g.add(shard); + } + const edgeMat = std(THREE, tint, { roughness: 0.5, emissive: tint, emissiveIntensity: 0.8 }); + const crack = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.03, 1.1), edgeMat); + crack.position.set((rng.next() - 0.5) * 0.3, FLOOR_Y + 0.03, 0); + crack.rotation.y = (rng.next() - 0.5) * 0.4; + g.add(crack); + // Both materials re-tinted per frame — the facade owns the floor colours. + g.userData.edgeMat = edgeMat; + g.userData.gapMats = { shardMat, crackMat: edgeMat }; + return g; +} + +function patrol(THREE, rng, tint) { + // Watch lantern: post + swinging glow cage. The sway is analytic f(t). + const g = new THREE.Group(); + const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 }); + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.6, 6), iron); + post.position.y = FLOOR_Y + 1.3; + g.add(post); + const cageMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 1.6 }); + const cage = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), cageMat); + cage.position.y = FLOOR_Y + 2.7; + g.add(cage); + const arm = new THREE.Group(); + arm.position.y = FLOOR_Y + 2.7; + const lampMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 1.2 }); + const lamp = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.7, 10, 1, true), lampMat); + lamp.position.y = -0.5; + arm.add(lamp); + g.add(arm); + g.userData.swing = { arm, phase: rng.next() * Math.PI * 2, cageMat, base: 1.6 }; + return g; +} + +export const HAZARD_BUILDERS = { gate, gap, patrol }; + +export const HAZARD_TINTS = { + gate: [0.45, 0.45, 0.48], + gap: [1.0, 0.4, 0.1], + patrol: [0.95, 0.75, 0.4], +}; + +/** + * Per-frame life for map-seated hazards. Positions are fixed (the map seats + * everything once at build); only the portcullis lift, lantern swing and + * ember glow animate, all analytic f(t, heroS) so seeks agree with playback. + * + * Each item carries userData.home = { x, z, yaw } (set by the facade at + * build) and gates additionally carry userData.hazardS (route arclength). + * partyS lists the route positions of the whole procession (hero + trailing + * companions, lead members in first-person) so the portcullis stays up until + * the last walker clears it — never hero-only. + */ +export function updateHazards({ hazards = null, t = 0, light = 1, opacity = 1, heroS = null, partyS = null } = {}) { + const items = hazards?.items ?? (Array.isArray(hazards) ? hazards : []); + for (const item of items) { + item.visible = opacity > 0.001; + if (!item.visible) continue; + const home = item.userData.home; + if (home) { + item.position.set(home.x, 0, home.z); + item.rotation.y = home.yaw || 0; + } + // Portcullis lift: shut at distance, fully raised while ANY walker + // of the procession is under it. The hero leads in first-person and + // trails in planted shots, so the lift keyed on the hero alone drops + // on the trailers (or shuts on the leader). Opens over ~4 units of + // approach on the near side and holds until the last member clears + // the far side + ~2 margin — no bar ever fills the near plane and no + // walker passes through iron. + const lift = item.userData.liftBars; + if (lift && typeof item.userData.hazardS === 'number') { + const members = Array.isArray(partyS) && partyS.length + ? partyS + : (typeof heroS === 'number' ? [heroS] : []); + let liftK = 0; + for (const ms of members) { + if (typeof ms !== 'number') continue; + const d = Math.abs(ms - item.userData.hazardS); + liftK = Math.max(liftK, smooth01((4.5 - d) / 3.7)); + } + lift.position.y = 2.4 * liftK; + } + const sw = item.userData.swing; + if (sw) { + sw.arm.rotation.z = Math.sin(t * 0.8 + sw.phase) * 0.35; + sw.cageMat.emissiveIntensity = sw.base * (0.4 + 0.6 * light) + * (0.85 + 0.15 * Math.sin(t * 3.1 + sw.phase)); + } + const em = item.userData.edgeMat; + if (em) em.emissiveIntensity = 0.8 * (0.4 + 0.6 * light); + } +} + +/** + * Lateral hero nudge so the walk appears to negotiate near hazards. + * Pure f(heroS, sectionPlan, t): hazards sit at fixed s, the hero is compared + * against them directly. Each nearby hazard pushes the hero away with a + * gaussian falloff (sigma 3, amp 0.7); gates push to alternating sides by + * index (through the bars' gaps, never through iron). Clamped to |x| <= 1.0 + * so the hero holds the corridor. + */ +export function avoidanceOffset(heroS = 0, sectionPlan = null, t = 0) { + let list = sectionPlan ?? []; + if (Array.isArray(list) && list.length && Array.isArray(list[0])) list = list.flat(); + if (!Array.isArray(list)) return 0; + let sum = 0; + for (let i = 0; i < list.length; i++) { + const e = list[i] ?? {}; + const raw = (heroS - (e.s ?? 0)) / 3; + const g = Math.exp(-(raw * raw)); + if (g < 0.001) continue; + const dir = (i % 2 === 0 ? 1 : -1); + sum += 0.7 * g * dir; + } + void t; + return Math.max(-1.0, Math.min(1.0, sum)); +} diff --git a/flow-state/src/venues/dungeon/map.js b/flow-state/src/venues/dungeon/map.js new file mode 100644 index 0000000..6fcc190 --- /dev/null +++ b/flow-state/src/venues/dungeon/map.js @@ -0,0 +1,966 @@ +// Dungeon map — a real 2D floor plan first, 3D built from it. +// +// Standard roguelike construction, all seeded (no Math.random, no wall-clock): +// rejection-sampled non-overlapping rectangular rooms, Kruskal MST over room +// centres plus loop edges, L-shaped corridors carved between linked rooms. +// The walk route is a closed travelling tour (greedy nearest-room BFS legs, +// returning to the start), smoothed and resampled to even arclength. +// +// Why grid-first: every wall is derived from cell occupancy (a wall run exists +// exactly where open meets solid), so the shell has no holes by construction; +// the camera only ever stands on open cells (route or room watch cells), so +// it can never be inside a wall; and the route is a real walkable path +// (BFS-verified), not a ribbon bent through blockwork. +// +// Contract: +// genDungeon({ seed, rng }) -> DungeonPlan (pure data, serialisable) +// pathAt(plan, s) -> { x, yaw } (world units, wraps) +// roomAt(plan, s) -> roomId (>=0) | -1 (corridor) +// zoneAt(plan, s) -> zone name +// CELL: world units per cell +// +// DungeonPlan: +// { seed, w, h, grid, rooms, wallRuns, ceilRects, route, items, portal, watch } +// grid: Uint8Array open cells; rooms: [{id,x,y,w,h,cx,cy,zone,indoor}] +// wallRuns: [{x0,z0,x1,z1,h}] world-space merged runs (axis aligned) +// ceilRects: [{x0,z0,x1,z1,y}] world-space ceiling slabs (indoor only) +// route: { pts:[{x,z}], cum:[...], totalLen, roomIds:[], zones:[] } +// items: { props, torches, puddles, windows, gates, gaps, patrols } +// portal: {x,z,yaw,roomId} | null; watch: { roomId: {x,z,lookX,lookZ} } + +import { Rng } from '../../engine/rng.js'; +import { smooth01 } from './shared.js'; + +export const CELL = 2.0; +const GW = 44; +const GH = 44; +const ROOM_TARGET = 8; +const ROOM_MIN = 4; +const ROOM_MAX = 8; + +const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk']; +const zoneIndoor = (z) => z === 'hall' || z === 'room'; + +const idx = (w, x, y) => y * w + x; +const inBounds = (w, h, x, y) => x >= 0 && y >= 0 && x < w && y < h; + +// ------------------------------------------------------------- union-find +// Kruskal MST over room centres: the cheapest standard way to guarantee every +// room joins one connected graph. + +function kruskal(n, edges) { + const parent = []; + for (let i = 0; i < n; i++) parent.push(i); + const find = (a) => (parent[a] === a ? a : (parent[a] = find(parent[a]))); + const mst = []; + const rest = []; + for (const e of edges) { + const ra = find(e.a), rb = find(e.b); + if (ra !== rb) { parent[ra] = rb; mst.push(e); } + else rest.push(e); + } + return { mst, rest }; +} + +// ------------------------------------------------------------------- BFS +// 4-neighbour shortest paths on open cells. Deterministic: fixed neighbour +// order, first-found wins, so the same grid always yields the same paths. + +const NB = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + +function bfsPath(open, w, h, sx, sy, tx, ty) { + if (sx === tx && sy === ty) return [{ x: sx, y: sy }]; + const prev = new Int32Array(w * h).fill(-1); + const qx = [sx], qy = [sy]; + prev[idx(w, sx, sy)] = idx(w, sx, sy); + let head = 0; + while (head < qx.length) { + const cx = qx[head], cy = qy[head]; + head++; + if (cx === tx && cy === ty) break; + for (const [dx, dy] of NB) { + const nx = cx + dx, ny = cy + dy; + if (!inBounds(w, h, nx, ny) || !open[idx(w, nx, ny)]) continue; + if (prev[idx(w, nx, ny)] !== -1) continue; + prev[idx(w, nx, ny)] = idx(w, cx, cy); + qx.push(nx); qy.push(ny); + } + } + if (prev[idx(w, tx, ty)] === -1) return null; + const path = []; + let c = idx(w, tx, ty); + while (c !== idx(w, sx, sy)) { + path.push({ x: c % w, y: Math.floor(c / w) }); + c = prev[c]; + } + path.push({ x: sx, y: sy }); + path.reverse(); + return path; +} + +function bfsDist(open, w, h, sx, sy, tx, ty) { + const p = bfsPath(open, w, h, sx, sy, tx, ty); + return p ? p.length : Infinity; +} + +// ------------------------------------------------------------------ LOS +// Bresenham cell walk between cell centres: every crossed cell must be open. + +function losClear(open, w, h, x0, y0, x1, y1) { + let dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; + let err = dx - dy, x = x0, y = y0; + for (;;) { + if (!inBounds(w, h, x, y) || !open[idx(w, x, y)]) return false; + if (x === x1 && y === y1) return true; + const e2 = 2 * err; + if (e2 > -dy) { err -= dy; x += sx; } + if (e2 < dx) { err += dx; y += sy; } + } +} + +// ------------------------------------------------------------------ plan + +export function genDungeon({ seed = 1, rng = null } = {}) { + const r = rng ? rng.fork('dungeon:map') : new Rng((seed >>> 0) || 1).fork('dungeon:map'); + const sd = (seed >>> 0) || 1; + + // --- rooms: rejection-sampled rects with a 1-cell moat between them, so + // corridors always have somewhere to go and rooms never share walls. + const rooms = []; + for (let attempt = 0; attempt < 240 && rooms.length < ROOM_TARGET; attempt++) { + const w = r.int(ROOM_MIN, ROOM_MAX); + const h = r.int(ROOM_MIN, ROOM_MAX); + const x = r.int(1, GW - w - 1); + const y = r.int(1, GH - h - 1); + let ok = true; + for (const o of rooms) { + if (x < o.x + o.w + 1 && o.x < x + w + 1 && y < o.y + o.h + 1 && o.y < y + h + 1) { + ok = false; break; + } + } + if (ok) rooms.push({ id: rooms.length, x, y, w, h, cx: x + w / 2, cy: y + h / 2 }); + } + // Degenerate seed insurance: a map with fewer than 3 rooms is a hallway + // with extra steps — fall back to three fixed rooms (still seeded zones). + while (rooms.length < 3) { + const i = rooms.length; + rooms.push({ id: i, x: 4 + i * 12, y: 20, w: 6, h: 6, cx: 7 + i * 12, cy: 23 }); + } + + const open = new Uint8Array(GW * GH); + const roomId = new Int16Array(GW * GH).fill(-1); + const carve = (x, y, id) => { + if (!inBounds(GW, GH, x, y)) return; + open[idx(GW, x, y)] = 1; + if (id >= 0) roomId[idx(GW, x, y)] = id; + }; + for (const rm of rooms) { + for (let y = rm.y; y < rm.y + rm.h; y++) { + for (let x = rm.x; x < rm.x + rm.w; x++) carve(x, y, rm.id); + } + } + + // --- links: MST (Kruskal, weight = centre distance + small seeded jitter + // so parallel runs vary) plus up to 2 loop edges — loops give the tour + // alternate ways round, and doorways the camera never takes. + const edges = []; + for (let i = 0; i < rooms.length; i++) { + for (let j = i + 1; j < rooms.length; j++) { + const a = rooms[i], b = rooms[j]; + edges.push({ a: i, b: j, w: Math.hypot(a.cx - b.cx, a.cy - b.cy) }); + } + } + edges.sort((p, q) => p.w - q.w); + const { mst, rest } = kruskal(rooms.length, edges); + const links = mst.slice(); + const rLoops = new Rng((sd ^ 0x10ee) >>> 0 || 1); + const shuffledRest = rest.slice(); + for (let i = shuffledRest.length - 1; i > 0; i--) { + const j = rLoops.int(0, i); + [shuffledRest[i], shuffledRest[j]] = [shuffledRest[j], shuffledRest[i]]; + } + for (const e of shuffledRest.slice(0, 2)) links.push(e); + + // --- corridors: L-shaped 1-cell carves between random cells of the two + // rooms (order seeded). Recorded as rects for exact ceiling cover. + const rCorr = new Rng((sd ^ 0xc091) >>> 0 || 1); + const corrRects = []; + const carveCorr = (x, y) => carve(x, y, -1); + for (const e of links) { + const A = rooms[e.a], B = rooms[e.b]; + const ax = rCorr.int(A.x, A.x + A.w - 1), ay = rCorr.int(A.y, A.y + A.h - 1); + const bx = rCorr.int(B.x, B.x + B.w - 1), by = rCorr.int(B.y, B.y + B.h - 1); + const horizFirst = rCorr.bool(); + const x0 = Math.min(ax, bx), x1 = Math.max(ax, bx); + const y0 = Math.min(ay, by), y1 = Math.max(ay, by); + if (horizFirst) { + for (let x = x0; x <= x1; x++) carveCorr(x, ay); + for (let y = y0; y <= y1; y++) carveCorr(bx, y); + corrRects.push({ x: x0, y: ay, w: x1 - x0 + 1, h: 1, link: e }); + corrRects.push({ x: bx, y: y0, w: 1, h: y1 - y0 + 1, link: e }); + } else { + for (let y = y0; y <= y1; y++) carveCorr(ax, y); + for (let x = x0; x <= x1; x++) carveCorr(x, by); + corrRects.push({ x: ax, y: y0, w: 1, h: y1 - y0 + 1, link: e }); + corrRects.push({ x: x0, y: by, w: x1 - x0 + 1, h: 1, link: e }); + } + } + + // --- zones: dealt round-robin off a seeded shuffle, so every map with + // 4+ rooms holds halls, rooms, forest and castle walls alike. + const rZones = new Rng((sd ^ 0x20e5) >>> 0 || 1); + const order = rooms.slice(); + for (let i = order.length - 1; i > 0; i--) { + const j = rZones.int(0, i); + [order[i], order[j]] = [order[j], order[i]]; + } + order.forEach((rm, i) => { + rm.zone = ZONE_NAMES[i % ZONE_NAMES.length]; + rm.indoor = zoneIndoor(rm.zone); + }); + const zoneOf = new Array(GW * GH).fill('hall'); + const indoorOf = new Uint8Array(GW * GH); + for (const rm of rooms) { + for (let y = rm.y; y < rm.y + rm.h; y++) { + for (let x = rm.x; x < rm.x + rm.w; x++) { + zoneOf[idx(GW, x, y)] = rm.zone; + indoorOf[idx(GW, x, y)] = rm.indoor ? 1 : 0; + } + } + } + for (const cr of corrRects) { + const A = rooms[cr.link.a], B = rooms[cr.link.b]; + const indoor = (A.indoor && B.indoor) ? 1 : 0; + const zone = indoor ? 'hall' : (A.indoor ? B.zone : A.zone); + const x1 = cr.x + cr.w - 1, y1 = cr.y + cr.h - 1; + for (let y = cr.y; y <= y1; y++) { + for (let x = cr.x; x <= x1; x++) { + if (!inBounds(GW, GH, x, y)) continue; + if (roomId[idx(GW, x, y)] === -1) { + zoneOf[idx(GW, x, y)] = zone; + indoorOf[idx(GW, x, y)] = indoor; + } + } + } + cr.indoor = indoor; + cr.zone = zone; + } + + // --- route: closed travelling tour — start at room 0, repeatedly walk to + // the nearest unvisited room (BFS distance), then leg home. Closed by + // construction; every leg BFS-verified walkable. + const roomCenterCell = (rm) => ({ + x: Math.max(rm.x, Math.min(rm.x + rm.w - 1, Math.round(rm.cx))), + y: Math.max(rm.y, Math.min(rm.y + rm.h - 1, Math.round(rm.cy))), + }); + const routeCells = []; + { + const start = roomCenterCell(rooms[0]); + let cur = start; + routeCells.push({ ...cur }); + const unvisited = new Set(rooms.map((rm) => rm.id)); + unvisited.delete(rooms[0].id); + while (unvisited.size) { + let best = null, bestD = Infinity; + for (const id of unvisited) { + const c = roomCenterCell(rooms[id]); + const d = bfsDist(open, GW, GH, cur.x, cur.y, c.x, c.y); + if (d < bestD) { bestD = d; best = { id, c }; } + } + const leg = bfsPath(open, GW, GH, cur.x, cur.y, best.c.x, best.c.y) || [cur]; + for (let k = 1; k < leg.length; k++) routeCells.push(leg[k]); + cur = best.c; + unvisited.delete(best.id); + } + const home = bfsPath(open, GW, GH, cur.x, cur.y, start.x, start.y) || []; + for (let k = 1; k < home.length; k++) routeCells.push(home[k]); + } + + // --- smooth: closed Chaikin ×2 keeps corners inside the carved cells + // (cuts stay near the polyline; corridors are a full cell wide), then + // even 0.5-unit resampling with per-sample room/zone tags. + const toWorld = (cx, cy) => ({ + x: (cx - GW / 2 + 0.5) * CELL, + z: (cy - GH / 2 + 0.5) * CELL, + }); + let poly = routeCells.map((c) => toWorld(c.x, c.y)); + for (let it = 0; it < 2; it++) { + const out = []; + for (let i = 0; i < poly.length; i++) { + const p = poly[i], q = poly[(i + 1) % poly.length]; + out.push( + { x: p.x * 0.75 + q.x * 0.25, z: p.z * 0.75 + q.z * 0.25 }, + { x: p.x * 0.25 + q.x * 0.75, z: p.z * 0.25 + q.z * 0.75 }, + ); + } + poly = out; + } + const pts = []; + { + // Even resample: walk the closed polyline in 0.5-unit steps. + const segLens = []; + let total = 0; + for (let i = 0; i < poly.length; i++) { + const p = poly[i], q = poly[(i + 1) % poly.length]; + const l = Math.hypot(q.x - p.x, q.z - p.z); + segLens.push(l); + total += l; + } + const STEP = 0.5; + const n = Math.max(8, Math.round(total / STEP)); + let si = 0, acc = 0; + for (let k = 0; k < n; k++) { + const target = (k / n) * total; + while (acc + segLens[si] < target && si < poly.length - 1) { + acc += segLens[si]; + si++; + } + const p = poly[si], q = poly[(si + 1) % poly.length]; + const f = segLens[si] > 1e-9 ? (target - acc) / segLens[si] : 0; + pts.push({ + x: p.x + (q.x - p.x) * f, + z: p.z + (q.z - p.z) * f, + }); + } + } + const cum = [0]; + for (let i = 1; i <= pts.length; i++) { + const p = pts[i - 1], q = pts[i % pts.length]; + cum.push(cum[i - 1] + Math.hypot(q.x - p.x, q.z - p.z)); + } + const totalLen = cum[pts.length]; + // Per-sample tags from the containing cell. + const cellOfPt = (p) => ({ + x: Math.floor(p.x / CELL + GW / 2), + z: Math.floor(p.z / CELL + GH / 2), + }); + const sampleRoom = pts.map((p) => { + const c = cellOfPt(p); + return inBounds(GW, GH, c.x, c.z) ? roomId[idx(GW, c.x, c.z)] : -1; + }); + const sampleZone = pts.map((p) => { + const c = cellOfPt(p); + return inBounds(GW, GH, c.x, c.z) ? zoneOf[idx(GW, c.x, c.z)] : 'hall'; + }); + + // --- walls: one run everywhere INDOOR open meets solid, merged along + // each axis. No holes possible: every indoor open edge is either + // open-neighboured or walled. Wall tops match the ceiling slabs exactly + // (per-zone heights — see ceilRects below), so walls always meet roofs. + // Outdoor edges grow vegetation instead of half-walls: the outside stays + // outside, lined with planting rather than masonry. + const wallRuns = []; + const vegCells = new Map(); + const noteVeg = (x, y, zone) => { + if (!inBounds(GW, GH, x, y) || open[idx(GW, x, y)]) return; + const k = idx(GW, x, y); + if (!vegCells.has(k)) vegCells.set(k, { x, y, zone }); + }; + { + // Wall height follows the ceiling over the cell: 'room' zones stand + // 4.2, halls and indoor corridors 3.2. + const wallH = (x, y) => (zoneOf[idx(GW, x, y)] === 'room' ? 4.2 : 3.2); + const hEdges = []; // {z, x0, x1, h} + const vEdges = []; // {x, z0, z1, h} + for (let y = 0; y < GH; y++) { + for (let x = 0; x < GW; x++) { + if (!open[idx(GW, x, y)]) continue; + const w0 = toWorld(x, y); + if (!indoorOf[idx(GW, x, y)]) { + // Outside: plant the solid neighbours, wall nothing. + const z = zoneOf[idx(GW, x, y)]; + noteVeg(x, y - 1, z); noteVeg(x, y + 1, z); + noteVeg(x - 1, y, z); noteVeg(x + 1, y, z); + continue; + } + const h = wallH(x, y); + if (!inBounds(GW, GH, x, y - 1) || !open[idx(GW, x, y - 1)]) { + hEdges.push({ z: w0.z - CELL / 2, x0: w0.x - CELL / 2, x1: w0.x + CELL / 2, h }); + } + if (!inBounds(GW, GH, x, y + 1) || !open[idx(GW, x, y + 1)]) { + hEdges.push({ z: w0.z + CELL / 2, x0: w0.x - CELL / 2, x1: w0.x + CELL / 2, h }); + } + if (!inBounds(GW, GH, x - 1, y) || !open[idx(GW, x - 1, y)]) { + vEdges.push({ x: w0.x - CELL / 2, z0: w0.z - CELL / 2, z1: w0.z + CELL / 2, h }); + } + if (!inBounds(GW, GH, x + 1, y) || !open[idx(GW, x + 1, y)]) { + vEdges.push({ x: w0.x + CELL / 2, z0: w0.z - CELL / 2, z1: w0.z + CELL / 2, h }); + } + } + } + const merge = (edges, fixed, lo, hi) => { + edges.sort((a, b) => a[fixed] - b[fixed] || a[lo] - b[lo]); + let cur = null; + const runs = []; + for (const e of edges) { + if (cur && e[fixed] === cur[fixed] && e.h === cur.h && e[lo] <= cur[hi] + 1e-6) { + cur[hi] = Math.max(cur[hi], e[hi]); + } else { + if (cur) runs.push(cur); + cur = { [fixed]: e[fixed], [lo]: e[lo], [hi]: e[hi], h: e.h }; + } + } + if (cur) runs.push(cur); + return runs; + }; + for (const e of merge(hEdges, 'z', 'x0', 'x1')) { + wallRuns.push({ x0: e.x0, z0: e.z, x1: e.x1, z1: e.z, h: e.h }); + } + for (const e of merge(vEdges, 'x', 'z0', 'z1')) { + wallRuns.push({ x0: e.x, z0: e.z0, x1: e.x, z1: e.z1, h: e.h }); + } + } + + // --- ceilings: exact slabs over indoor rooms + indoor corridor rects. + const ceilRects = []; + { + const rect = (x, y, w, h, top) => { + const a = toWorld(x, y), b = toWorld(x + w - 1, y + h - 1); + ceilRects.push({ + x0: a.x - CELL / 2, z0: a.z - CELL / 2, + x1: b.x + CELL / 2, z1: b.z + CELL / 2, y: top, + }); + }; + for (const rm of rooms) { + if (rm.indoor) rect(rm.x, rm.y, rm.w, rm.h, -1.15 + (rm.zone === 'room' ? 4.2 : 3.2)); + } + for (const cr of corrRects) { + if (cr.indoor) rect(cr.x, cr.y, cr.w, cr.h, -1.15 + 3.2); + } + } + // --- route helpers over samples. + const routeCellsSet = new Set(routeCells.map((c) => idx(GW, c.x, c.y))); + const distToRoute = (cx, cy) => { + let best = Infinity; + for (const k of routeCellsSet) { + const dx = (k % GW) - cx, dy = Math.floor(k / GW) - cy; + best = Math.min(best, Math.abs(dx) + Math.abs(dy)); + } + return best; + }; + + // --- watch cells: per room, the open cell farthest from the walked line + // that still sees it — the camera's chair, out of the walkway, eyes on + // the action. Plus a fallback look point on the route. + const watch = {}; + for (const rm of rooms) { + const inRoomRoute = []; + for (let y = rm.y; y < rm.y + rm.h; y++) { + for (let x = rm.x; x < rm.x + rm.w; x++) { + if (routeCellsSet.has(idx(GW, x, y))) inRoomRoute.push({ x, y }); + } + } + if (!inRoomRoute.length) continue; + let bestCell = null, bestD = -1; + for (let y = rm.y; y < rm.y + rm.h; y++) { + for (let x = rm.x; x < rm.x + rm.w; x++) { + const d = distToRoute(x, y); + if (d < 2) continue; + const sees = inRoomRoute.some((c) => losClear(open, GW, GH, x, y, c.x, c.y)); + if (!sees) continue; + if (d > bestD) { bestD = d; bestCell = { x, y }; } + } + } + if (!bestCell) continue; + let lookCell = inRoomRoute[0], lookD = Infinity; + for (const c of inRoomRoute) { + if (!losClear(open, GW, GH, bestCell.x, bestCell.y, c.x, c.y)) continue; + const d = Math.hypot(c.x - rm.cx, c.y - rm.cy); + if (d < lookD) { lookD = d; lookCell = c; } + } + const wp = toWorld(bestCell.x, bestCell.y); + const lp = toWorld(lookCell.x, lookCell.y); + watch[rm.id] = { x: wp.x, z: wp.z, lookX: lp.x, lookZ: lp.z }; + } + + // --- items. + const rItems = new Rng((sd ^ 0x171e5) >>> 0 || 1); + const items = { props: [], torches: [], puddles: [], windows: [], gates: [], gaps: [], patrols: [], vegetation: [] }; + // Boundary planting: bushes, trees and boulders where an outdoor wall + // would stand — forest edges grow green, castle-wall edges pile rock. + // Kind and jitter hash from the cell — pure f(cell), no stream. + for (const { x, y, zone } of vegCells.values()) { + const hsh = ((x * 73856093) ^ (y * 19349663)) >>> 0; + const p = toWorld(x, y); + // Unsigned shifts throughout: signed >> would sign-extend the hash + // and throw jitter and scale out of range on high-bit cells. + const jx = (((hsh >>> 0) % 100) / 100 - 0.5) * 0.8; + const jz = (((hsh >>> 8) % 100) / 100 - 0.5) * 0.8; + const rocky = zone === 'wallwalk'; + const kind = rocky + ? (hsh % 3 === 2 ? 'bush' : 'boulder') + : (hsh % 3 === 0 ? 'tree' : 'bush'); + items.vegetation.push({ + x: p.x + jx, z: p.z + jz, + kind, + s: 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6, + }); + } + // Portal first: largest indoor room, the cell farthest off-route. Props + // placed afterwards keep one cell of clearance round it. + let portal = null; + let portalCell = null; + { + const indoor = rooms.filter((rm) => rm.indoor).sort((a, b) => (b.w * b.h) - (a.w * a.h)); + const host = indoor[0] || rooms.slice().sort((a, b) => (b.w * b.h) - (a.w * a.h))[0]; + let bc = null, bd = -1; + for (let y = host.y; y < host.y + host.h; y++) { + for (let x = host.x; x < host.x + host.w; x++) { + const d = distToRoute(x, y); + if (d > bd) { bd = d; bc = { x, y }; } + } + } + if (bc && bd >= 1) { + portalCell = bc; + const p = toWorld(bc.x, bc.y); + // Face the nearest route cell. + let nc = null, nd = Infinity; + for (const k of routeCellsSet) { + const d = Math.hypot((k % GW) - bc.x, Math.floor(k / GW) - bc.y); + if (d < nd) { nd = d; nc = k; } + } + const np = nc !== null ? toWorld(nc % GW, Math.floor(nc / GW)) : { x: p.x, z: p.z - 1 }; + portal = { + x: p.x, z: p.z, + yaw: Math.atan2(np.x - p.x, np.z - p.z), + roomId: host.id, + }; + } + } + // Props: 2-4 per room, off the walked line, clear of the portal. + for (const rm of rooms) { + const n = rItems.int(2, 4); + let placed = 0, guard = 0; + while (placed < n && guard++ < 60) { + const x = rItems.int(rm.x, rm.x + rm.w - 1); + const y = rItems.int(rm.y, rm.y + rm.h - 1); + if (distToRoute(x, y) < 2) continue; + if (portalCell && Math.abs(portalCell.x - x) + Math.abs(portalCell.y - y) < 2) continue; + const p = toWorld(x, y); + const type = rm.indoor + ? rItems.pick(['rubble', 'furniture', 'rubble', 'banner', 'stalagmite']) + : rItems.pick(['trees', 'rubble', 'rampart', 'trees']); + items.props.push({ x: p.x, z: p.z, yaw: rItems.next() * Math.PI * 2, type }); + placed++; + } + } + // Route furniture: torches on walls, slits, puddles, gates, gaps, patrols. + const stepOf = (du) => Math.max(1, Math.round(du / 0.5)); + { + // Torches every ~7 units on indoor walls — sconces belong on stone, + // not on open ground (patrol lanterns light the outside instead). + // Few and steady: a torch every few steps reads as clutter. + for (let i = 0; i < pts.length && items.torches.length < 18; i += stepOf(7)) { + const p = pts[i], q = pts[(i + 1) % pts.length]; + const rcx = Math.floor(p.x / CELL + GW / 2); + const rcy = Math.floor(p.z / CELL + GH / 2); + if (!inBounds(GW, GH, rcx, rcy) || !indoorOf[idx(GW, rcx, rcy)]) continue; + const dx = q.x - p.x, dz = q.z - p.z; + const l = Math.hypot(dx, dz) || 1; + const px = -dz / l, pz = dx / l; + for (const side of [1, -1]) { + const cx = Math.floor((p.x + px * side * 1.2) / CELL + GW / 2); + const cy = Math.floor((p.z + pz * side * 1.2) / CELL + GH / 2); + if (!inBounds(GW, GH, cx, cy) || open[idx(GW, cx, cy)]) continue; + items.torches.push({ + x: p.x + px * side * 0.9, z: p.z + pz * side * 0.9, + yaw: Math.atan2(-px * side, -pz * side), + post: false, + }); + break; + } + } + // Window slits every ~6 units on indoor stretches with a high wall + // beside the route. Outdoor slits would float above the low parapet + // walls, so indoor-only (ceilings imply walls tall enough to slit). + for (let i = 2; i < pts.length && items.windows.length < 20; i += stepOf(6)) { + const p = pts[i], q = pts[(i + 1) % pts.length]; + const dx = q.x - p.x, dz = q.z - p.z; + const l = Math.hypot(dx, dz) || 1; + const px = -dz / l, pz = dx / l; + const cx = Math.floor((p.x + px * 1.2) / CELL + GW / 2); + const cy = Math.floor((p.z + pz * 1.2) / CELL + GH / 2); + if (!inBounds(GW, GH, cx, cy) || open[idx(GW, cx, cy)]) continue; + const rcx = Math.floor(p.x / CELL + GW / 2); + const rcy = Math.floor(p.z / CELL + GH / 2); + if (!inBounds(GW, GH, rcx, rcy) || !indoorOf[idx(GW, rcx, rcy)]) continue; + items.windows.push({ + x: p.x + px * 0.95, z: p.z + pz * 0.95, + yaw: Math.atan2(-px, -pz), + }); + } + // Puddles: 4 spread round the loop, slightly off the walked line. + for (let k = 0; k < 4; k++) { + const i = Math.floor((k + 0.5) / 4 * pts.length) % pts.length; + const p = pts[i], q = pts[(i + 1) % pts.length]; + const dx = q.x - p.x, dz = q.z - p.z; + const l = Math.hypot(dx, dz) || 1; + items.puddles.push({ x: p.x + (-dz / l) * 0.5, z: p.z + (dx / l) * 0.5, r: 0.7 + rItems.next() * 0.4 }); + } + // Gates where the route enters rooms (roomId flips corridor→room). + for (let i = 0; i < pts.length && items.gates.length < 4; i++) { + const prev = sampleRoom[(i + pts.length - 1) % pts.length]; + const cur = sampleRoom[i]; + if (prev !== -1 || cur === -1) continue; + const s = cum[i]; + if (items.gates.length && s - items.gates[items.gates.length - 1].s < 12) continue; + const p = pts[i], q = pts[(i + 1) % pts.length]; + items.gates.push({ x: p.x, z: p.z, yaw: Math.atan2(q.x - p.x, q.z - p.z), s }); + } + // Gaps spread through rooms, off the gates. + for (let i = 0; i < pts.length && items.gaps.length < 2; i += stepOf(9)) { + if (sampleRoom[i] === -1) continue; + const s = cum[i]; + if (items.gates.some((g) => Math.abs(s - g.s) < 6)) continue; + const p = pts[i], q = pts[(i + 1) % pts.length]; + items.gaps.push({ x: p.x, z: p.z, yaw: Math.atan2(q.x - p.x, q.z - p.z), s }); + } + // Patrol lanterns stand along the open ground every ~25 units — they + // are the outdoor light rhythm now that torches stay on stone. + for (let i = 0; i < pts.length && items.patrols.length < 6; i += stepOf(25)) { + const cx = Math.floor(pts[i].x / CELL + GW / 2); + const cy = Math.floor(pts[i].z / CELL + GH / 2); + if (!inBounds(GW, GH, cx, cy) || indoorOf[idx(GW, cx, cy)]) continue; + const s = cum[i]; + if (items.patrols.length && s - items.patrols[items.patrols.length - 1].s < 18) continue; + const p = pts[i], q = pts[(i + 1) % pts.length]; + // Stand beside the route, never in stone: try both shoulders, + // fall back to the walked line itself. + const dx = q.x - p.x, dz = q.z - p.z; + const l = Math.hypot(dx, dz) || 1; + let px = p.x, pz = p.z; + for (const side of [1.2, -1.2, 0]) { + const tx = p.x + (-dz / l) * side, tz = p.z + (dx / l) * side; + const tcx = Math.floor(tx / CELL + GW / 2), tcy = Math.floor(tz / CELL + GH / 2); + if (inBounds(GW, GH, tcx, tcy) && open[idx(GW, tcx, tcy)]) { + px = tx; pz = tz; break; + } + } + items.patrols.push({ x: px, z: pz, yaw: Math.atan2(q.x - p.x, q.z - p.z), s }); + } + + // --- cast: entities that live on the map, loot worth stopping for, + // and the halts where the walk breaks step. All analytic f(t): entity + // poses are pure functions of time (see entityPose), halts bend the + // odometer without ever reversing it (see odometer). + items.entities = []; + items.loot = []; + items.halts = []; + // Nearest route sample (cell, arclength, index) for any cell. + const cellRouteS = new Map(); + pts.forEach((p, i) => { + const cx = Math.floor(p.x / CELL + GW / 2); + const cy = Math.floor(p.z / CELL + GH / 2); + const k = cy * GW + cx; + if (inBounds(GW, GH, cx, cy) && !cellRouteS.has(k)) { + cellRouteS.set(k, { s: cum[i], i }); + } + }); + const nearRoute = (cx, cy) => { + let best = null, bd = Infinity; + for (const [k, v] of cellRouteS) { + const dx = (k % GW) - cx, dy = Math.floor(k / GW) - cy; + const d = Math.abs(dx) + Math.abs(dy); + if (d < bd) { bd = d; best = v; } + } + return best ? { d: bd, ...best } : null; + }; + const routeTangent = (i) => { + const p = pts[i % pts.length], q = pts[(i + 1) % pts.length]; + const l = Math.hypot(q.x - p.x, q.z - p.z) || 1; + return { x: (q.x - p.x) / l, z: (q.z - p.z) / l }; + }; + const takenS = []; + const clearOf = (s, du) => takenS.every((o) => { + const L = totalLen; + const d = Math.abs(s - o) % L; + return Math.min(d, L - d) >= du; + }); + // Lurkers pace across the walked line, menacing and sidestepped. + { + const cands = []; + for (let y = 0; y < GH && cands.length < 400; y++) { + for (let x = 0; x < GW; x++) { + if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue; + const nr = nearRoute(x, y); + if (!nr || nr.d !== 1) continue; + cands.push({ x, y, ...nr }); + } + } + const order = rItems.shuffle(cands); + for (const c of order) { + if (items.entities.filter((e) => e.kind === 'lurker').length >= 2) break; + if (!clearOf(c.s, 25)) continue; + const p = toWorld(c.x, c.y); + const tn = routeTangent(c.i); + items.entities.push({ + kind: 'lurker', x: p.x, z: p.z, + yaw: Math.atan2(-tn.z, tn.x), + s: c.s, roomId: roomId[idx(GW, c.x, c.y)], + ax: -tn.z, az: tn.x, range: 1.2, speed: 0.45, + phase: rItems.next() * 2, + }); + takenS.push(c.s); + } + } + // A sentry stands the first gate; without a gate, a third lurker. + if (items.gates.length) { + const g = items.gates[0]; + // Shoulder of the gate: perpendicular of the route heading. + const px = Math.cos(g.yaw), pz = -Math.sin(g.yaw); + let sx = g.x + px * 1.5, sz = g.z + pz * 1.5, placed = false; + for (const side of [1, -1]) { + const tx = g.x + px * 1.5 * side, tz = g.z + pz * 1.5 * side; + const tcx = Math.floor(tx / CELL + GW / 2), tcy = Math.floor(tz / CELL + GH / 2); + if (inBounds(GW, GH, tcx, tcy) && open[idx(GW, tcx, tcy)]) { + sx = tx; sz = tz; placed = true; break; + } + } + if (placed) { + items.entities.push({ + kind: 'sentry', x: sx, z: sz, + yaw: g.yaw + Math.PI, + s: g.s, roomId: -1, + ax: 0, az: 0, range: 0, speed: 0, phase: 0, + }); + takenS.push(g.s); + } + } + // Critters dart between hideouts; a wisp drifts for atmosphere. + { + const cands = []; + for (let y = 0; y < GH && cands.length < 400; y++) { + for (let x = 0; x < GW; x++) { + if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue; + const nr = nearRoute(x, y); + if (!nr || nr.d < 2 || nr.d > 3) continue; + cands.push({ x, y, ...nr }); + } + } + const order = rItems.shuffle(cands); + for (const c of order) { + if (items.entities.filter((e) => e.kind === 'critter').length >= 2) break; + if (!clearOf(c.s, 20)) continue; + const p = toWorld(c.x, c.y); + const tn = routeTangent(c.i); + items.entities.push({ + kind: 'critter', x: p.x, z: p.z, + yaw: Math.atan2(tn.x, tn.z), + s: c.s, roomId: roomId[idx(GW, c.x, c.y)], + ax: tn.x, az: tn.z, range: 2.0, speed: 1.4, + phase: rItems.next() * 2, + }); + takenS.push(c.s); + } + const wcands = []; + for (let y = 0; y < GH && wcands.length < 200; y++) { + for (let x = 0; x < GW; x++) { + if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue; + const nr = nearRoute(x, y); + if (!nr || nr.d < 2) continue; + wcands.push({ x, y, ...nr }); + } + } + if (wcands.length) { + const c = rItems.pick(wcands); + const p = toWorld(c.x, c.y); + items.entities.push({ + kind: 'wisp', x: p.x, z: p.z, yaw: 0, + s: c.s, roomId: roomId[idx(GW, c.x, c.y)], + ax: 0, az: 0, range: 1.0, speed: 0.5, + phase: rItems.next() * 2, + }); + } + } + // Loot worth stopping for: room cells a step off the walked line, + // spaced round the loop. Every cache earns its halt below. + { + const cands = []; + for (let y = 0; y < GH && cands.length < 400; y++) { + for (let x = 0; x < GW; x++) { + if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue; + const nr = nearRoute(x, y); + if (!nr || nr.d < 1 || nr.d > 2) continue; + if (portalCell && Math.abs(portalCell.x - x) + Math.abs(portalCell.y - y) < 2) continue; + cands.push({ x, y, ...nr }); + } + } + const kinds = rItems.shuffle(['cache', 'relic', 'hoard']); + const order = rItems.shuffle(cands); + for (const c of order) { + if (items.loot.length >= 3) break; + if (!clearOf(c.s, 30)) continue; + const p = toWorld(c.x, c.y); + // Face the nearest route point. + const rp = pts[c.i % pts.length]; + items.loot.push({ + kind: kinds[items.loot.length % kinds.length], + x: p.x, z: p.z, + yaw: Math.atan2(rp.x - p.x, rp.z - p.z), + s: c.s, roomId: roomId[idx(GW, c.x, c.y)], + }); + takenS.push(c.s); + } + // Halts: one pause per cache, spaced by construction (loot holds + // 30u apart, pauses need 15) so pauses never overlap — the + // odometer can hold or advance, never retreat. + for (const l of items.loot) { + items.halts.push({ s: l.s, dur: 2.2 }); + } + } + } + + return { + seed: sd, w: GW, h: GH, cell: CELL, grid: open, indoor: indoorOf, + rooms: rooms.map((rm) => ({ id: rm.id, x: rm.x, y: rm.y, w: rm.w, h: rm.h, zone: rm.zone, indoor: rm.indoor })), + wallRuns, ceilRects, + route: { pts, cum, totalLen, roomIds: sampleRoom, zones: sampleZone }, + items, portal, watch, + }; +} + +// ------------------------------------------------------------- queries + +function locate(plan, s) { + const { pts, cum } = plan.route; + const L = plan.route.totalLen; + const q = ((s % L) + L) % L; + let lo = 0, hi = cum.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (cum[mid] <= q) lo = mid; + else hi = mid; + } + const i = lo % pts.length; + const p = pts[i], nq = pts[(i + 1) % pts.length]; + const span = cum[lo + 1] - cum[lo] || 1; + const f = Math.max(0, Math.min(1, (q - cum[lo]) / span)); + return { + x: p.x + (nq.x - p.x) * f, + z: p.z + (nq.z - p.z) * f, + yaw: Math.atan2(nq.x - p.x, nq.z - p.z), + index: i, + }; +} + +/** World position + heading at arclength s (wraps). Pure, no state. */ +export function pathAt(plan, s) { + const here = locate(plan, s); + const ahead = locate(plan, s + 0.75); + return { + x: here.x, + z: here.z, + yaw: Math.atan2(ahead.x - here.x, ahead.z - here.z), + }; +} + +/** Room id at arclength s, or -1 in a corridor. */ +export function roomAt(plan, s) { + return plan.route.roomIds[locate(plan, s).index]; +} + +/** Zone name at arclength s. */ +export function zoneAt(plan, s) { + return plan.route.zones[locate(plan, s).index]; +} + +/** + * The interrupted odometer: a constant-gait walk that pauses at halts. + * + * heroS(t) = cruise·(t − paused(t)), paused(t) = Σ clamp(t − s_h/cruise, 0, dur_h). + * Each halt freezes the walk for dur seconds at exactly its arclength, then + * resumes — velocity is piecewise {0, cruise}, so the walk can hold or + * advance but never retreat, whatever the params. Pure f(t): seeks agree + * with playback. Halts are spaced ≥15u apart at plan time (see items.halts), + * so pauses never overlap and the bound holds a priori. + */ +export function odometer(cruise, halts, L, t) { + const c = Math.max(0.05, cruise); + let paused = 0; + for (const h of halts || []) { + paused += Math.max(0, Math.min(t - h.s / c, h.dur)); + } + return (((c * (t - paused)) % L) + L) % L; +} + +function wrapDist(a, b, L) { + const d = Math.abs(a - b) % L; + return Math.min(d, L - d); +} + +function tri01(x) { + const u = ((x % 2) + 2) % 2; + return u < 1 ? u : 2 - u; +} + +function triSign(x) { + const u = ((x % 2) + 2) % 2; + return u < 1 ? 1 : -1; +} + +function yawLerp(a, b, k) { + // Shortest-arc blend of two headings. + const sa = Math.sin(a), ca = Math.cos(a); + const sb = Math.sin(b), cb = Math.cos(b); + return Math.atan2(sa + (sb - sa) * k, ca + (cb - ca) * k); +} + +/** + * Where an entity is and how alarmed, at time t with the hero at (heroS, + * heroX, heroZ). Pure f(...) — analytic triangle gaits, gaussian proximity, + * no integration, so every frame renders identically from any seek. + * + * Returns { x, z, yaw, alert } with alert ∈ [0, 1]: 1 while the hero is on + * top of it, 0 beyond 8 units of route behind/ahead. + */ +export function entityPose(e, t, heroS, heroX, heroZ, L) { + const prox = (e.s === undefined || e.s === null || L <= 0) + ? 0 + : smooth01(1 - wrapDist(heroS, e.s, L) / 8); + const faceHero = Math.atan2(heroX - e.x, heroZ - e.z); + if (e.kind === 'lurker') { + const ph = t * e.speed + e.phase; + const u = tri01(ph) * 2 - 1; + const paceYaw = Math.atan2(e.ax * triSign(ph), e.az * triSign(ph)); + const amp = e.range * (1 - prox * 0.6); + return { + x: e.x + e.ax * u * amp, + z: e.z + e.az * u * amp, + yaw: yawLerp(paceYaw, faceHero, prox), + alert: prox, + }; + } + if (e.kind === 'sentry') { + return { x: e.x, z: e.z, yaw: yawLerp(e.yaw, faceHero, prox), alert: prox }; + } + if (e.kind === 'critter') { + const ph = t * e.speed + e.phase; + const u = tri01(ph) * 2 - 1; + let fx = e.x + e.ax * u * e.range; + let fz = e.z + e.az * u * e.range; + // Flees the hero's lantern: offset away, scaled by proximity. + const dx = fx - heroX, dz = fz - heroZ; + const dl = Math.hypot(dx, dz); + const flee = prox * 1.5; + if (dl > 1e-4) { fx += (dx / dl) * flee; fz += (dz / dl) * flee; } + else { fx += e.az * flee; fz += -e.ax * flee; } + return { + x: fx, z: fz, + yaw: Math.atan2(e.ax * triSign(ph), e.az * triSign(ph)), + alert: prox, + }; + } + // wisp: slow orbit that leans toward a nearby hero. + const a = t * e.speed + e.phase; + const r = e.range * (1 - prox * 0.4); + let wx = e.x + Math.cos(a) * r; + let wz = e.z + Math.sin(a * 0.9) * r; + if (prox > 0) { + const dx = heroX - e.x, dz = heroZ - e.z; + const dl = Math.hypot(dx, dz) || 1; + wx += (dx / dl) * prox * 1.2; + wz += (dz / dl) * prox * 1.2; + } + return { x: wx, z: wz, yaw: 0, alert: prox }; +} diff --git a/flow-state/src/venues/dungeon/props.js b/flow-state/src/venues/dungeon/props.js new file mode 100644 index 0000000..3639c19 --- /dev/null +++ b/flow-state/src/venues/dungeon/props.js @@ -0,0 +1,266 @@ +// Props submodule — library of dungeon dressing + the props the walk meets. +// +// Pure + headless-safe: no renderer, no wall-clock, no Math.random — seeded Rng +// only, all motion analytic f(t) so seek === playback and preview === export. +// Type/side are discrete plan-time choices; footprints stay small so a prop +// never swallows the path. The world is STATIC (the camera walks, unlike the +// ride where the world streams), so props are seated once per section plan and +// only their glow animates per frame. +// +// Contract (exact export names/signatures): +// PROP_BUILDERS: registry object { rubble, furniture, trees, rampart, torch, banner, stalagmite } +// planProps({ seed, rng, sections, span }) -> per-section [{s, side, type}] +// planPortal({ seed, rng, span }) -> { s, side } +// buildProps({ THREE, sectionPlan, palette, seed }) -> { group, items } +// updateProps({ props, sectionPlan, t, light, popMul, opacity, palette, pathPlan }) -> void +// buildPortal({ THREE, palette }) -> { group, discMat } +// PORTAL_RING_R: portal ring radius (facade frames the shot around it) + + +const FLOOR_Y = -1.15; + +export const PORTAL_RING_R = 1.3; + +function wrapS(s, span) { + return (((s % span) + span) % span); +} + +function std(THREE, color, opts = {}) { + return new THREE.MeshStandardMaterial({ + color: new THREE.Color(color[0], color[1], color[2]), + roughness: opts.roughness ?? 0.7, + metalness: opts.metalness ?? 0.08, + emissive: new THREE.Color( + (opts.emissive ?? [0, 0, 0])[0], + (opts.emissive ?? [0, 0, 0])[1], + (opts.emissive ?? [0, 0, 0])[2] + ), + emissiveIntensity: opts.emissiveIntensity ?? 1, + }); +} + +function stoneMat(THREE, tint) { + return std(THREE, tint, { roughness: 0.9, metalness: 0.02, emissive: tint, emissiveIntensity: 0.12 }); +} + +function box(THREE, g, w, h, d, material, x, y, z, ry = 0) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material); + m.position.set(x, y, z); + if (ry) m.rotation.y = ry; + g.add(m); + return m; +} + +function rubble(THREE, rng, tint) { + const g = new THREE.Group(); + const mat = stoneMat(THREE, tint); + const n = 2 + Math.floor(rng.next() * 3); + for (let k = 0; k < n; k++) { + const r = 0.25 + rng.next() * 0.45; + const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), mat); + rock.position.set((rng.next() - 0.5) * 1.6, FLOOR_Y + r * 0.4, (rng.next() - 0.5) * 1.6); + rock.scale.y = 0.7; + rock.rotation.set(rng.next() * 3, rng.next() * 3, rng.next() * 3); + g.add(rock); + } + return g; +} + +function furniture(THREE, rng, tint) { + // A broken table: top slab + legs, one leg short so it tilts. + const g = new THREE.Group(); + const mat = stoneMat(THREE, tint); + const top = box(THREE, g, 1.4, 0.09, 0.9, mat, 0, FLOOR_Y + 0.72, 0); + top.rotation.z = 0.06; + for (const [sx, sz, h] of [[-0.6, -0.35, 0.72], [0.6, -0.35, 0.72], [-0.6, 0.35, 0.72], [0.6, 0.35, 0.5]]) { + box(THREE, g, 0.08, h, 0.08, mat, sx, FLOOR_Y + h / 2, sz); + } + return g; +} + +function trees(THREE, rng, tint) { + const g = new THREE.Group(); + const bark = stoneMat(THREE, tint); + const leaf = std(THREE, tint, { roughness: 0.9, emissive: tint, emissiveIntensity: 0.3 }); + const n = 2 + Math.floor(rng.next() * 2); + for (let k = 0; k < n; k++) { + const h = 1.6 + rng.next() * 1.6; + const x = (rng.next() - 0.5) * 3, z = (rng.next() - 0.5) * 3; + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 0.9, 5), bark); + trunk.position.set(x, FLOOR_Y + 0.45, z); + const crown = new THREE.Mesh(new THREE.ConeGeometry(0.65 + rng.next() * 0.4, h, 7), leaf); + crown.position.set(x, FLOOR_Y + 0.9 + h / 2, z); + crown.userData.swayPhase = rng.next() * Math.PI * 2; + g.add(trunk); g.add(crown); + } + return g; +} + +function rampart(THREE, rng, tint) { + // Crenellated parapet block: wall + merlons, long axis with the path. + const g = new THREE.Group(); + const mat = stoneMat(THREE, tint); + box(THREE, g, 0.5, 1.0, 2.6, mat, 0, FLOOR_Y + 0.5, 0); + const n = 3 + Math.floor(rng.next() * 2); + for (let k = 0; k < n; k++) { + box(THREE, g, 0.5, 0.4, 0.34, mat, 0, FLOOR_Y + 1.2, -1.1 + k * (2.2 / Math.max(1, n - 1))); + } + return g; +} + +function torchProp(THREE, rng, tint) { + // Wall bracket + flame: the flame material flickers in update. + const g = new THREE.Group(); + const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 }); + const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.5, 6), iron); + arm.rotation.x = 0.7; + arm.position.set(0, FLOOR_Y + 1.7, 0); + g.add(arm); + const cup = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.06, 0.14, 8), iron); + cup.position.set(0, FLOOR_Y + 1.88, 0.17); + g.add(cup); + const flameMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 2.2 }); + const flame = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), flameMat); + flame.position.set(0, FLOOR_Y + 2.02, 0.17); + flame.scale.y = 1.5; + g.add(flame); + g.userData.flame = { mat: flameMat, mesh: flame, phase: rng.next() * Math.PI * 2, base: 2.2 }; + return g; +} + +function banner(THREE, rng, tint) { + const g = new THREE.Group(); + const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 }); + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.6, 6), iron); + pole.position.set(0, FLOOR_Y + 1.3, 0); + g.add(pole); + const clothMat = std(THREE, tint, { roughness: 0.85, emissive: tint, emissiveIntensity: 0.35 }); + const cloth = new THREE.Mesh(new THREE.PlaneGeometry(0.7, 1.4), clothMat); + cloth.position.set(0.36, FLOOR_Y + 1.7, 0); + g.add(cloth); + g.userData.cloth = { mesh: cloth, phase: rng.next() * Math.PI * 2 }; + return g; +} + +function stalagmite(THREE, rng, tint) { + const g = new THREE.Group(); + const mat = stoneMat(THREE, tint); + const h = 0.8 + rng.next() * 1.4; + const cone = new THREE.Mesh(new THREE.ConeGeometry(0.3 + rng.next() * 0.2, h, 7), mat); + cone.position.set(0, FLOOR_Y + h / 2, 0); + g.add(cone); + if (rng.next() < 0.5) { + // Stalactite above: the cave bites both ways. + const ch = 0.5 + rng.next() * 0.8; + const up = new THREE.Mesh(new THREE.ConeGeometry(0.25, ch, 7), mat); + up.rotation.x = Math.PI; + up.position.set((rng.next() - 0.5) * 0.6, FLOOR_Y + 3.4 - ch / 2, 0); + g.add(up); + } + return g; +} + +function bush(THREE, rng, tint) { + // One-mesh boundary planting: a squashed leafy blob, faintly emissive + // so the green edge reads at night. Planted densely along outdoor + // boundaries, so cheap matters more than pretty. + const g = new THREE.Group(); + const leaf = std(THREE, tint, { roughness: 0.9, emissive: tint, emissiveIntensity: 0.3 }); + const blob = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 0), leaf); + blob.scale.set(1 + rng.next() * 0.4, 0.65 + rng.next() * 0.2, 1 + rng.next() * 0.4); + blob.position.y = FLOOR_Y + 0.3; + blob.rotation.y = rng.next() * Math.PI; + g.add(blob); + return g; +} + +function boulder(THREE, rng, tint) { + // One-mesh rocky barrier: a squashed gray mass for castle-wall edges. + // Faint emissive like the junk stone so dark songs still read it. + const g = new THREE.Group(); + const mat = stoneMat(THREE, tint); + const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), mat); + rock.scale.set(1 + rng.next() * 0.4, 0.7 + rng.next() * 0.25, 1 + rng.next() * 0.4); + rock.position.y = FLOOR_Y + 0.42; + rock.rotation.set(rng.next() * 3, rng.next() * 3, rng.next() * 3); + g.add(rock); + return g; +} + +export const PROP_BUILDERS = { rubble, furniture, trees, rampart, torch: torchProp, banner, stalagmite, bush, boulder }; + +export const PROP_TINTS = { + rubble: [0.45, 0.44, 0.46], + furniture: [0.4, 0.33, 0.26], + trees: [0.16, 0.32, 0.16], + rampart: [0.48, 0.47, 0.5], + torch: [1.0, 0.55, 0.18], + banner: [0.6, 0.14, 0.16], + stalagmite: [0.5, 0.5, 0.54], + bush: [0.14, 0.3, 0.14], + boulder: [0.45, 0.44, 0.46], +}; + +/** + * Analytic glow for placed dressing: flames breathe, cloth sways. Positions + * are fixed (the map seats everything once); only light animates, as pure + * f(t). Works on any group carrying userData.flame / userData.cloth, i.e. + * torch and banner builder output. + */ +export function animateDressing(item, t, light = 1) { + const fl = item.userData.flame; + if (fl && fl.mat) { + fl.mat.emissiveIntensity = fl.base * (0.35 + 0.65 * light) + * (0.85 + 0.15 * Math.sin(t * 9.3 + fl.phase)); + if (fl.mesh) fl.mesh.scale.y = 1.5 + Math.sin(t * 7.1 + fl.phase) * 0.12; + } + const cl = item.userData.cloth; + if (cl && cl.mesh) { + cl.mesh.rotation.y = Math.sin(t * 0.9 + cl.phase) * 0.18; + } +} + +/** + * The portal: a standing stone ring with a viz-fed disc. The disc material is + * returned so the facade can bind the viz texture (emissiveMap) per frame — + * the IMAGE is the video, the ring is the song's stone. + */ +export function buildPortal({ THREE, palette = null } = {}) { + const pal = Array.isArray(palette) && palette.length ? palette : [[0.7, 0.8, 1.0]]; + const group = new THREE.Group(); + group.name = 'portal'; + const stone = stoneMat(THREE, [0.42, 0.42, 0.46]); + const ring = new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RING_R, 0.22, 10, 28), stone); + ring.position.y = FLOOR_Y + PORTAL_RING_R + 0.2; + group.add(ring); + for (const side of [-1, 1]) { + const foot = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.5), stone); + foot.position.set(side * (PORTAL_RING_R + 0.1), FLOOR_Y + 0.3, 0); + group.add(foot); + } + const discMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(0.02, 0.02, 0.03), + emissive: new THREE.Color(1, 1, 1), + emissiveIntensity: 1.2, + roughness: 0.5, metalness: 0.1, + side: THREE.DoubleSide, + }); + const disc = new THREE.Mesh(new THREE.CircleGeometry(PORTAL_RING_R - 0.1, 28), discMat); + disc.position.y = FLOOR_Y + PORTAL_RING_R + 0.2; + disc.name = 'portalDisc'; + group.add(disc); + // A seeded accent arc on the ring so the portal belongs to the song. + const c = pal[1 % pal.length] || pal[0]; + const arcMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(0.06, 0.06, 0.07), + emissive: new THREE.Color(c[0], c[1], c[2]), + emissiveIntensity: 0.9, + roughness: 0.5, + }); + const arc = new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RING_R + 0.02, 0.07, 8, 12, Math.PI * 0.7), arcMat); + arc.position.copy(ring.position); + arc.rotation.z = Math.PI * 0.15; + group.add(arc); + group.userData.arcMat = arcMat; + return { group, discMat }; +} diff --git a/flow-state/src/venues/dungeon/shared.js b/flow-state/src/venues/dungeon/shared.js new file mode 100644 index 0000000..ced1dee --- /dev/null +++ b/flow-state/src/venues/dungeon/shared.js @@ -0,0 +1,58 @@ +// Shared helpers for the dungeon venue submodules (layout / zones / props / +// hazards). Pure, no THREE, no wall-clock, no Math.random — seeded Rng only, +// all motion analytic f(t, seed) so seek === playback. +// +// A submodule owns ONE portion of the image and the 3D space its objects live +// in. The facade (../dungeon.js) owns the single walk clock and the path; +// every submodule reads (t, plan, pathAt) and never integrates. + +import { Rng } from '../../engine/rng.js'; + +/** Independent seeded stream that never perturbs sibling streams. */ +export function forkDungeon(seed, label) { + return new Rng(hashSeed(seed) ^ 0x9e3779b9).fork(String(label)); +} + +function hashSeed(seed) { + return (seed >>> 0) || 1; +} + +/** Analytic wrap: position of a base offset walking at rate dz after time t. */ +export function streamWrap(s0, t, dz, span) { + const s = s0 + t * dz; + return s0 + ((((s - s0) % span) + span) % span); +} + +/** Smoothstep 0..1. */ +export function smooth01(x) { + const t = Math.max(0, Math.min(1, x)); + return t * t * (3 - 2 * t); +} + +/** + * Crew-style visibility fade: how many of a population with `count` members + * show at a continuous level in [0, ~1.1]. Never toggles — pure f(level,index). + * Same pattern as ride.js crew fade (DESIGN §4: furniture never hard-toggles + * on interpolated state). + */ +export function fadeFor(level, index, count, soft = 0.12) { + const showIndex = index / Math.max(1, count); + return Math.max(0, Math.min(1, (level - showIndex) / soft)); +} + +/** Segment kinds the layout planner may emit. */ +export const SEGMENT_KINDS = [ + 'hall', + 'room', + 'forest', + 'wallwalk', +]; + +/** Zone names the zone planner may emit (one per section). */ +export const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk']; + +/** Prop types in the prop library (primitives only, no glTF). */ +export const PROP_TYPES = ['rubble', 'furniture', 'trees', 'rampart', 'torch', 'banner', 'stalagmite']; + +/** Hazard types (visual only — the walk sidesteps them). */ +export const HAZARD_TYPES = ['gate', 'gap', 'patrol']; diff --git a/flow-state/src/venues/registry.js b/flow-state/src/venues/registry.js index d88c8d5..e57bd42 100644 --- a/flow-state/src/venues/registry.js +++ b/flow-state/src/venues/registry.js @@ -7,10 +7,11 @@ import { validateModule } from '../params/schema.js'; import { chamber } from './chamber.js'; import { stage } from './stage.js'; import { ride } from './ride.js'; +import { dungeon } from './dungeon.js'; -export const ARCHETYPES = ['chamber', 'stage', 'ride']; +export const ARCHETYPES = ['chamber', 'stage', 'ride', 'dungeon']; -const MODULES = [chamber, stage, ride]; +const MODULES = [chamber, stage, ride, dungeon]; const errors = []; for (const m of MODULES) { diff --git a/flow-state/test/dungeon-modules.test.js b/flow-state/test/dungeon-modules.test.js new file mode 100644 index 0000000..8030489 --- /dev/null +++ b/flow-state/test/dungeon-modules.test.js @@ -0,0 +1,355 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { Rng } from '../src/engine/rng.js'; +import { genDungeon, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from '../src/venues/dungeon/map.js'; +import { PROP_BUILDERS } from '../src/venues/dungeon/props.js'; +import { HAZARD_BUILDERS, avoidanceOffset } from '../src/venues/dungeon/hazards.js'; +import { generateVenue } from '../src/venues/VenueGenerator.js'; +import { generatePersonality } from '../src/look/Personality.js'; + +test('dungeon map is deterministic with rooms, zones and a closed route', () => { + const a = genDungeon({ seed: 7, rng: new Rng(7) }); + const b = genDungeon({ seed: 7, rng: new Rng(7) }); + assert.deepEqual(a, b, 'same seed must generate the same dungeon'); + assert.ok(a.rooms.length >= 3, `only ${a.rooms.length} rooms`); + const zones = new Set(a.rooms.map((r) => r.zone)); + assert.ok(zones.size >= 3, `zone coverage thin: ${[...zones].join(',')}`); + assert.ok(a.route.totalLen > 80, `route too short: ${a.route.totalLen}`); + // pathAt wraps and is pure. + const s1 = pathAt(a, 3.7), s2 = pathAt(a, 3.7 + a.route.totalLen); + assert.deepEqual(s1, s2, 'pathAt must wrap seamlessly'); + assert.ok(Number.isFinite(s1.yaw), 'heading must be finite'); +}); + +test('every room is reachable: BFS over open cells hits all rooms', () => { + for (const seed of [5, 7, 4242]) { + const m = genDungeon({ seed, rng: new Rng(seed) }); + const seen = new Set([0]); + const r0 = m.rooms[0]; + const qx = [r0.x + Math.floor(r0.w / 2)], qy = [r0.y + Math.floor(r0.h / 2)]; + const seenCells = new Set([qy[0] * m.w + qx[0]]); + const roomOf = new Int16Array(m.w * m.h).fill(-1); + for (const rm of m.rooms) { + for (let y = rm.y; y < rm.y + rm.h; y++) { + for (let x = rm.x; x < rm.x + rm.w; x++) roomOf[y * m.w + x] = rm.id; + } + } + let head = 0; + while (head < qx.length) { + const cx = qx[head], cy = qy[head]; + head++; + if (roomOf[cy * m.w + cx] >= 0) seen.add(roomOf[cy * m.w + cx]); + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue; + if (!m.grid[ny * m.w + nx] || seenCells.has(ny * m.w + nx)) continue; + seenCells.add(ny * m.w + nx); + qx.push(nx); qy.push(ny); + } + } + assert.equal(seen.size, m.rooms.length, + `seed ${seed}: reachable ${seen.size}/${m.rooms.length} rooms`); + } +}); + +test('the route only walks open cells and visits every room', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + // Route samples sit on open cells (checked at cell granularity). + for (let s = 0; s < m.route.totalLen; s += 2) { + const p = pathAt(m, s); + const cx = Math.floor(p.x / CELL + m.w / 2); + const cy = Math.floor(p.z / CELL + m.h / 2); + assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `route off the grid at s=${s}`); + assert.ok(m.grid[cy * m.w + cx], `route crosses a solid cell at s=${s.toFixed(1)}`); + } + const visited = new Set(m.route.roomIds.filter((id) => id >= 0)); + assert.equal(visited.size, m.rooms.length, 'the tour must enter every room'); +}); + +test('no holes indoors, green edges outdoors: every open edge is walled or planted', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + const hRuns = m.wallRuns.filter((r) => r.z0 === r.z1); + const vRuns = m.wallRuns.filter((r) => r.x0 === r.x1); + const coveredH = (x, z) => hRuns.some((r) => + Math.abs(r.z0 - z) < 1e-6 && x + 1e-6 >= r.x0 && x - 1e-6 <= r.x1); + const coveredV = (x, z) => vRuns.some((r) => + Math.abs(r.x0 - x) < 1e-6 && z + 1e-6 >= r.z0 && z - 1e-6 <= r.z1); + const plantedNear = (x, z) => m.items.vegetation.some((v) => + Math.hypot(v.x - x, v.z - z) < 1.6); + const w0 = (cx) => (cx - m.w / 2) * CELL; // west edge of cell cx + const z0 = (cy) => (cy - m.h / 2) * CELL; + // Indoor-ness is the map's own flag (rooms by rect, corridors by the + // rooms they join) — the test reads it, never re-derives it, so a + // corridor the tour never walks still judges correctly. + const cellIndoor = (x, y) => m.indoor[y * m.w + x] === 1; + let walled = 0, planted = 0; + for (let y = 0; y < m.h; y++) { + for (let x = 0; x < m.w; x++) { + if (!m.grid[y * m.w + x]) continue; + const openN = (nx, ny) => nx >= 0 && ny >= 0 && nx < m.w && ny < m.h && m.grid[ny * m.w + nx]; + const edges = []; + if (!openN(x - 1, y)) edges.push(['v', w0(x), z0(y) + CELL / 2]); + if (!openN(x + 1, y)) edges.push(['v', w0(x) + CELL, z0(y) + CELL / 2]); + if (!openN(x, y - 1)) edges.push(['h', w0(x) + CELL / 2, z0(y)]); + if (!openN(x, y + 1)) edges.push(['h', w0(x) + CELL / 2, z0(y) + CELL]); + for (const [kind, ex, ez] of edges) { + const covered = kind === 'h' ? coveredH(ex, ez) : coveredV(ex, ez); + if (cellIndoor(x, y)) { + assert.ok(covered, `hole on ${kind === 'h' ? 'horizontal' : 'vertical'} edge of (${x},${y})`); + walled++; + } else { + assert.ok(plantedNear(ex, ez) || covered, + `outdoor edge of (${x},${y}) neither walled nor planted`); + planted++; + } + } + } + } + assert.ok(walled > 100, `only ${walled} wall edges checked — map too open?`); + assert.ok(planted > 20, `only ${planted} planted edges — outdoor bare?`); + for (const v of m.items.vegetation) { + assert.ok(['tree', 'bush', 'boulder'].includes(v.kind), `unknown planting ${v.kind}`); + const cx = Math.floor(v.x / CELL + m.w / 2); + const cy = Math.floor(v.z / CELL + m.h / 2); + assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, 'planting off the grid'); + assert.ok(!m.grid[cy * m.w + cx], 'planting on an open cell — it would block the walk'); + } +}); + +test('watch cells stand on open ground with line-of-sight to the route', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + assert.ok(Object.keys(m.watch).length >= m.rooms.length - 1, + 'nearly every room needs a camera chair'); + for (const [id, w] of Object.entries(m.watch)) { + const cx = Math.floor(w.x / CELL + m.w / 2); + const cy = Math.floor(w.z / CELL + m.h / 2); + assert.ok(m.grid[cy * m.w + cx], `watch cell of room ${id} is solid`); + const rm = m.rooms.find((r) => r.id === Number(id)); + assert.ok(cx >= rm.x && cx < rm.x + rm.w && cy >= rm.y && cy < rm.y + rm.h, + `watch cell of room ${id} is outside its room`); + } +}); + +test('portal sits off-route in a room, facing the walk', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + assert.ok(m.portal, 'no portal planned'); + const cx = Math.floor(m.portal.x / CELL + m.w / 2); + const cy = Math.floor(m.portal.z / CELL + m.h / 2); + assert.ok(m.grid[cy * m.w + cx], 'portal cell is solid'); + const rm = m.rooms.find((r) => r.id === m.portal.roomId); + assert.ok(rm && rm.indoor, 'portal belongs in an indoor room'); + assert.ok(Number.isFinite(m.portal.yaw), 'portal yaw must be finite'); +}); + +test('items sit on open cells, off the walked line', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + const onOpen = (x, z, what) => { + const cx = Math.floor(x / CELL + m.w / 2); + const cy = Math.floor(z / CELL + m.h / 2); + assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `${what} off the grid`); + assert.ok(m.grid[cy * m.w + cx], `${what} on a solid cell`); + }; + for (const p of m.items.props) { + assert.ok(PROP_BUILDERS[p.type], `unknown prop ${p.type}`); + onOpen(p.x, p.z, `prop ${p.type}`); + } + assert.ok(m.items.torches.length >= 2, `only ${m.items.torches.length} torches`); + assert.ok(m.items.torches.length <= 18, 'torch cap blown — clutter'); + for (const tc of m.items.torches) { + // Sconces hang on indoor stone: an indoor open cell within a step, + // with a solid neighbour in reach of the bracket — never floating, + // never buried deep. + const cx = Math.floor(tc.x / CELL + m.w / 2); + const cy = Math.floor(tc.z / CELL + m.h / 2); + let indoorNear = false, solidNear = false, openNear = false; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue; + if (m.grid[ny * m.w + nx]) { + openNear = true; + if (m.indoor[ny * m.w + nx]) indoorNear = true; + } else solidNear = true; + } + } + assert.ok(openNear, 'torch buried in stone'); + assert.ok(indoorNear, 'torch burning outdoors'); + assert.ok(solidNear, 'torch with no wall in reach — floating'); + } + assert.ok(m.items.patrols.length >= 1, 'no patrol lanterns for the open ground'); + assert.ok(m.items.windows.length >= 4, `only ${m.items.windows.length} windows`); + assert.ok(m.items.puddles.length === 4, 'want exactly 4 puddles'); + for (const g of [...m.items.gates, ...m.items.gaps, ...m.items.patrols]) { + onOpen(g.x, g.z, 'hazard'); + } + for (const g of m.items.gates) { + assert.ok(HAZARD_BUILDERS.gate, 'gate builder missing'); + assert.ok(typeof g.s === 'number', 'gate needs route arclength for the lift'); + } + // Avoidance stays bounded on the real gate list. + for (let i = 0; i < 50; i++) { + const off = avoidanceOffset(i * 1.7, m.items.gates, i * 0.63); + assert.ok(Number.isFinite(off) && Math.abs(off) <= 1.0, `avoidance out of bounds: ${off}`); + } +}); + +test('portcullis stays up for the whole procession, not just the hero', async () => { + const T = await import('three'); + const { updateHazards, HAZARD_BUILDERS } = await import('../src/venues/dungeon/hazards.js'); + const gate = HAZARD_BUILDERS.gate(T, new Rng(3), [0.45, 0.45, 0.48]); + gate.userData.home = { x: 0, z: 0, yaw: 0 }; + gate.userData.hazardS = 100; + const liftOf = (partyS) => { + updateHazards({ hazards: { items: [gate] }, t: 10, heroS: partyS[0], partyS }); + return gate.userData.liftBars.position.y; + }; + // Hero under the gate: fully raised. + assert.ok(liftOf([100]) > 2.3, 'gate not raised for the hero'); + // Hero through, one trailer still under it: must stay fully raised. + assert.ok(liftOf([104, 100.5, 108]) > 2.3, 'gate dropped on the trailer'); + // Whole party clear: back down. + assert.ok(liftOf([112, 116, 120]) < 0.1, 'gate stays up with nobody near'); + // Hero-only fallback still behaves (no partyS passed). + updateHazards({ hazards: { items: [gate] }, t: 10, heroS: 100 }); + assert.ok(gate.userData.liftBars.position.y > 2.3, 'hero-only lift broken'); +}); + +test('roomAt/zoneAt follow the route through rooms and corridors', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + const seenRooms = new Set(), seenZones = new Set(); + let corridor = false; + for (let s = 0; s < m.route.totalLen; s += 1) { + const r = roomAt(m, s); + const z = zoneAt(m, s); + assert.ok(Number.isInteger(r) && r >= -1 && r < m.rooms.length, `bad room ${r}`); + assert.ok(typeof z === 'string' && z.length > 0, `bad zone ${z}`); + if (r >= 0) seenRooms.add(r); + else corridor = true; + seenZones.add(z); + } + assert.equal(seenRooms.size, m.rooms.length, 'route samples must cover every room'); + assert.ok(corridor, 'route never leaves rooms — corridors unused?'); + assert.ok(seenZones.size >= 2, 'route crosses a single zone only'); +}); + +test('the cast assembles: enemies, critters and a wisp on open cells', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + const kinds = m.items.entities.map((e) => e.kind); + assert.ok(kinds.filter((k) => k === 'lurker').length >= 2, `lurkers: ${kinds.join(',')}`); + assert.ok(kinds.includes('critter'), 'no critters'); + assert.ok(kinds.includes('wisp'), 'no wisp'); + assert.equal(m.items.entities.length, 6, `cast size ${m.items.entities.length}`); + for (const e of m.items.entities) { + assert.ok(['lurker', 'sentry', 'critter', 'wisp'].includes(e.kind), `unknown entity ${e.kind}`); + const cx = Math.floor(e.x / CELL + m.w / 2); + const cy = Math.floor(e.z / CELL + m.h / 2); + assert.ok(m.grid[cy * m.w + cx] === 1, `${e.kind} on a solid cell`); + assert.ok(Number.isFinite(e.s) && Number.isFinite(e.yaw), `${e.kind} missing s/yaw`); + assert.ok(e.range >= 0 && Number.isFinite(e.speed) && Number.isFinite(e.phase), + `${e.kind} missing gait params`); + } +}); + +test('loot sits a step off the walk, each cache earning its halt', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + assert.ok(m.items.loot.length >= 2, `only ${m.items.loot.length} loot spots`); + assert.ok(m.items.loot.length <= 3, 'loot glut'); + for (const l of m.items.loot) { + assert.ok(['cache', 'relic', 'hoard'].includes(l.kind), `unknown loot ${l.kind}`); + const cx = Math.floor(l.x / CELL + m.w / 2); + const cy = Math.floor(l.z / CELL + m.h / 2); + assert.ok(m.grid[cy * m.w + cx] === 1, `loot ${l.kind} on a solid cell`); + } + assert.equal(m.items.halts.length, m.items.loot.length, 'every cache earns a halt'); + for (let i = 0; i < m.items.halts.length; i++) { + const h = m.items.halts[i]; + assert.ok(h.dur > 0 && h.dur <= 3, `halt dur ${h.dur}`); + assert.equal(h.s, m.items.loot[i].s, 'halt must sit on its loot'); + for (let j = i + 1; j < m.items.halts.length; j++) { + const L = m.route.totalLen; + const d = Math.abs(h.s - m.items.halts[j].s) % L; + assert.ok(Math.min(d, L - d) >= 15, 'halts overlap — the odometer could retreat'); + } + } +}); + +test('odometer holds at halts and never retreats', () => { + const cruise = 1.4, L = 200; + const halts = [{ s: 50, dur: 2.2 }, { s: 150, dur: 2.2 }]; + // Deterministic. + assert.equal(odometer(cruise, halts, L, 10), odometer(cruise, halts, L, 10)); + // Unpaused stretch advances at gait. + assert.ok(Math.abs(odometer(cruise, halts, L, 10) - cruise * 10) < 1e-9, 'gait wrong'); + // Arrival holds: from t_a to t_a+dur the reading stays on the loot. + const ta = 50 / cruise; + assert.ok(Math.abs(odometer(cruise, halts, L, ta + 1.1) - 50) < 1e-9, 'halt does not hold'); + assert.ok(Math.abs(odometer(cruise, halts, L, ta + 2.2) - 50) < 1e-9, 'halt releases early'); + // Monotonic everywhere, both gaits, both halt sets. + for (const c of [0.4, 1.4, 2.6]) { + let prev = odometer(c, halts, L, 0); + for (let t = 0.05; t < 300; t += 0.05) { + const s = odometer(c, halts, L, t); + const d = (s - prev + L) % L; + assert.ok(d < c * 0.06 + 1e-9, `retreat at t=${t.toFixed(2)} (cruise ${c})`); + prev = s; + } + } +}); + +test('entity poses are deterministic, bounded and hero-aware', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + const L = m.route.totalLen; + for (const e of m.items.entities) { + const p1 = entityPose(e, 10, 5, 0, 0, L); + const p2 = entityPose(e, 10, 5, 0, 0, L); + assert.deepEqual(p1, p2, `${e.kind} pose unstable`); + assert.ok(Math.hypot(p1.x - e.x, p1.z - e.z) <= e.range + 1.6, + `${e.kind} wanders ${Math.hypot(p1.x - e.x, p1.z - e.z).toFixed(2)} from home`); + assert.ok(p1.alert >= 0 && p1.alert <= 1, `${e.kind} alert out of range`); + // Near the hero it notices; far away it doesn't. + const near = entityPose(e, 10, e.s, e.x, e.z, L); + const far = entityPose(e, 10, (e.s + L / 2) % L, e.x, e.z, L); + assert.ok(near.alert >= far.alert, `${e.kind} ignores the hero`); + assert.ok(near.alert > 0.5, `${e.kind} cold at point blank`); + } +}); + +const SLOW_DARK = { + meanCentroid: 0.3, meanFlatness: 0.1, bpm: 68, + dynamicRange: 0.7, meanLoudness: 0.3, +}; + +function slowPersonalityAt(seed) { + const rng = new Rng(seed); + return generatePersonality(SLOW_DARK, rng.fork('personality'), () => 10, 4); +} + +test('VenueGenerator plans the dungeon map when the dungeon is picked', () => { + const rng = new Rng(5); + const story = { + sections: [{ act: 'setup' }, { act: 'development' }, { act: 'climax' }, { act: 'resolution' }], + }; + const vs = generateVenue({ + summary: SLOW_DARK, rng: rng.fork('venue'), personality: slowPersonalityAt(5), + director: null, story, sections: [{}, {}, {}, {}], + }); + assert.equal(vs.archetype, 'dungeon'); + assert.ok(vs.dungeon && vs.dungeon.map, 'dungeon venue must carry the map'); + assert.ok(vs.dungeon.map.rooms.length >= 3, 'map has no rooms'); + assert.ok(vs.dungeon.map.route.totalLen > 80, 'route too short'); + assert.equal(vs.population.kind, 'party', 'dungeon walks a party, not a crowd'); + assert.equal(vs.projection.kind, 'portal', 'dungeon projects on the portal'); + // Deterministic across regenerations. + const again = generateVenue({ + summary: SLOW_DARK, rng: new Rng(5).fork('venue'), personality: slowPersonalityAt(5), + director: null, story, sections: [{}, {}, {}, {}], + }); + assert.deepEqual(again.dungeon, vs.dungeon); + // Non-dungeon venues carry no dungeon plan. + const fast = generateVenue({ + summary: { ...SLOW_DARK, bpm: 140 }, rng: new Rng(5).fork('venue'), + personality: slowPersonalityAt(5), director: null, story, sections: [{}, {}, {}, {}], + }); + if (fast.archetype !== 'dungeon') assert.equal(fast.dungeon, null); +}); diff --git a/flow-state/test/venue.test.js b/flow-state/test/venue.test.js index e75eb03..f631c1b 100644 --- a/flow-state/test/venue.test.js +++ b/flow-state/test/venue.test.js @@ -262,3 +262,126 @@ test('ride declares directCamera and drives its own rig deterministically', () = function awaitImportThree() { return import('three'); } + +test('dungeon declares directCamera and plants its cameras on open cells', () => { + const THREE = awaitImportThree(); + return THREE.then((T) => { + const dungeonMod = venues.find((v) => v.name === 'Dungeon'); + assert.ok(dungeonMod, 'Dungeon venue not registered'); + assert.equal(dungeonMod.directCamera, true, 'Dungeon must own its camera rig'); + const personality = personalityAt(11); + const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4); + personality.identity = identity; + const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity); + const scene = new T.Scene(); + const dungeonSpec = { + seed: 5, population: { count: 3, spread: 0.5, jitter: 0.1 }, + paletteMap: [0, 1, 2, 3], + perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }], + }; + const instance = dungeonMod.build({ + scene, seed: 4242, params: {}, actorSpec: actors.monolith, + venueSpec: dungeonSpec, THREE: T, + }); + const stubCam = () => ({ + position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } }, + rotation: {}, + look: null, + lookAt(x, y, z) { this.look = [x, y, z]; }, + updateMatrixWorld() {}, + }); + const drive = (time, framing) => { + const cam = stubCam(); + dungeonMod.update({ + instance, scene, camera: cam, + timeline: { time, frame: Math.round(time * 60), progress: time / 200 }, + features: {}, params: { light: 0.85, speed: 0.7 }, + palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]], + personality, framing, opacity: 1, + actorSpec: actors.monolith, + venueSpec: instance.venueSpec, + venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }, + THREE: T, + }); + const { x, y, z } = cam.position; + return { x, y, z, look: cam.look }; + }; + const framing = { scale: 1, shift: [0, 0] }; + const a = drive(10, framing), b = drive(10, framing); + assert.deepEqual(a, b, 'same inputs must place the camera identically'); + const c = drive(40, framing); + const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z); + assert.ok(travelled > 0.15, `dungeon camera barely moves: ${travelled.toFixed(3)} units in 30 s`); + // Every camera — walk or room plant — stands on an open cell at eye + // height, and always looks somewhere. The map is 88 units wide, so + // positions roam; openness is the invariant, not a small box. + const cellOpen = (x, z) => { + const map = instance.map; + const cx = Math.floor(x / 2 + map.w / 2); + const cy = Math.floor(z / 2 + map.h / 2); + return cx >= 0 && cy >= 0 && cx < map.w && cy < map.h && map.grid[cy * map.w + cx] === 1; + }; + const watches = [ + framing, + { scale: 1, shift: [0, 0], venueShot: { pos: [0, 1, 2], look: [0, 0.2, -5], size: 'normal', target: 'walk', dolly: 1.0 } }, + { scale: 1, shift: [0, 0], venueShot: { pos: [0, 1.5, 3], look: [0, 0.9, -3], size: 'normal', target: 'room', dolly: 1.0 } }, + { scale: 1, shift: [0, 0], venueShot: { pos: [2.6, 1.5, 1.0], look: [-0.5, 1.3, -8], size: 'wide', target: 'portal', dolly: 1.3, drive: 0.5 } }, + ]; + for (const t of [0, 30, 90, 200, 400]) { + for (const f of watches) { + const p = drive(t, f); + assert.ok(cellOpen(p.x, p.z), `camera inside a wall at t=${t} (${p.x.toFixed(2)},${p.z.toFixed(2)})`); + assert.ok(p.y >= 0.2 && p.y <= 3.4, `y=${p.y.toFixed(2)} off eye height at t=${t}`); + assert.ok(p.look && p.look.length === 3, 'camera must look somewhere'); + // The bearer hides in exactly the frames the camera stands + // where he is: first-person never renders him, plants do. + assert.equal(instance.hero.visible, instance.watchRoom < 0 ? false : true, + `hero visibility disagrees with watchRoom at t=${t}`); + } + } + // Walk vs room aim apart: the modes are the coverage. + const m1 = drive(60, watches[1]); + const m2 = drive(60, watches[2]); + const m3 = drive(60, watches[3]); + assert.ok(Math.hypot(m1.x - m2.x, m1.z - m2.z) > 0.2, + 'walk vs room must place the camera apart'); + assert.ok(Math.hypot(m1.look[0] - m3.look[0], m1.look[2] - m3.look[2]) > 0.2, + 'walk vs portal must aim apart'); + // The portal ring sits on an open cell and the tableau camera aims + // at the live actor, not at a conventional mark. + const ring = instance.portal.group.position; + assert.ok(cellOpen(ring.x, ring.z), 'portal ring inside a wall'); + const hero = instance.hero.position; + assert.deepEqual([m3.look[0], m3.look[2]], [hero.x, hero.z], + 'tableau camera must track the actor'); + // Enemies wear the song's actor pool: grown bodies from this track's + // walker/structure/swarm ActorSpecs, darkened hostile with ember + // eyes set into the face the geometry actually has. + const grown = (instance.cast || []).filter((en) => en.grown); + assert.ok(grown.length > 0, 'cast never grew its song bodies'); + // Grown from the song's actor pool, not the venue monolith: the + // cached specs carry walker/structure/swarm archetypes. + const specs = instance.enemySpecs || {}; + for (const [kind, arch] of [['lurker', 'walker'], ['sentry', 'structure'], ['critter', 'swarm']]) { + if (!specs[kind]) continue; + assert.equal(specs[kind].archetype, arch, + `${kind} enemy grown from ${specs[kind].archetype}, not ${arch}`); + assert.notEqual(specs[kind].seed, actors.monolith.seed, + `${kind} enemy reuses the monolith seed`); + } + for (const en of grown) { + if (en.spec.kind === 'wisp') continue; + assert.ok(en.bodyMesh && en.bodyMesh.isMesh, `${en.spec.kind} has no body`); + assert.ok(en.eyes.length >= 2, `${en.spec.kind} has no eyes`); + // The body is this song's cast, not a stock primitive: its part + // is tagged with the kind it plays. + assert.equal(en.bodyMesh.userData.kind, en.spec.kind, + `${en.spec.kind} body is not tagged from the actor pool`); + const bb = en.bodyMesh.geometry.boundingBox; + assert.ok(bb, `${en.spec.kind} body has no measured box`); + const h = (bb.max.y - bb.min.y) * en.bodyMesh.scale.x; + assert.ok(Math.abs(h - en.height) < 0.05, + `${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`); + } + }); +}); diff --git a/flow-state/tools/new-venue.js b/flow-state/tools/new-venue.js index 8f1900e..74bf2e1 100644 --- a/flow-state/tools/new-venue.js +++ b/flow-state/tools/new-venue.js @@ -9,7 +9,7 @@ import { fileURLToPath } from 'url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const VENUES = join(root, 'src/venues'); const REGISTRY = join(root, 'src/venues/registry.js'); -const ARCHETYPES = ['chamber','stage','ride']; +const ARCHETYPES = ['chamber','stage','ride','dungeon']; const args = process.argv.slice(2); const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; })); const name = args.find(a=>!a.startsWith('--'));