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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user