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
+52 -21
View File
@@ -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));