From 6a2af2e45f78e8c8777b90f6fc2afa437b2fe684 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Thu, 8 Oct 2026 22:20:04 +0200 Subject: [PATCH] Dungeon themes: per-song visual identity (crypt/overgrown/ember/flooded/windswept) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One album must not render the same dungeon twice. dungeonTheme() scores the measured summary per theme — pace terms gate on tempoConfidence so doubled detectors never read fast, crypt is penalised for blazing masters — plus small seeded jitter so ambiguous songs vary across seeds while decided races stay decided. The plan carries the theme name; dressing mix, planting/timber/puddle densities follow it, and the facade builds all materials (stone/floor multipliers, torch + light color, wood, fog tint) from the same record. Calibrated bank spread is 4/4/4/4/1 (windswept holds runner); corners stable across seeds; plan reshaping, facade wiring, and murk-vs-clear gates covered by tests. --- flow-state/DESIGN.md | 11 + flow-state/dungeon-debug.html | 12 +- flow-state/src/venues/VenueGenerator.js | 5 +- flow-state/src/venues/dungeon.js | 73 ++++-- flow-state/src/venues/dungeon/map.js | 335 ++++++++++++++++++++++-- flow-state/test/dungeon-modules.test.js | 225 +++++++++++++++- flow-state/test/venue.test.js | 42 ++- 7 files changed, 648 insertions(+), 55 deletions(-) diff --git a/flow-state/DESIGN.md b/flow-state/DESIGN.md index 32fb25c..29b365a 100644 --- a/flow-state/DESIGN.md +++ b/flow-state/DESIGN.md @@ -124,6 +124,17 @@ Actors and objects change only at camera cuts: - Absolute animation speed follows the song: motion bias is tempo-dominated and every `rate: true` param scales by per-track `rateScale` — slow songs got fast scenes before this. +- Venues have a per-track **character** the same way: the dungeon derives + sealed/murky/big (`dungeonCharacter`: tonal darkness, doubling-compensated + tempo, stillness, weight) from the measured summary, calibrated against + the bank because arrangement intent doesn't survive measurement (ambient + doubles tempo, hiss reads bright). Dark slow songs seal fully indoor; + blazing masters always crack open. +- The dungeon also gets a per-track visual **theme** (`dungeonTheme`: + crypt/overgrown/ember/flooded/windswept), scored off the same summary + traits — one album must not render the same dungeon twice. Pace terms + gate on tempoConfidence (doubled detectors must not read fast), jitter + is small enough that decided races stay decided across seeds. ## 7. Grounds and composition diff --git a/flow-state/dungeon-debug.html b/flow-state/dungeon-debug.html index adfc1e9..96bb8a2 100644 --- a/flow-state/dungeon-debug.html +++ b/flow-state/dungeon-debug.html @@ -60,7 +60,7 @@ look.venue.module = dungeon; look.venue.name = dungeon.name; look.venue.archetype = dungeon.archetype; const mapSeed = mapSeedParam !== null ? Number(mapseedParam) : (look.venue.seed ?? 7); -const map = genDungeon({ seed: mapSeed }); +const map = genDungeon({ seed: mapSeed, summary: track.summary }); look.venue.dungeon = { map }; // ---------------------------------------------------------- 2D map @@ -69,6 +69,9 @@ const mc = document.getElementById('map'); const mg = mc.getContext('2d'); const S = mc.width / map.w; document.getElementById('maptitle').textContent = `${songName} · map seed ${mapSeed} · ${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u`; +const ch = map.character || {}; +const chLine = `
characterseal ${(ch.indoorBias ?? 0).toFixed(2)} · murk ${(ch.murk ?? 0).toFixed(2)} · size ×${(ch.sizeMul ?? 1).toFixed(2)}
` + + `
theme${map.theme ?? 'crypt'}
`; for (let y = 0; y < map.h; y++) { for (let x = 0; x < map.w; x++) { if (!map.grid[y * map.w + x]) { mg.fillStyle = '#07090d'; } @@ -109,6 +112,9 @@ for (const l of map.items.loot || []) { mg.strokeStyle = '#fde047'; mg.lineWidth = 1; mg.beginPath(); mg.arc((l.x / 2 + map.w / 2) * S, (l.z / 2 + map.h / 2) * S, 5, 0, 7); mg.stroke(); } +for (const t of map.items.timbers || []) { + dot(t.x, t.z, '#92400e', 2); +} if (map.portal) { dot(map.portal.x, map.portal.z, '#f0abfc', 5); mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath(); @@ -123,7 +129,7 @@ mg.fillStyle = '#fff'; map.rooms.forEach((rm) => { mg.fillText(String(rm.id), (rm.x + 0.3) * S, (rm.y + 1) * S); }); -document.getElementById('mapmeta').innerHTML = +document.getElementById('mapmeta').innerHTML = chLine + map.rooms.map((rm) => `
room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}
`).join('') + `
@@ -135,7 +141,7 @@ document.getElementById('mapmeta').innerHTML = veg · boulder · lurker · sentry · critter · wisp · - loot (halt)
`; + loot (halt) · timber`; // ---------------------------------------------------------- 3D stills // Fake viz feed so windows/portal/puddles show their IMAGE (the compositor diff --git a/flow-state/src/venues/VenueGenerator.js b/flow-state/src/venues/VenueGenerator.js index 2ddd835..759add2 100644 --- a/flow-state/src/venues/VenueGenerator.js +++ b/flow-state/src/venues/VenueGenerator.js @@ -171,12 +171,13 @@ 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). + // don't move (same rule as venue:pop). The track summary tunes the + // dungeon's character (sealed/murky/big for dark slow songs). let dungeonPlan = null; if (picked.archetype === 'dungeon') { const dRng = rng.fork('dungeon:plan'); dungeonPlan = { - map: genDungeon({ seed, rng: dRng }), + map: genDungeon({ seed, rng: dRng, summary }), }; } diff --git a/flow-state/src/venues/dungeon.js b/flow-state/src/venues/dungeon.js index fb65cbf..01e291b 100644 --- a/flow-state/src/venues/dungeon.js +++ b/flow-state/src/venues/dungeon.js @@ -15,7 +15,7 @@ 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 { genDungeon, THEMES, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from './dungeon/map.js'; import { PROP_BUILDERS, PROP_TINTS, buildPortal, animateDressing } from './dungeon/props.js'; import { HAZARD_BUILDERS, HAZARD_TINTS, updateHazards, avoidanceOffset } from './dungeon/hazards.js'; @@ -143,6 +143,9 @@ export const dungeon = { // The map IS the dungeon: grid first, everything built from it. const map = (vsDun && vsDun.map) || genDungeon({ seed, rng }); const L = map.route.totalLen; + // Visual identity for this song: the theme record tints every + // material below, so two songs never share the same dungeon. + const theme = THEMES[map.theme] ?? THEMES.crypt; // Stone + flame shared materials (tinted per frame in update). const stoneMat = new T.MeshStandardMaterial({ @@ -155,7 +158,7 @@ export const dungeon = { }); 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, + emissive: new T.Color(theme.torch[0], theme.torch[1], theme.torch[2]), emissiveIntensity: 2.2, roughness: 0.4, }); const windowMat = new T.MeshStandardMaterial({ @@ -163,6 +166,12 @@ export const dungeon = { emissive: new T.Color(1, 1, 1), emissiveIntensity: 1.1, roughness: 0.5, metalness: 0.1, }); + // Timber: theme wood for door frames and wall beams. Static colour — + // wood reads as wood on every palette; torchlight does the rest. + const woodMat = new T.MeshStandardMaterial({ + color: new T.Color(theme.wood[0], theme.wood[1], theme.wood[2]), roughness: 0.8, metalness: 0.0, + emissive: new T.Color(theme.wood[0] * 0.24, theme.wood[1] * 0.24, theme.wood[2] * 0.24), emissiveIntensity: 1.0, + }); // Floor: one continuous slab under the whole grid. A plane cannot // have holes, so the walk always has ground; walls sit on top. @@ -197,13 +206,22 @@ export const dungeon = { } scene.add(shell); + // Timber framing: oak door frames on the gates, posts + plates on + // the walls. One shared material, static boxes from the plan. + for (const tb of map.items.timbers || []) { + const beam = new T.Mesh(new T.BoxGeometry(tb.sx, tb.sy, tb.sz), woodMat); + beam.position.set(tb.x, tb.y, tb.z); + beam.rotation.y = tb.yaw || 0; + scene.add(beam); + } + // Dressing, all seated once from map cells — static architecture, so // nothing here ever moves; per-frame work is glow only (see update). // Torches burn on the walls the route passes; where the route runs // open ground they stand on iron posts instead of floating. const dressing = []; for (const tc of map.items.torches) { - const torch = PROP_BUILDERS.torch(T, rng, PROP_TINTS.torch); + const torch = PROP_BUILDERS.torch(T, rng, theme.torch); torch.position.set(tc.x, 0, tc.z); torch.rotation.y = tc.yaw; if (tc.post) { @@ -435,7 +453,7 @@ export const dungeon = { key.position.set(3.5, 5.0, 6.0); key.castShadow = false; scene.add(key); - const torch = new T.PointLight(0xff9a3c, 2.0, 12); + const torch = new T.PointLight(new T.Color(theme.torch[0], theme.torch[1], theme.torch[2]), 2.0, 12); scene.add(torch); // Lantern fill: a warm light riding with the camera (see update). const camFill = new T.PointLight(0xffe0b8, 0.7, 20); @@ -458,7 +476,7 @@ export const dungeon = { scene.add(party); const inst = { - map, shell, stoneMat, floorMat, flameMat, windowMat, + map, shell, stoneMat, floorMat, flameMat, windowMat, woodMat, puddleMat, puddles, portal, portalDiscMat: portal.discMat, hero, party, torch, dressing, hazards, hazardItems, hazardGates, cast, lootGroups, @@ -500,6 +518,11 @@ export const dungeon = { const L = instance.L; const c0 = pal[0]; const fieldLight = vState ? vState.light : 0.7; + // Murk from the song's character: dark songs dim the whole venue + // and pull the fog close. The hero's torch stays brightest — gloom + // with one warm light reads dungeon, not black screen. + const murk = (instance.map.character && instance.map.character.murk) || 0; + const murkDim = 1 - 0.45 * murk; // --- walk clock: the interrupted odometer. Gait is constant per // video (see build); halts pause it at loot. Pure f(t). @@ -535,30 +558,35 @@ export const dungeon = { // Stone takes the palette; torch flames arc with the section light. // Generous emissive floor (chamber-junk pattern): on the darkest // songs lit-only stone goes black and the walk moves through nothing. + // Stone takes the palette times the theme: the palette keeps songs + // distinct, the theme keeps dungeons distinct — multiplicative, so + // neither can wash the other out. const tintM = 0.45 + fieldLight * 0.75; - 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; + const theme = THEMES[instance.map.theme] ?? THEMES.crypt; + const sm = theme.stoneMul, fm = theme.floorMul; + instance.stoneMat.color.setRGB((c0[0] * 0.42 * tintM + 0.07) * sm[0], (c0[1] * 0.42 * tintM + 0.07) * sm[1], (c0[2] * 0.46 * tintM + 0.08) * sm[2]); + instance.stoneMat.emissive.setRGB(c0[0] * 0.16 * sm[0], c0[1] * 0.16 * sm[1], c0[2] * 0.18 * sm[2]); + instance.floorMat.color.setRGB((c0[0] * 0.3 * tintM + 0.05) * fm[0], (c0[1] * 0.3 * tintM + 0.05) * fm[1], (c0[2] * 0.34 * tintM + 0.06) * fm[2]); + instance.floorMat.emissive.setRGB(c0[0] * 0.16 * fm[0], c0[1] * 0.16 * fm[1], c0[2] * 0.18 * fm[2]); + if (instance.key) instance.key.intensity = (1.0 + fieldLight * 1.2) * murkDim; + if (instance.ambient) instance.ambient.intensity = (0.6 + fieldLight * 0.55) * murkDim; // Lantern fill riding with the camera: nearby stone always reads, // whichever way the key falls. Positioned from the camera, which is // itself analytic f(t) — seek === playback still holds. if (instance.camFill) { instance.camFill.position.set(camera.position.x, 1.2, camera.position.z + 1.5); - instance.camFill.intensity = 0.8 + fieldLight * 0.6; + instance.camFill.intensity = (0.8 + fieldLight * 0.6) * murkDim; } // Song sky + matched fog, same contract as chamber/stage/ride. - paintSky(instance.skyMat, { palette: pal, light: fieldLight * zoneLightMul, style, seed: instance.seed }); + paintSky(instance.skyMat, { palette: pal, light: fieldLight * zoneLightMul * murkDim, style, seed: instance.seed }); if (scene.fog) { const fogMul = (vState ? vState.fog : 0.35) * zoneFogMul; - const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45); + const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45) * (1 - 0.35 * murk); scene.fog.near = fogDepth; scene.fog.far = fogDepth + 30; const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null; - if (mid) scene.fog.color.copy(mid); - else scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07); + if (mid) scene.fog.color.copy(mid).multiply(new T.Color(theme.fogTint[0], theme.fogTint[1], theme.fogTint[2])); + else scene.fog.color.setRGB(c0[0] * 0.05 * theme.fogTint[0], c0[1] * 0.05 * theme.fogTint[1], c0[2] * 0.07 * theme.fogTint[2]).multiplyScalar(1 - 0.3 * murk); } // Window slits + portal disc glow with the section light; the IMAGE // is the bound viz texture. @@ -684,14 +712,15 @@ export const dungeon = { // 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. + // the lens even where the route bends (chords run shorter than + // arcs). Planted shots show the whole party trailing. instance.hero.visible = opacity > 0.01 && !firstPerson; const kids = instance.party.children; for (let k = 0; k < kids.length; k++) { - const off = firstPerson ? (4.5 + k * 2.0) : -(1.9 + k * 1.7); + const off = firstPerson ? (6.0 + k * 2.0) : -(1.9 + k * 1.7); const cs = wrapS(heroS + off, L); const cp = pathAt(instance.map, cs); - const aside = firstPerson ? (k % 2 === 0 ? -0.7 : 0.7) : 0; + const aside = firstPerson ? (k % 2 === 0 ? -0.9 : 0.9) : 0; kids[k].position.set( cp.x + avoid * 0.5 + aside + Math.sin(t * 0.5 + k * 2.1) * 0.15, Math.abs(Math.sin(t * 3.4 + k * 1.4)) * 0.04, @@ -843,11 +872,13 @@ export const dungeon = { } } // The torch follows the bearer and breathes with the section light — - // and burns higher when something out there notices him back. + // and burns higher when something out there notices him back. Murk + // dims it only slightly: the hero's flame is the one light a dark + // dungeon keeps. instance.torch.position.set( instance.hero.position.x + 0.42, 1.5, instance.hero.position.z); instance.torch.intensity = (1.6 + fieldLight * 2.0) * (0.9 + 0.1 * Math.sin(t * 9.3)) - * (1 + proxMax * 0.9); + * (1 + proxMax * 0.9) * (1 - 0.2 * murk); if (instance.flameMat) { instance.flameMat.emissiveIntensity = 2.2 * (0.4 + 0.6 * fieldLight) * (0.88 + 0.12 * Math.sin(t * 9.3)); diff --git a/flow-state/src/venues/dungeon/map.js b/flow-state/src/venues/dungeon/map.js index 6fcc190..a43c153 100644 --- a/flow-state/src/venues/dungeon/map.js +++ b/flow-state/src/venues/dungeon/map.js @@ -13,32 +13,201 @@ // (BFS-verified), not a ribbon bent through blockwork. // // Contract: -// genDungeon({ seed, rng }) -> DungeonPlan (pure data, serialisable) +// dungeonCharacter(summary) -> { indoorBias, murk, sizeMul, dark, slow } +// genDungeon({ seed, rng, summary }) -> 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 // +// Song → dungeon mapping (see dungeonCharacter): dark/slow/heavy songs get +// sealed, murky, big-roomed dungeons with no outside views; bright/fast +// songs get airier ruins with forest and wallwalk openings. Same seed + +// same summary is the same dungeon; the seed alone is not. +// // DungeonPlan: -// { seed, w, h, grid, rooms, wallRuns, ceilRects, route, items, portal, watch } +// { seed, w, h, grid, rooms, wallRuns, ceilRects, route, items, portal, watch, character, theme } // 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 } +// items: { props, torches, puddles, windows, gates, gaps, patrols, ... } // portal: {x,z,yaw,roomId} | null; watch: { roomId: {x,z,lookX,lookZ} } +// character: { indoorBias, sealed, murk, sizeMul, dark, slow } — the mapping above +// theme: one of THEME_NAMES — the venue's visual identity for this song import { Rng } from '../../engine/rng.js'; import { smooth01 } from './shared.js'; export const CELL = 2.0; +const FLOOR_Y = -1.15; const GW = 44; const GH = 44; const ROOM_TARGET = 8; -const ROOM_MIN = 4; -const ROOM_MAX = 8; +const ROOM_MIN = 5; +const ROOM_MAX = 10; +const ROOM_ATTEMPTS = 500; + +const clamp01 = (x) => Math.max(0, Math.min(1, x)); + +/** + * Song → dungeon character. Pure f(summary); every field that matters about + * the track's sound is read here once, so the whole venue derives from one + * documented mapping instead of scattered heuristics. Calibrated against the + * measured 17-song bank, not arrangement intent — two measurement quirks + * shape the design: + * + * - Tempo doubling: beatless/broken tracks read ~2x (drone 62→170). bpmF + * folds readings above 150 back down; the seal veto below still uses the + * unfolded reading, because doubling only ever inflates — 160+ measured + * is genuinely fast either way. + * - Hiss masking: noisy tracks read spectrally bright whatever sits under + * the hiss (murk 0.915 ≈ glare 0.912), so darkness alone cannot find + * murk's quadrant; flatness + pace + weight can. + * + * - indoorBias 0..1: how sealed the dungeon is. Dark + slow songs approach + * 1 (mostly stone, few openings); blazing masters (centroid ≈ 1) are + * cracked open toward ~0.2 regardless of pace. + * - sealed bool: fully indoor — every room and corridor roofed, no outside + * views. Darkest tonal master (dark ≥ 0.5), or hissy slow weight + * (flat > 0.55, slow > 0.3, heavy > 0.4, un vetoed). In the bank exactly + * dusk + murk seal; fast hiss (grit/plate) stays open and moves. + * - murk 0..1: gloom the facade applies as dimmer lights + closer, darker + * fog. Tonal darkness first, hiss and stillness behind it. + * - sizeMul 1..1.45: room footprint multiplier. Slow + loud songs get + * fewer, bigger masses instead of small rooms. + */ +export function dungeonCharacter(summary = {}) { + const t = summaryTraits(summary); + let indoorBias = clamp01(0.3 + t.dark * 0.65 + t.slow * 0.3 + t.still * 0.1); + if (t.dark < 0.05) indoorBias *= 0.55; + return { + indoorBias: t.sealed ? 1 : indoorBias, + sealed: t.sealed, + murk: clamp01(t.dark * 0.8 + Math.min(1, (summary?.meanFlatness ?? 0.15) * 1.4) * 0.15 + t.still * 0.15), + sizeMul: 1 + clamp01(t.slow * 0.55 + t.heavy * 0.45) * 0.45, + dark: t.dark, + slow: t.slow, + }; +} + +/** + * Shared audio traits for the venue mapping — one source of truth for + * dungeonCharacter (layout) and dungeonTheme (identity) so the two can + * never disagree about what a song sounds like. Pace terms gate on + * tempoConfidence: doubled detectors report high bpm with low confidence + * (drone 170 @ 0.24), and trusting those readings raw would call every + * ambient track fast. + */ +function summaryTraits(summary = {}) { + const s = summary || {}; + const bpm = s.bpm ?? 120; + const dark = 1 - Math.min(1, (s.meanCentroid ?? 0.5) * 1.4); + const bpmF = bpm > 150 ? bpm / 2 : bpm; + const slow = clamp01((115 - bpmF) / 45); + const still = clamp01(((s.dynamicRange ?? 0.5) - 0.35) / 0.55); + const heavy = clamp01(((s.meanLoudness ?? 0.004) - 0.0002) / 0.0018); + const noisy = (s.meanFlatness ?? 0.15) > 0.55; + const paceTrust = clamp01(((s.tempoConfidence ?? 0.5) - 0.3) / 0.4); + const fast = clamp01((bpm - 120) / 60) * paceTrust; + const bright = clamp01(((s.meanCentroid ?? 0.5) - 0.5) * 2); + const dyn = clamp01(((s.dynamicRange ?? 0.5) - 0.3) / 0.5); + return { + dark, slow, still, heavy, noisy, fast, bright, dyn, + sealed: dark >= 0.5 + || (noisy && slow > 0.3 && heavy > 0.4 && bpm <= 160), + }; +} + +/** + * The five dungeon themes — the venue's visual identity per song. Each + * record is pure data (colors as [r,g,b] arrays, pick lists with repetition + * as weights, density multipliers), so the plan stays serialisable and the + * facade just builds materials from it: + * - stoneMul/floorMul: multipliers on the palette-driven stone/floor colors + * - torch: absolute flame emissive + torch-light color + * - fogTint: multiplier on the fog color (atmosphere grade per theme) + * - wood: absolute timber color + * - propIndoor/propOutdoor: weighted pick lists for room dressing + * - vegMul: boundary planting density · timberMul: wall-beam budget share + * - puddleMul: puddle count share + */ +export const THEMES = { + crypt: { + stoneMul: [1.0, 1.0, 1.05], floorMul: [1.0, 1.0, 1.05], + torch: [1.0, 0.55, 0.18], fogTint: [1.0, 0.95, 0.9], wood: [0.21, 0.13, 0.07], + propIndoor: ['rubble', 'furniture', 'rubble', 'banner', 'stalagmite'], + propOutdoor: ['trees', 'rubble', 'rampart', 'trees'], + vegMul: 1.0, timberMul: 1.0, puddleMul: 1.0, + }, + overgrown: { + stoneMul: [0.85, 1.05, 0.85], floorMul: [0.85, 1.05, 0.85], + torch: [0.4, 0.9, 0.6], fogTint: [0.9, 1.0, 0.9], wood: [0.25, 0.18, 0.1], + propIndoor: ['rubble', 'banner', 'stalagmite', 'rubble', 'bush'], + propOutdoor: ['trees', 'trees', 'bush', 'boulder', 'trees'], + vegMul: 1.5, timberMul: 0.7, puddleMul: 1.0, + }, + ember: { + stoneMul: [1.05, 0.85, 0.8], floorMul: [1.05, 0.85, 0.8], + torch: [1.0, 0.3, 0.12], fogTint: [1.0, 0.85, 0.8], wood: [0.12, 0.08, 0.06], + propIndoor: ['rubble', 'stalagmite', 'stalagmite', 'rubble', 'banner'], + propOutdoor: ['boulder', 'rubble', 'rampart', 'boulder'], + vegMul: 0.5, timberMul: 0.6, puddleMul: 0.5, + }, + flooded: { + stoneMul: [0.95, 1.0, 1.1], floorMul: [0.9, 1.0, 1.1], + torch: [0.6, 0.8, 1.0], fogTint: [0.9, 0.95, 1.05], wood: [0.2, 0.16, 0.12], + propIndoor: ['furniture', 'banner', 'rubble', 'furniture'], + propOutdoor: ['bush', 'rampart', 'trees', 'bush'], + vegMul: 0.8, timberMul: 0.8, puddleMul: 2.0, + }, + windswept: { + stoneMul: [1.1, 1.05, 0.95], floorMul: [1.1, 1.05, 0.95], + torch: [1.0, 0.9, 0.75], fogTint: [1.05, 1.0, 0.95], wood: [0.3, 0.22, 0.14], + propIndoor: ['furniture', 'rubble', 'banner', 'rubble'], + propOutdoor: ['boulder', 'bush', 'rampart', 'boulder'], + vegMul: 0.7, timberMul: 0.5, puddleMul: 0.7, + }, +}; +export const THEME_NAMES = Object.keys(THEMES); + +/** + * Pick the venue's visual identity for a song. Pure f(summary, seed): + * audio scores pick the region, a small seeded jitter keeps neighbouring + * songs from collapsing onto one theme. Calibrated corners: + * dark+slow → crypt, hissy → overgrown, heavy/fast → ember, + * bright+dynamic → flooded, bright+fast → windswept. + * + * Two honest-measurement rules shape the weights (same findings as + * dungeonCharacter): pace terms gate on tempoConfidence, so doubled + * detectors don't read fast; crypt is penalised for blazing masters, so + * bright ambient opens instead of entombing. Jitter is 0.15 — enough to + * vary re-rolls of genuinely ambiguous songs, too small to flip a decided + * race. Calibrated against the measured 17-song bank: crypt×4, + * overgrown×4, ember×4, flooded×4, windswept×1. + */ +export function dungeonTheme(summary = {}, seed = 1) { + const t = summaryTraits(summary); + const s = summary || {}; + // Brightness gate kept separate from darkness: a pale but dynamic + // ballad is flooded, never windswept; speed alone never floods. + const bright = clamp01(((s.meanCentroid ?? 0.5) - 0.5) * 2); + const scores = { + crypt: t.dark * 0.7 + t.slow * 0.4 + t.still * 0.3 - t.bright * 0.4, + overgrown: (t.noisy ? 1 : 0) * 0.9 + t.slow * 0.2 + (1 - t.dark) * 0.15 - t.fast * 0.4, + ember: t.heavy * 0.8 + t.fast * 0.3 + (t.noisy ? 1 : 0) * 0.15, + flooded: t.bright * 0.7 + t.dyn * 0.3, + windswept: t.fast * 0.9 + t.bright * 0.45, + }; + const rng = new Rng((((seed >>> 0) || 1) ^ 0x7e3a9) >>> 0 || 1); + let best = 'crypt', bestScore = -Infinity; + for (const name of THEME_NAMES) { + const s = scores[name] + rng.next() * 0.15; + if (s > bestScore) { bestScore = s; best = name; } + } + return best; +} -const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk']; const zoneIndoor = (z) => z === 'hall' || z === 'room'; const idx = (w, x, y) => y * w + x; @@ -121,16 +290,25 @@ function losClear(open, w, h, x0, y0, x1, y1) { // ------------------------------------------------------------------ plan -export function genDungeon({ seed = 1, rng = null } = {}) { +export function genDungeon({ seed = 1, rng = null, summary = null } = {}) { const r = rng ? rng.fork('dungeon:map') : new Rng((seed >>> 0) || 1).fork('dungeon:map'); const sd = (seed >>> 0) || 1; + const character = dungeonCharacter(summary); + const sizeMul = character.sizeMul; + // Visual identity for this song: the theme record drives dressing mix + // and densities below; the facade builds materials from the same record. + // Seeded off the map seed so the identity never shifts sibling streams. + const themeName = dungeonTheme(summary, sd); + const theme = THEMES[themeName] ?? THEMES.crypt; // --- rooms: rejection-sampled rects with a 1-cell moat between them, so // corridors always have somewhere to go and rooms never share walls. + // Footprints scale with the song's heft (sizeMul): heavy songs get + // masses, not cells. 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); + for (let attempt = 0; attempt < ROOM_ATTEMPTS && rooms.length < ROOM_TARGET; attempt++) { + const w = Math.max(3, Math.round(r.int(ROOM_MIN, ROOM_MAX) * sizeMul)); + const h = Math.max(3, Math.round(r.int(ROOM_MIN, ROOM_MAX) * sizeMul)); const x = r.int(1, GW - w - 1); const y = r.int(1, GH - h - 1); let ok = true; @@ -207,18 +385,39 @@ export function genDungeon({ seed = 1, rng = null } = {}) { } } - // --- zones: dealt round-robin off a seeded shuffle, so every map with - // 4+ rooms holds halls, rooms, forest and castle walls alike. + // --- zones: per-room draw against the song's indoorBias. A sealed + // character closes every room; otherwise indoor rooms split hall/room + // and the rest open to forest/wallwalk. With 4+ rooms and bias < 1, one + // room is forced each way so mixed maps always have both stone and sky. 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]]; } + const indoorPool = ['hall', 'room']; + const outdoorPool = ['forest', 'wallwalk']; + let sealAll = character.sealed === true; order.forEach((rm, i) => { - rm.zone = ZONE_NAMES[i % ZONE_NAMES.length]; - rm.indoor = zoneIndoor(rm.zone); + let zone; + if (sealAll || rZones.next() < character.indoorBias) { + zone = indoorPool[i % indoorPool.length]; + } else { + zone = outdoorPool[i % outdoorPool.length]; + } + rm.zone = zone; + rm.indoor = zoneIndoor(zone); }); + if (!sealAll && rooms.length >= 4 && !rooms.some((rm) => !rm.indoor)) { + const pick = order[rZones.int(0, order.length - 1)]; + pick.zone = outdoorPool[rZones.int(0, outdoorPool.length - 1)]; + pick.indoor = false; + } + if (!sealAll && rooms.length >= 4 && !rooms.some((rm) => rm.indoor)) { + const pick = order[rZones.int(0, order.length - 1)]; + pick.zone = indoorPool[rZones.int(0, indoorPool.length - 1)]; + pick.indoor = true; + } const zoneOf = new Array(GW * GH).fill('hall'); const indoorOf = new Uint8Array(GW * GH); for (const rm of rooms) { @@ -476,10 +675,14 @@ export function genDungeon({ seed = 1, rng = null } = {}) { // --- items. const rItems = new Rng((sd ^ 0x171e5) >>> 0 || 1); - const items = { props: [], torches: [], puddles: [], windows: [], gates: [], gaps: [], patrols: [], vegetation: [] }; + const items = { props: [], torches: [], puddles: [], windows: [], gates: [], gaps: [], patrols: [], vegetation: [], timbers: [] }; // 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. + const plantAt = (x, y, kind, s, jx, jz) => { + const p = toWorld(x, y); + items.vegetation.push({ x: p.x + jx, z: p.z + jz, kind, s }); + }; for (const { x, y, zone } of vegCells.values()) { const hsh = ((x * 73856093) ^ (y * 19349663)) >>> 0; const p = toWorld(x, y); @@ -487,15 +690,44 @@ export function genDungeon({ seed = 1, rng = null } = {}) { // 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; + // Theme density: ember barrens plant half the edges; above 1.0 the + // extra share grows undergrowth beside the main plant. Hash-fraction, + // so the same cells plant on every run. + if ((((hsh >>> 24) % 100) / 100) > Math.min(1, theme.vegMul)) continue; 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, - }); + const sc = 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6; + plantAt(x, y, kind, sc, jx, jz); + if (theme.vegMul > 1 && (((hsh >>> 20) % 100) / 100) < theme.vegMul - 1) { + plantAt(x, y, 'bush', sc * 0.6, -jx * 0.5, -jz * 0.5); + } + } + // Coverage guarantee: thinning must never leave an outdoor edge bare. + // Sweep every open-outdoor edge; plant a bush on the solid neighbour + // wherever no plant stands within reach. Fixed iteration order, one + // plant per solid cell, so the fill is as deterministic as the rest. + const filled = new Set(); + for (let y = 0; y < GH; y++) { + for (let x = 0; x < GW; x++) { + if (!open[idx(GW, x, y)] || indoorOf[idx(GW, x, y)]) continue; + const w0 = toWorld(x, y); + const edges = [ + [w0.x - CELL / 2, w0.z, x - 1, y], [w0.x + CELL / 2, w0.z, x + 1, y], + [w0.x, w0.z - CELL / 2, x, y - 1], [w0.x, w0.z + CELL / 2, x, y + 1], + ]; + for (const [ex, ez, nx, ny] of edges) { + if (inBounds(GW, GH, nx, ny) && open[idx(GW, nx, ny)]) continue; + if (!inBounds(GW, GH, nx, ny) || filled.has(idx(GW, nx, ny))) continue; + const covered = items.vegetation.some((v) => Math.hypot(v.x - ex, v.z - ez) < 1.6); + if (!covered) { + filled.add(idx(GW, nx, ny)); + const np = toWorld(nx, ny); + items.vegetation.push({ x: np.x, z: np.z, kind: 'bush', s: 0.9 }); + } + } + } } // Portal first: largest indoor room, the cell farthest off-route. Props // placed afterwards keep one cell of clearance round it. @@ -538,9 +770,11 @@ export function genDungeon({ seed = 1, rng = null } = {}) { 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); + // Theme dressing: each theme restocks the room vocabulary, so an + // ember vault reads as stalagmites and a flooded hall as furniture. const type = rm.indoor - ? rItems.pick(['rubble', 'furniture', 'rubble', 'banner', 'stalagmite']) - : rItems.pick(['trees', 'rubble', 'rampart', 'trees']); + ? rItems.pick(theme.propIndoor) + : rItems.pick(theme.propOutdoor); items.props.push({ x: p.x, z: p.z, yaw: rItems.next() * Math.PI * 2, type }); placed++; } @@ -590,9 +824,10 @@ export function genDungeon({ seed = 1, rng = null } = {}) { 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; + // Puddles spread round the loop, slightly off the walked line — + // more of them in the flooded theme, fewer in the ember barrens. + for (let k = 0, nP = Math.max(2, Math.round(4 * theme.puddleMul)); k < nP; k++) { + const i = Math.floor((k + 0.5) / nP * 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; @@ -640,6 +875,54 @@ export function genDungeon({ seed = 1, rng = null } = {}) { items.patrols.push({ x: px, z: pz, yaw: Math.atan2(q.x - p.x, q.z - p.z), s }); } + // Timber framing: oak door frames on every gate (two posts + a + // lintel the walk passes under) and posts + top plates along a + // seeded share of indoor wall runs. World-space boxes; the facade + // builds them with one shared wood material. Counts capped so the + // mesh budget stays flat across seeds. + { + const rTimber = new Rng((sd ^ 0x71be5) >>> 0 || 1); + for (const g of items.gates) { + const fx = Math.sin(g.yaw), fz = Math.cos(g.yaw); + const rx = Math.cos(g.yaw), rz = -Math.sin(g.yaw); + for (const side of [-1.15, 1.15]) { + items.timbers.push({ + x: g.x + rx * side, y: FLOOR_Y + 1.35, z: g.z + rz * side, + sx: 0.24, sy: 2.7, sz: 0.24, yaw: g.yaw, + }); + } + items.timbers.push({ + x: g.x, y: FLOOR_Y + 2.75, z: g.z, + sx: 2.75, sy: 0.32, sz: 0.32, yaw: g.yaw, + }); + } + let budget = Math.round(120 * theme.timberMul); + const order = rTimber.shuffle(wallRuns.slice()); + for (const run of order) { + if (budget <= 0) break; + const lenX = run.x1 - run.x0, lenZ = run.z1 - run.z0; + const len = Math.max(lenX, lenZ); + if (len < 4 || rTimber.next() > 0.6) continue; + const alongX = lenX >= lenZ; + const yaw = alongX ? 0 : Math.PI / 2; + const n = Math.max(2, Math.floor(len / 3)); + for (let k = 0; k <= n && budget > 0; k++) { + const f = n === 0 ? 0.5 : k / n; + items.timbers.push({ + x: run.x0 + lenX * f, y: FLOOR_Y + run.h / 2, z: run.z0 + lenZ * f, + sx: 0.3, sy: run.h, sz: 0.3, yaw: 0, + }); + budget--; + } + if (budget <= 0) break; + items.timbers.push({ + x: (run.x0 + run.x1) / 2, y: FLOOR_Y + run.h - 0.15, z: (run.z0 + run.z1) / 2, + sx: len, sy: 0.28, sz: 0.36, yaw, + }); + budget--; + } + } + // --- 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 @@ -816,7 +1099,7 @@ export function genDungeon({ seed = 1, rng = null } = {}) { 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, + items, portal, watch, character, theme: themeName, }; } diff --git a/flow-state/test/dungeon-modules.test.js b/flow-state/test/dungeon-modules.test.js index 8030489..99d2bd6 100644 --- a/flow-state/test/dungeon-modules.test.js +++ b/flow-state/test/dungeon-modules.test.js @@ -2,7 +2,7 @@ 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 { genDungeon, dungeonCharacter, dungeonTheme, THEMES, THEME_NAMES, 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'; @@ -179,7 +179,8 @@ test('items sit on open cells, off the walked line', () => { } 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'); + assert.ok(m.items.puddles.length === Math.max(2, Math.round(4 * THEMES[m.theme].puddleMul)), + `puddle count ${m.items.puddles.length} ignores theme ${m.theme}`); for (const g of [...m.items.gates, ...m.items.gaps, ...m.items.patrols]) { onOpen(g.x, g.z, 'hazard'); } @@ -320,6 +321,115 @@ const SLOW_DARK = { dynamicRange: 0.7, meanLoudness: 0.3, }; +// Summaries that pin the character mapping at its corners: a sealed crypt +// (dark + slow) and an airy ruin (bright + fast). +const SEALED = { bpm: 70, meanCentroid: 0.05, meanFlatness: 0.3, meanLoudness: 0.008, dynamicRange: 0.4 }; +const AIRY = { bpm: 140, meanCentroid: 0.9, meanFlatness: 0.7, meanLoudness: 0.0014, dynamicRange: 0.89 }; + +test('dungeonCharacter maps song to dungeon in documented ranges', () => { + for (const summary of [{}, SEALED, AIRY, SLOW_DARK, { bpm: 200, meanCentroid: 5, meanLoudness: -1 }]) { + const c = dungeonCharacter(summary); + for (const k of ['indoorBias', 'murk', 'sizeMul', 'dark', 'slow']) { + assert.ok(Number.isFinite(c[k]), `${k} not finite for ${JSON.stringify(summary)}`); + } + assert.ok(c.indoorBias >= 0 && c.indoorBias <= 1, 'indoorBias out of range'); + assert.ok(c.murk >= 0 && c.murk <= 1, 'murk out of range'); + assert.ok(c.sizeMul >= 1 && c.sizeMul <= 1.45, 'sizeMul out of range'); + } + // Defaults are the neutral middle, not an extreme. + const d0 = dungeonCharacter({}); + assert.ok(d0.indoorBias > 0.3 && d0.indoorBias < 0.9, 'default bias extreme'); + assert.ok(d0.murk > 0.1 && d0.murk < 0.8, 'default murk extreme'); + // Monotonic in darkness: darker songs seal and murk harder. + const brighter = dungeonCharacter({ ...SEALED, meanCentroid: 0.5 }); + const sealed = dungeonCharacter(SEALED); + assert.ok(sealed.indoorBias >= brighter.indoorBias, 'darker song less sealed'); + assert.ok(sealed.murk >= brighter.murk, 'darker song less murky'); + // Monotonic in tempo: slower songs seal harder and build bigger. + // (bpm 140 stays unfolded — above 150 the detector doubles, so the + // comparison stays in honest tempo territory.) + const faster = dungeonCharacter({ ...SEALED, bpm: 140 }); + assert.ok(sealed.indoorBias >= faster.indoorBias, 'slower song less sealed'); + assert.ok(sealed.sizeMul >= faster.sizeMul, 'slower song builds smaller'); +}); + +test('dark slow songs seal the dungeon: no outside views', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7), summary: SEALED }); + assert.equal(m.character.indoorBias, 1, 'sealed summary must pin bias'); + assert.ok(m.rooms.length >= 3, 'sealed map needs rooms too'); + for (const rm of m.rooms) { + assert.ok(rm.indoor, `sealed room ${rm.id} left open (${rm.zone})`); + } + for (let s = 0; s < m.route.totalLen; s += 2) { + const z = zoneAt(m, s); + assert.ok(z === 'hall' || z === 'room', `sealed route sees outside: ${z}`); + } +}); + +test('bright fast songs open the dungeon: some outside views', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7), summary: AIRY }); + assert.ok(m.character.indoorBias < 0.5, 'airy summary must drop bias'); + assert.ok(m.rooms.some((rm) => !rm.indoor), 'airy map sealed shut'); + assert.ok(m.rooms.some((rm) => rm.indoor), 'airy map has no interior at all'); +}); + +test('heavy songs build bigger rooms than light ones, same seed', () => { + const heavy = genDungeon({ seed: 7, rng: new Rng(7), summary: { bpm: 70, meanCentroid: 0.3, meanLoudness: 0.01 } }); + const light = genDungeon({ seed: 7, rng: new Rng(7), summary: { bpm: 140, meanCentroid: 0.6, meanLoudness: 0.001 } }); + assert.ok(heavy.character.sizeMul > light.character.sizeMul + 0.2, 'heft must move sizeMul'); + const mean = (p) => p.rooms.reduce((a, r) => a + r.w * r.h, 0) / p.rooms.length; + assert.ok(mean(heavy) > mean(light) * 1.2, + `heavy mean area ${mean(heavy).toFixed(1)} vs light ${mean(light).toFixed(1)}`); + assert.ok(heavy.rooms.length >= 3 && light.rooms.length >= 3, 'both maps need rooms'); +}); + +test('timber frames every gate and beams some walls, bounded', () => { + const m = genDungeon({ seed: 7, rng: new Rng(7) }); + assert.ok(m.items.gates.length > 0, 'seed 7 needs gates for this test'); + assert.ok(m.items.timbers.length > 0, 'no timber at all'); + assert.ok(m.items.timbers.length <= 140, `timber blowout: ${m.items.timbers.length}`); + for (const tb of m.items.timbers) { + for (const k of ['x', 'y', 'z', 'sx', 'sy', 'sz', 'yaw']) { + assert.ok(Number.isFinite(tb[k]), `timber ${k} not finite`); + } + assert.ok(tb.sx > 0 && tb.sy > 0 && tb.sz > 0, 'timber with dead dimension'); + } + // Every gate gets a door frame: two posts flanking it plus a lintel over. + for (const g of m.items.gates) { + const rx = Math.cos(g.yaw), rz = -Math.sin(g.yaw); + const near = m.items.timbers.filter((tb) => + Math.hypot(tb.x - g.x, tb.z - g.z) < 1.6); + assert.ok(near.length >= 3, `gate at ${g.x.toFixed(1)},${g.z.toFixed(1)} has ${near.length} timbers`); + const posts = near.filter((tb) => Math.abs(tb.sy - 2.7) < 1e-9); + assert.equal(posts.length, 2, `gate frame wants 2 posts, has ${posts.length}`); + for (const side of [-1.15, 1.15]) { + assert.ok(posts.some((tb) => + Math.abs((tb.x - g.x) * rx + (tb.z - g.z) * rz - side) < 0.35), + `gate frame missing post at lateral ${side}`); + } + assert.ok(near.some((tb) => Math.abs(tb.sx - 2.75) < 1e-9 && tb.y > -1.15 + 2.4), + 'gate frame missing its lintel'); + } + // Wall beams sit on wall runs (plate centres), never floating. + const beams = m.items.timbers.filter((tb) => Math.abs(tb.sx - 0.3) > 1e-9 || Math.abs(tb.sz - 0.3) > 1e-9); + assert.ok(beams.length > 0, 'no wall beams sampled'); + const onRun = (tb) => m.wallRuns.some((r) => { + const len = Math.max(r.x1 - r.x0, r.z1 - r.z0); + const d = len > 0 + ? Math.abs((tb.x - r.x0) * (r.z1 - r.z0) - (tb.z - r.z0) * (r.x1 - r.x0)) / len + : Math.hypot(tb.x - r.x0, tb.z - r.z0); + const within = tb.x >= Math.min(r.x0, r.x1) - 0.4 && tb.x <= Math.max(r.x0, r.x1) + 0.4 + && tb.z >= Math.min(r.z0, r.z1) - 0.4 && tb.z <= Math.max(r.z0, r.z1) + 0.4; + return within && d < 0.35; + }); + for (const tb of beams) { + // Door-frame pieces live at gates, not on runs — skip those. + const atGate = m.items.gates.some((g) => Math.hypot(tb.x - g.x, tb.z - g.z) < 1.6); + if (atGate) continue; + assert.ok(onRun(tb), `beam at ${tb.x.toFixed(1)},${tb.z.toFixed(1)} floats off its wall`); + } +}); + function slowPersonalityAt(seed) { const rng = new Rng(seed); return generatePersonality(SLOW_DARK, rng.fork('personality'), () => 10, 4); @@ -353,3 +463,114 @@ test('VenueGenerator plans the dungeon map when the dungeon is picked', () => { }); if (fast.archetype !== 'dungeon') assert.equal(fast.dungeon, null); }); + +const THEME_CORNERS = { + crypt: { bpm: 62, meanCentroid: 0.02, meanFlatness: 0.3, meanLoudness: 0.008, dynamicRange: 0.9 }, + overgrown: { bpm: 100, meanCentroid: 0.45, meanFlatness: 0.85, meanLoudness: 0.0005, dynamicRange: 0.5 }, + ember: { bpm: 150, meanCentroid: 0.5, meanFlatness: 0.3, meanLoudness: 0.0022, dynamicRange: 0.4 }, + flooded: { bpm: 120, meanCentroid: 0.9, meanFlatness: 0.2, meanLoudness: 0.001, dynamicRange: 0.9 }, + windswept: { bpm: 170, meanCentroid: 0.85, meanFlatness: 0.2, meanLoudness: 0.001, dynamicRange: 0.4 }, +}; + +test('dungeonTheme is deterministic and names a known theme', () => { + for (const s of [{}, ...Object.values(THEME_CORNERS)]) { + assert.equal(dungeonTheme(s, 7), dungeonTheme(s, 7), 'theme unstable across calls'); + assert.ok(THEME_NAMES.includes(dungeonTheme(s, 7)), `unknown theme for ${JSON.stringify(s)}`); + assert.ok(THEMES[dungeonTheme(s, 7)], 'theme record missing'); + } + // Same song, different seeds may vary (jitter), but the pick stays valid. + for (const seed of [1, 2, 3, 99, 12345]) { + assert.ok(THEME_NAMES.includes(dungeonTheme(THEME_CORNERS.ember, seed))); + } +}); + +test('each theme wins its home corner', () => { + for (const [want, s] of Object.entries(THEME_CORNERS)) { + assert.equal(dungeonTheme(s, 7), want, `${want} corner picked ${dungeonTheme(s, 7)}`); + assert.equal(dungeonTheme(s, 99), want, `${want} corner unstable across seeds`); + } +}); + +test('theme sweep reaches every theme', () => { + const seen = new Set(); + for (let bpm = 60; bpm <= 180; bpm += 20) { + for (const cent of [0.1, 0.4, 0.7, 0.95]) { + for (const flat of [0.1, 0.6, 0.9]) { + for (const loud of [0.0005, 0.002]) { + seen.add(dungeonTheme({ bpm, meanCentroid: cent, meanFlatness: flat, meanLoudness: loud, dynamicRange: 0.5 }, 7)); + } + } + } + } + assert.deepEqual([...seen].sort(), [...THEME_NAMES].sort(), + `sweep reaches only ${[...seen].join(',')}`); +}); + +test('theme records are well-formed', () => { + for (const [name, th] of Object.entries(THEMES)) { + for (const k of ['stoneMul', 'floorMul', 'torch', 'fogTint', 'wood']) { + assert.ok(Array.isArray(th[k]) && th[k].length === 3, `${name}.${k} not an RGB triple`); + assert.ok(th[k].every((v) => Number.isFinite(v) && v >= 0 && v <= 1.2), `${name}.${k} out of range`); + } + for (const k of ['propIndoor', 'propOutdoor']) { + assert.ok(Array.isArray(th[k]) && th[k].length > 0, `${name}.${k} empty`); + assert.ok(th[k].every((t) => PROP_BUILDERS[t]), `${name}.${k} names an unbuildable prop`); + } + for (const k of ['vegMul', 'timberMul', 'puddleMul']) { + assert.ok(Number.isFinite(th[k]) && th[k] > 0, `${name}.${k} not a positive density`); + } + } +}); + +test('theme reshapes the plan: dressing, planting, timber, puddles', () => { + // Ember-forcing vs crypt-forcing summaries, same seed: only the theme differs. + const ember = genDungeon({ seed: 7, rng: new Rng(7), summary: THEME_CORNERS.ember }); + const crypt = genDungeon({ seed: 7, rng: new Rng(7), summary: THEME_CORNERS.crypt }); + assert.equal(ember.theme, 'ember'); + assert.equal(crypt.theme, 'crypt'); + const stal = (m) => m.items.props.filter((p) => p.type === 'stalagmite').length; + assert.ok(stal(ember) >= stal(crypt), `ember ${stal(ember)} vs crypt ${stal(crypt)} stalagmites`); + assert.ok(ember.items.puddles.length <= crypt.items.puddles.length, + `ember ${ember.items.puddles.length} vs crypt ${crypt.items.puddles.length} puddles`); + assert.ok(ember.items.timbers.length < crypt.items.timbers.length, + `ember ${ember.items.timbers.length} vs crypt ${crypt.items.timbers.length} timbers`); +}); + +test('facade builds themed materials and lights', async () => { + const T = await import('three'); + const { dungeon } = await import('../src/venues/dungeon.js'); + const summary = THEME_CORNERS.ember; + const map = genDungeon({ seed: 7, rng: new Rng(7), summary }); + const theme = THEMES[map.theme]; + const scene = new T.Scene(); + const instance = dungeon.build({ + scene, seed: 7, params: {}, + actorSpec: null, + venueSpec: { seed: 5, dungeon: { map }, population: { count: 2 }, paletteMap: [0, 1, 2, 3], perSection: [] }, + THREE: T, + }); + assert.ok(instance, 'build returned nothing'); + const flame = instance.flameMat.emissive; + assert.ok(Math.abs(flame.r - theme.torch[0]) < 1e-6 + && Math.abs(flame.g - theme.torch[1]) < 1e-6 + && Math.abs(flame.b - theme.torch[2]) < 1e-6, + `flame ${flame.r.toFixed(2)},${flame.g.toFixed(2)},${flame.b.toFixed(2)} != theme ${theme.torch}`); + const tl = instance.torch.color; + assert.ok(Math.abs(tl.r - theme.torch[0]) < 1e-6 + && Math.abs(tl.g - theme.torch[1]) < 1e-6 + && Math.abs(tl.b - theme.torch[2]) < 1e-6, + 'torch light ignores theme'); + // Per-frame palette tint carries the theme multipliers. + dungeon.update({ + instance, scene, camera: new T.PerspectiveCamera(), + timeline: { time: 1, frame: 60, progress: 0.01 }, + features: {}, params: {}, palette: [[1, 1, 1]], + personality: null, framing: null, opacity: 1, + actorSpec: null, venueSpec: instance.venueSpec, venueState: null, + THREE: T, + }); + const st = instance.stoneMat.color; + const fx = theme.stoneMul[0]; + assert.ok(Math.abs(st.r / fx - (0.42 * (0.45 + 0.7 * 0.75) + 0.07)) < 0.05, + 'stone tint does not carry the theme multiplier'); +}); diff --git a/flow-state/test/venue.test.js b/flow-state/test/venue.test.js index f631c1b..2d27a99 100644 --- a/flow-state/test/venue.test.js +++ b/flow-state/test/venue.test.js @@ -265,7 +265,7 @@ function awaitImportThree() { test('dungeon declares directCamera and plants its cameras on open cells', () => { const THREE = awaitImportThree(); - return THREE.then((T) => { + return THREE.then(async (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'); @@ -383,5 +383,45 @@ test('dungeon declares directCamera and plants its cameras on open cells', () => assert.ok(Math.abs(h - en.height) < 0.05, `${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`); } + // Murk dims the venue: the same update on a murky map must read + // darker lights and closer fog than on a clear one. + { + const { genDungeon } = await import('../src/venues/dungeon/map.js'); + const murky = genDungeon({ seed: 4242, summary: { bpm: 70, meanCentroid: 0.05, meanLoudness: 0.008 } }); + const clear = genDungeon({ seed: 4242, summary: { bpm: 170, meanCentroid: 0.6, meanLoudness: 0.001 } }); + assert.ok(murky.character.murk > clear.character.murk + 0.3, 'murk spread too small'); + const buildMurky = (map) => { + const sc = new T.Scene(); + const spec = { ...dungeonSpec, seed: 6, dungeon: { map } }; + const inst = dungeonMod.build({ + scene: sc, seed: 4242, params: {}, actorSpec: actors.monolith, + venueSpec: spec, THREE: T, + }); + const cam = stubCam(); + dungeonMod.update({ + instance: inst, scene: sc, camera: cam, + timeline: { time: 10, frame: 600, progress: 0.05 }, + 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: spec, + venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }, + THREE: T, + }); + return { inst, fogNear: sc.fog ? sc.fog.near : Infinity }; + }; + const murkyBuilt = buildMurky(murky); + const clearBuilt = buildMurky(clear); + const murkyInst = murkyBuilt.inst, clearInst = clearBuilt.inst; + assert.ok(murkyInst.ambient.intensity < clearInst.ambient.intensity, + 'murk must dim ambient'); + assert.ok(murkyInst.key.intensity < clearInst.key.intensity, + 'murk must dim the key'); + assert.ok(murkyInst.camFill.intensity < clearInst.camFill.intensity, + 'murk must dim the lantern fill'); + assert.ok(murkyBuilt.fogNear < clearBuilt.fogNear, + 'murk must pull fog closer'); + } }); });