Dungeon themes: per-song visual identity (crypt/overgrown/ember/flooded/windswept)

One album must not render the same dungeon twice. dungeonTheme() scores
the measured summary per theme — pace terms gate on tempoConfidence so
doubled detectors never read fast, crypt is penalised for blazing
masters — plus small seeded jitter so ambiguous songs vary across seeds
while decided races stay decided. The plan carries the theme name;
dressing mix, planting/timber/puddle densities follow it, and the facade
builds all materials (stone/floor multipliers, torch + light color, wood,
fog tint) from the same record.

Calibrated bank spread is 4/4/4/4/1 (windswept holds runner); corners
stable across seeds; plan reshaping, facade wiring, and murk-vs-clear
gates covered by tests.
This commit is contained in:
Dejvino
2026-10-08 22:20:04 +02:00
parent c96e810d44
commit 6a2af2e45f
7 changed files with 648 additions and 55 deletions
+11
View File
@@ -124,6 +124,17 @@ Actors and objects change only at camera cuts:
- Absolute animation speed follows the song: motion bias is - Absolute animation speed follows the song: motion bias is
tempo-dominated and every `rate: true` param scales by per-track tempo-dominated and every `rate: true` param scales by per-track
`rateScale` — slow songs got fast scenes before this. `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 ## 7. Grounds and composition
+9 -3
View File
@@ -60,7 +60,7 @@ look.venue.module = dungeon;
look.venue.name = dungeon.name; look.venue.name = dungeon.name;
look.venue.archetype = dungeon.archetype; look.venue.archetype = dungeon.archetype;
const mapSeed = mapSeedParam !== null ? Number(mapseedParam) : (look.venue.seed ?? 7); 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 }; look.venue.dungeon = { map };
// ---------------------------------------------------------- 2D map // ---------------------------------------------------------- 2D map
@@ -69,6 +69,9 @@ const mc = document.getElementById('map');
const mg = mc.getContext('2d'); const mg = mc.getContext('2d');
const S = mc.width / map.w; 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`; 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 = `<div class="kv"><span>character</span><b>seal ${(ch.indoorBias ?? 0).toFixed(2)} · murk ${(ch.murk ?? 0).toFixed(2)} · size ×${(ch.sizeMul ?? 1).toFixed(2)}</b></div>` +
`<div class="kv"><span>theme</span><b>${map.theme ?? 'crypt'}</b></div>`;
for (let y = 0; y < map.h; y++) { for (let y = 0; y < map.h; y++) {
for (let x = 0; x < map.w; x++) { for (let x = 0; x < map.w; x++) {
if (!map.grid[y * map.w + x]) { mg.fillStyle = '#07090d'; } 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.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(); 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) { if (map.portal) {
dot(map.portal.x, map.portal.z, '#f0abfc', 5); dot(map.portal.x, map.portal.z, '#f0abfc', 5);
mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath(); mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath();
@@ -123,7 +129,7 @@ mg.fillStyle = '#fff';
map.rooms.forEach((rm) => { map.rooms.forEach((rm) => {
mg.fillText(String(rm.id), (rm.x + 0.3) * S, (rm.y + 1) * S); 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) => map.rooms.map((rm) =>
`<div class="kv"><span>room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}</span><b>${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}</b></div>`).join('') + `<div class="kv"><span>room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}</span><b>${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}</b></div>`).join('') +
`<div class="legend"> `<div class="legend">
@@ -135,7 +141,7 @@ document.getElementById('mapmeta').innerHTML =
<i style="background:#166534"></i>veg · <i style="background:#6b7280"></i>boulder · <i style="background:#166534"></i>veg · <i style="background:#6b7280"></i>boulder ·
<i style="background:#f87171"></i>lurker · <i style="background:#fb923c"></i>sentry · <i style="background:#f87171"></i>lurker · <i style="background:#fb923c"></i>sentry ·
<i style="background:#facc15"></i>critter · <i style="background:#a5f3fc"></i>wisp · <i style="background:#facc15"></i>critter · <i style="background:#a5f3fc"></i>wisp ·
<i style="background:#fde047"></i>loot (halt)</div></div>`; <i style="background:#fde047"></i>loot (halt) · <i style="background:#92400e"></i>timber</div></div>`;
// ---------------------------------------------------------- 3D stills // ---------------------------------------------------------- 3D stills
// Fake viz feed so windows/portal/puddles show their IMAGE (the compositor // Fake viz feed so windows/portal/puddles show their IMAGE (the compositor
+3 -2
View File
@@ -171,12 +171,13 @@ export function generateVenue({ summary = {}, rng, personality = null, director
// Dungeon sub-plans: the floor plan itself — grid, rooms, walls, route, // Dungeon sub-plans: the floor plan itself — grid, rooms, walls, route,
// items, portal, watch cells. Pure data, seeded, serialisable; the facade // items, portal, watch cells. Pure data, seeded, serialisable; the facade
// builds it. Other archetypes get dungeon:null. Forked so existing draws // 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; let dungeonPlan = null;
if (picked.archetype === 'dungeon') { if (picked.archetype === 'dungeon') {
const dRng = rng.fork('dungeon:plan'); const dRng = rng.fork('dungeon:plan');
dungeonPlan = { dungeonPlan = {
map: genDungeon({ seed, rng: dRng }), map: genDungeon({ seed, rng: dRng, summary }),
}; };
} }
+52 -21
View File
@@ -15,7 +15,7 @@ import { actorToGeometry } from '../actors/meshes.js';
import { generateActor } from '../actors/ActorGenerator.js'; import { generateActor } from '../actors/ActorGenerator.js';
import { buildSky, paintSky } from './sky.js'; import { buildSky, paintSky } from './sky.js';
import { Rng } from '../engine/rng.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 { PROP_BUILDERS, PROP_TINTS, buildPortal, animateDressing } from './dungeon/props.js';
import { HAZARD_BUILDERS, HAZARD_TINTS, updateHazards, avoidanceOffset } from './dungeon/hazards.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. // The map IS the dungeon: grid first, everything built from it.
const map = (vsDun && vsDun.map) || genDungeon({ seed, rng }); const map = (vsDun && vsDun.map) || genDungeon({ seed, rng });
const L = map.route.totalLen; 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). // Stone + flame shared materials (tinted per frame in update).
const stoneMat = new T.MeshStandardMaterial({ const stoneMat = new T.MeshStandardMaterial({
@@ -155,7 +158,7 @@ export const dungeon = {
}); });
const flameMat = new T.MeshStandardMaterial({ const flameMat = new T.MeshStandardMaterial({
color: new T.Color(0.1, 0.05, 0.02), 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, roughness: 0.4,
}); });
const windowMat = new T.MeshStandardMaterial({ const windowMat = new T.MeshStandardMaterial({
@@ -163,6 +166,12 @@ export const dungeon = {
emissive: new T.Color(1, 1, 1), emissiveIntensity: 1.1, emissive: new T.Color(1, 1, 1), emissiveIntensity: 1.1,
roughness: 0.5, metalness: 0.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 // Floor: one continuous slab under the whole grid. A plane cannot
// have holes, so the walk always has ground; walls sit on top. // have holes, so the walk always has ground; walls sit on top.
@@ -197,13 +206,22 @@ export const dungeon = {
} }
scene.add(shell); 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 // Dressing, all seated once from map cells — static architecture, so
// nothing here ever moves; per-frame work is glow only (see update). // nothing here ever moves; per-frame work is glow only (see update).
// Torches burn on the walls the route passes; where the route runs // Torches burn on the walls the route passes; where the route runs
// open ground they stand on iron posts instead of floating. // open ground they stand on iron posts instead of floating.
const dressing = []; const dressing = [];
for (const tc of map.items.torches) { 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.position.set(tc.x, 0, tc.z);
torch.rotation.y = tc.yaw; torch.rotation.y = tc.yaw;
if (tc.post) { if (tc.post) {
@@ -435,7 +453,7 @@ export const dungeon = {
key.position.set(3.5, 5.0, 6.0); key.position.set(3.5, 5.0, 6.0);
key.castShadow = false; key.castShadow = false;
scene.add(key); 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); scene.add(torch);
// Lantern fill: a warm light riding with the camera (see update). // Lantern fill: a warm light riding with the camera (see update).
const camFill = new T.PointLight(0xffe0b8, 0.7, 20); const camFill = new T.PointLight(0xffe0b8, 0.7, 20);
@@ -458,7 +476,7 @@ export const dungeon = {
scene.add(party); scene.add(party);
const inst = { const inst = {
map, shell, stoneMat, floorMat, flameMat, windowMat, map, shell, stoneMat, floorMat, flameMat, windowMat, woodMat,
puddleMat, puddles, portal, portalDiscMat: portal.discMat, puddleMat, puddles, portal, portalDiscMat: portal.discMat,
hero, party, torch, dressing, hazards, hazardItems, hazardGates, hero, party, torch, dressing, hazards, hazardItems, hazardGates,
cast, lootGroups, cast, lootGroups,
@@ -500,6 +518,11 @@ export const dungeon = {
const L = instance.L; const L = instance.L;
const c0 = pal[0]; const c0 = pal[0];
const fieldLight = vState ? vState.light : 0.7; 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 // --- walk clock: the interrupted odometer. Gait is constant per
// video (see build); halts pause it at loot. Pure f(t). // 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. // Stone takes the palette; torch flames arc with the section light.
// Generous emissive floor (chamber-junk pattern): on the darkest // Generous emissive floor (chamber-junk pattern): on the darkest
// songs lit-only stone goes black and the walk moves through nothing. // songs lit-only stone goes black and the walk moves through nothing.
// Stone takes the palette times the theme: the palette keeps songs
// distinct, the theme keeps dungeons distinct — multiplicative, so
// neither can wash the other out.
const tintM = 0.45 + fieldLight * 0.75; const 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); const theme = THEMES[instance.map.theme] ?? THEMES.crypt;
instance.stoneMat.emissive.setRGB(c0[0] * 0.16, c0[1] * 0.16, c0[2] * 0.18); const sm = theme.stoneMul, fm = theme.floorMul;
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.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.floorMat.emissive.setRGB(c0[0] * 0.16, c0[1] * 0.16, c0[2] * 0.18); instance.stoneMat.emissive.setRGB(c0[0] * 0.16 * sm[0], c0[1] * 0.16 * sm[1], c0[2] * 0.18 * sm[2]);
if (instance.key) instance.key.intensity = 1.0 + fieldLight * 1.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]);
if (instance.ambient) instance.ambient.intensity = 0.6 + fieldLight * 0.55; 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, // Lantern fill riding with the camera: nearby stone always reads,
// whichever way the key falls. Positioned from the camera, which is // whichever way the key falls. Positioned from the camera, which is
// itself analytic f(t) — seek === playback still holds. // itself analytic f(t) — seek === playback still holds.
if (instance.camFill) { if (instance.camFill) {
instance.camFill.position.set(camera.position.x, 1.2, camera.position.z + 1.5); 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. // 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) { if (scene.fog) {
const fogMul = (vState ? vState.fog : 0.35) * zoneFogMul; 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.near = fogDepth;
scene.fog.far = fogDepth + 30; scene.fog.far = fogDepth + 30;
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null; const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
if (mid) scene.fog.color.copy(mid); 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, c0[1] * 0.05, c0[2] * 0.07); 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 // Window slits + portal disc glow with the section light; the IMAGE
// is the bound viz texture. // is the bound viz texture.
@@ -684,14 +712,15 @@ export const dungeon = {
// The bearer never renders from his own eyes: first-person hides // The bearer never renders from his own eyes: first-person hides
// him (a body at the lens clips); the party leads ahead carrying // him (a body at the lens clips); the party leads ahead carrying
// lanterns — far enough ahead and aside that no companion fills // 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; instance.hero.visible = opacity > 0.01 && !firstPerson;
const kids = instance.party.children; const kids = instance.party.children;
for (let k = 0; k < kids.length; k++) { 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 cs = wrapS(heroS + off, L);
const cp = pathAt(instance.map, cs); 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( kids[k].position.set(
cp.x + avoid * 0.5 + aside + Math.sin(t * 0.5 + k * 2.1) * 0.15, 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, 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 — // 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.torch.position.set(
instance.hero.position.x + 0.42, 1.5, instance.hero.position.z); 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)) 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) { if (instance.flameMat) {
instance.flameMat.emissiveIntensity = 2.2 * (0.4 + 0.6 * fieldLight) instance.flameMat.emissiveIntensity = 2.2 * (0.4 + 0.6 * fieldLight)
* (0.88 + 0.12 * Math.sin(t * 9.3)); * (0.88 + 0.12 * Math.sin(t * 9.3));
+309 -26
View File
@@ -13,32 +13,201 @@
// (BFS-verified), not a ribbon bent through blockwork. // (BFS-verified), not a ribbon bent through blockwork.
// //
// Contract: // 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) // pathAt(plan, s) -> { x, yaw } (world units, wraps)
// roomAt(plan, s) -> roomId (>=0) | -1 (corridor) // roomAt(plan, s) -> roomId (>=0) | -1 (corridor)
// zoneAt(plan, s) -> zone name // zoneAt(plan, s) -> zone name
// CELL: world units per cell // 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: // 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}] // 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) // wallRuns: [{x0,z0,x1,z1,h}] world-space merged runs (axis aligned)
// ceilRects: [{x0,z0,x1,z1,y}] world-space ceiling slabs (indoor only) // ceilRects: [{x0,z0,x1,z1,y}] world-space ceiling slabs (indoor only)
// route: { pts:[{x,z}], cum:[...], totalLen, roomIds:[], zones:[] } // 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} } // 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 { Rng } from '../../engine/rng.js';
import { smooth01 } from './shared.js'; import { smooth01 } from './shared.js';
export const CELL = 2.0; export const CELL = 2.0;
const FLOOR_Y = -1.15;
const GW = 44; const GW = 44;
const GH = 44; const GH = 44;
const ROOM_TARGET = 8; const ROOM_TARGET = 8;
const ROOM_MIN = 4; const ROOM_MIN = 5;
const ROOM_MAX = 8; 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 zoneIndoor = (z) => z === 'hall' || z === 'room';
const idx = (w, x, y) => y * w + x; const idx = (w, x, y) => y * w + x;
@@ -121,16 +290,25 @@ function losClear(open, w, h, x0, y0, x1, y1) {
// ------------------------------------------------------------------ plan // ------------------------------------------------------------------ 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 r = rng ? rng.fork('dungeon:map') : new Rng((seed >>> 0) || 1).fork('dungeon:map');
const sd = (seed >>> 0) || 1; 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 // --- rooms: rejection-sampled rects with a 1-cell moat between them, so
// corridors always have somewhere to go and rooms never share walls. // 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 = []; const rooms = [];
for (let attempt = 0; attempt < 240 && rooms.length < ROOM_TARGET; attempt++) { for (let attempt = 0; attempt < ROOM_ATTEMPTS && rooms.length < ROOM_TARGET; attempt++) {
const w = r.int(ROOM_MIN, ROOM_MAX); const w = Math.max(3, Math.round(r.int(ROOM_MIN, ROOM_MAX) * sizeMul));
const h = r.int(ROOM_MIN, ROOM_MAX); const h = Math.max(3, Math.round(r.int(ROOM_MIN, ROOM_MAX) * sizeMul));
const x = r.int(1, GW - w - 1); const x = r.int(1, GW - w - 1);
const y = r.int(1, GH - h - 1); const y = r.int(1, GH - h - 1);
let ok = true; 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 // --- zones: per-room draw against the song's indoorBias. A sealed
// 4+ rooms holds halls, rooms, forest and castle walls alike. // 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 rZones = new Rng((sd ^ 0x20e5) >>> 0 || 1);
const order = rooms.slice(); const order = rooms.slice();
for (let i = order.length - 1; i > 0; i--) { for (let i = order.length - 1; i > 0; i--) {
const j = rZones.int(0, i); const j = rZones.int(0, i);
[order[i], order[j]] = [order[j], order[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) => { order.forEach((rm, i) => {
rm.zone = ZONE_NAMES[i % ZONE_NAMES.length]; let zone;
rm.indoor = zoneIndoor(rm.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 zoneOf = new Array(GW * GH).fill('hall');
const indoorOf = new Uint8Array(GW * GH); const indoorOf = new Uint8Array(GW * GH);
for (const rm of rooms) { for (const rm of rooms) {
@@ -476,10 +675,14 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
// --- items. // --- items.
const rItems = new Rng((sd ^ 0x171e5) >>> 0 || 1); 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 // Boundary planting: bushes, trees and boulders where an outdoor wall
// would stand — forest edges grow green, castle-wall edges pile rock. // would stand — forest edges grow green, castle-wall edges pile rock.
// Kind and jitter hash from the cell — pure f(cell), no stream. // 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()) { for (const { x, y, zone } of vegCells.values()) {
const hsh = ((x * 73856093) ^ (y * 19349663)) >>> 0; const hsh = ((x * 73856093) ^ (y * 19349663)) >>> 0;
const p = toWorld(x, y); 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. // and throw jitter and scale out of range on high-bit cells.
const jx = (((hsh >>> 0) % 100) / 100 - 0.5) * 0.8; const jx = (((hsh >>> 0) % 100) / 100 - 0.5) * 0.8;
const jz = (((hsh >>> 8) % 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 rocky = zone === 'wallwalk';
const kind = rocky const kind = rocky
? (hsh % 3 === 2 ? 'bush' : 'boulder') ? (hsh % 3 === 2 ? 'bush' : 'boulder')
: (hsh % 3 === 0 ? 'tree' : 'bush'); : (hsh % 3 === 0 ? 'tree' : 'bush');
items.vegetation.push({ const sc = 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6;
x: p.x + jx, z: p.z + jz, plantAt(x, y, kind, sc, jx, jz);
kind, if (theme.vegMul > 1 && (((hsh >>> 20) % 100) / 100) < theme.vegMul - 1) {
s: 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6, 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 // Portal first: largest indoor room, the cell farthest off-route. Props
// placed afterwards keep one cell of clearance round it. // 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 (distToRoute(x, y) < 2) continue;
if (portalCell && Math.abs(portalCell.x - x) + Math.abs(portalCell.y - y) < 2) continue; if (portalCell && Math.abs(portalCell.x - x) + Math.abs(portalCell.y - y) < 2) continue;
const p = toWorld(x, y); 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 const type = rm.indoor
? rItems.pick(['rubble', 'furniture', 'rubble', 'banner', 'stalagmite']) ? rItems.pick(theme.propIndoor)
: rItems.pick(['trees', 'rubble', 'rampart', 'trees']); : rItems.pick(theme.propOutdoor);
items.props.push({ x: p.x, z: p.z, yaw: rItems.next() * Math.PI * 2, type }); items.props.push({ x: p.x, z: p.z, yaw: rItems.next() * Math.PI * 2, type });
placed++; placed++;
} }
@@ -590,9 +824,10 @@ export function genDungeon({ seed = 1, rng = null } = {}) {
yaw: Math.atan2(-px, -pz), yaw: Math.atan2(-px, -pz),
}); });
} }
// Puddles: 4 spread round the loop, slightly off the walked line. // Puddles spread round the loop, slightly off the walked line —
for (let k = 0; k < 4; k++) { // more of them in the flooded theme, fewer in the ember barrens.
const i = Math.floor((k + 0.5) / 4 * pts.length) % pts.length; 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 p = pts[i], q = pts[(i + 1) % pts.length];
const dx = q.x - p.x, dz = q.z - p.z; const dx = q.x - p.x, dz = q.z - p.z;
const l = Math.hypot(dx, dz) || 1; 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 }); 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, // --- cast: entities that live on the map, loot worth stopping for,
// and the halts where the walk breaks step. All analytic f(t): entity // and the halts where the walk breaks step. All analytic f(t): entity
// poses are pure functions of time (see entityPose), halts bend the // 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 })), 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, wallRuns, ceilRects,
route: { pts, cum, totalLen, roomIds: sampleRoom, zones: sampleZone }, route: { pts, cum, totalLen, roomIds: sampleRoom, zones: sampleZone },
items, portal, watch, items, portal, watch, character, theme: themeName,
}; };
} }
+223 -2
View File
@@ -2,7 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { Rng } from '../src/engine/rng.js'; 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 { PROP_BUILDERS } from '../src/venues/dungeon/props.js';
import { HAZARD_BUILDERS, avoidanceOffset } from '../src/venues/dungeon/hazards.js'; import { HAZARD_BUILDERS, avoidanceOffset } from '../src/venues/dungeon/hazards.js';
import { generateVenue } from '../src/venues/VenueGenerator.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.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.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]) { for (const g of [...m.items.gates, ...m.items.gaps, ...m.items.patrols]) {
onOpen(g.x, g.z, 'hazard'); onOpen(g.x, g.z, 'hazard');
} }
@@ -320,6 +321,115 @@ const SLOW_DARK = {
dynamicRange: 0.7, meanLoudness: 0.3, 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) { function slowPersonalityAt(seed) {
const rng = new Rng(seed); const rng = new Rng(seed);
return generatePersonality(SLOW_DARK, rng.fork('personality'), () => 10, 4); 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); 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');
});
+41 -1
View File
@@ -265,7 +265,7 @@ function awaitImportThree() {
test('dungeon declares directCamera and plants its cameras on open cells', () => { test('dungeon declares directCamera and plants its cameras on open cells', () => {
const THREE = awaitImportThree(); const THREE = awaitImportThree();
return THREE.then((T) => { return THREE.then(async (T) => {
const dungeonMod = venues.find((v) => v.name === 'Dungeon'); const dungeonMod = venues.find((v) => v.name === 'Dungeon');
assert.ok(dungeonMod, 'Dungeon venue not registered'); assert.ok(dungeonMod, 'Dungeon venue not registered');
assert.equal(dungeonMod.directCamera, true, 'Dungeon must own its camera rig'); 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, assert.ok(Math.abs(h - en.height) < 0.05,
`${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`); `${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');
}
}); });
}); });