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