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.
1250 lines
55 KiB
JavaScript
1250 lines
55 KiB
JavaScript
// Dungeon map — a real 2D floor plan first, 3D built from it.
|
||
//
|
||
// Standard roguelike construction, all seeded (no Math.random, no wall-clock):
|
||
// rejection-sampled non-overlapping rectangular rooms, Kruskal MST over room
|
||
// centres plus loop edges, L-shaped corridors carved between linked rooms.
|
||
// The walk route is a closed travelling tour (greedy nearest-room BFS legs,
|
||
// returning to the start), smoothed and resampled to even arclength.
|
||
//
|
||
// Why grid-first: every wall is derived from cell occupancy (a wall run exists
|
||
// exactly where open meets solid), so the shell has no holes by construction;
|
||
// the camera only ever stands on open cells (route or room watch cells), so
|
||
// it can never be inside a wall; and the route is a real walkable path
|
||
// (BFS-verified), not a ribbon bent through blockwork.
|
||
//
|
||
// Contract:
|
||
// 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, 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, ... }
|
||
// 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 = 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 zoneIndoor = (z) => z === 'hall' || z === 'room';
|
||
|
||
const idx = (w, x, y) => y * w + x;
|
||
const inBounds = (w, h, x, y) => x >= 0 && y >= 0 && x < w && y < h;
|
||
|
||
// ------------------------------------------------------------- union-find
|
||
// Kruskal MST over room centres: the cheapest standard way to guarantee every
|
||
// room joins one connected graph.
|
||
|
||
function kruskal(n, edges) {
|
||
const parent = [];
|
||
for (let i = 0; i < n; i++) parent.push(i);
|
||
const find = (a) => (parent[a] === a ? a : (parent[a] = find(parent[a])));
|
||
const mst = [];
|
||
const rest = [];
|
||
for (const e of edges) {
|
||
const ra = find(e.a), rb = find(e.b);
|
||
if (ra !== rb) { parent[ra] = rb; mst.push(e); }
|
||
else rest.push(e);
|
||
}
|
||
return { mst, rest };
|
||
}
|
||
|
||
// ------------------------------------------------------------------- BFS
|
||
// 4-neighbour shortest paths on open cells. Deterministic: fixed neighbour
|
||
// order, first-found wins, so the same grid always yields the same paths.
|
||
|
||
const NB = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||
|
||
function bfsPath(open, w, h, sx, sy, tx, ty) {
|
||
if (sx === tx && sy === ty) return [{ x: sx, y: sy }];
|
||
const prev = new Int32Array(w * h).fill(-1);
|
||
const qx = [sx], qy = [sy];
|
||
prev[idx(w, sx, sy)] = idx(w, sx, sy);
|
||
let head = 0;
|
||
while (head < qx.length) {
|
||
const cx = qx[head], cy = qy[head];
|
||
head++;
|
||
if (cx === tx && cy === ty) break;
|
||
for (const [dx, dy] of NB) {
|
||
const nx = cx + dx, ny = cy + dy;
|
||
if (!inBounds(w, h, nx, ny) || !open[idx(w, nx, ny)]) continue;
|
||
if (prev[idx(w, nx, ny)] !== -1) continue;
|
||
prev[idx(w, nx, ny)] = idx(w, cx, cy);
|
||
qx.push(nx); qy.push(ny);
|
||
}
|
||
}
|
||
if (prev[idx(w, tx, ty)] === -1) return null;
|
||
const path = [];
|
||
let c = idx(w, tx, ty);
|
||
while (c !== idx(w, sx, sy)) {
|
||
path.push({ x: c % w, y: Math.floor(c / w) });
|
||
c = prev[c];
|
||
}
|
||
path.push({ x: sx, y: sy });
|
||
path.reverse();
|
||
return path;
|
||
}
|
||
|
||
function bfsDist(open, w, h, sx, sy, tx, ty) {
|
||
const p = bfsPath(open, w, h, sx, sy, tx, ty);
|
||
return p ? p.length : Infinity;
|
||
}
|
||
|
||
// ------------------------------------------------------------------ LOS
|
||
// Bresenham cell walk between cell centres: every crossed cell must be open.
|
||
|
||
function losClear(open, w, h, x0, y0, x1, y1) {
|
||
let dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
|
||
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
|
||
let err = dx - dy, x = x0, y = y0;
|
||
for (;;) {
|
||
if (!inBounds(w, h, x, y) || !open[idx(w, x, y)]) return false;
|
||
if (x === x1 && y === y1) return true;
|
||
const e2 = 2 * err;
|
||
if (e2 > -dy) { err -= dy; x += sx; }
|
||
if (e2 < dx) { err += dx; y += sy; }
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ plan
|
||
|
||
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 < 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;
|
||
for (const o of rooms) {
|
||
if (x < o.x + o.w + 1 && o.x < x + w + 1 && y < o.y + o.h + 1 && o.y < y + h + 1) {
|
||
ok = false; break;
|
||
}
|
||
}
|
||
if (ok) rooms.push({ id: rooms.length, x, y, w, h, cx: x + w / 2, cy: y + h / 2 });
|
||
}
|
||
// Degenerate seed insurance: a map with fewer than 3 rooms is a hallway
|
||
// with extra steps — fall back to three fixed rooms (still seeded zones).
|
||
while (rooms.length < 3) {
|
||
const i = rooms.length;
|
||
rooms.push({ id: i, x: 4 + i * 12, y: 20, w: 6, h: 6, cx: 7 + i * 12, cy: 23 });
|
||
}
|
||
|
||
const open = new Uint8Array(GW * GH);
|
||
const roomId = new Int16Array(GW * GH).fill(-1);
|
||
const carve = (x, y, id) => {
|
||
if (!inBounds(GW, GH, x, y)) return;
|
||
open[idx(GW, x, y)] = 1;
|
||
if (id >= 0) roomId[idx(GW, x, y)] = id;
|
||
};
|
||
for (const rm of rooms) {
|
||
for (let y = rm.y; y < rm.y + rm.h; y++) {
|
||
for (let x = rm.x; x < rm.x + rm.w; x++) carve(x, y, rm.id);
|
||
}
|
||
}
|
||
|
||
// --- links: MST (Kruskal, weight = centre distance + small seeded jitter
|
||
// so parallel runs vary) plus up to 2 loop edges — loops give the tour
|
||
// alternate ways round, and doorways the camera never takes.
|
||
const edges = [];
|
||
for (let i = 0; i < rooms.length; i++) {
|
||
for (let j = i + 1; j < rooms.length; j++) {
|
||
const a = rooms[i], b = rooms[j];
|
||
edges.push({ a: i, b: j, w: Math.hypot(a.cx - b.cx, a.cy - b.cy) });
|
||
}
|
||
}
|
||
edges.sort((p, q) => p.w - q.w);
|
||
const { mst, rest } = kruskal(rooms.length, edges);
|
||
const links = mst.slice();
|
||
const rLoops = new Rng((sd ^ 0x10ee) >>> 0 || 1);
|
||
const shuffledRest = rest.slice();
|
||
for (let i = shuffledRest.length - 1; i > 0; i--) {
|
||
const j = rLoops.int(0, i);
|
||
[shuffledRest[i], shuffledRest[j]] = [shuffledRest[j], shuffledRest[i]];
|
||
}
|
||
for (const e of shuffledRest.slice(0, 2)) links.push(e);
|
||
|
||
// --- corridors: L-shaped 1-cell carves between random cells of the two
|
||
// rooms (order seeded). Recorded as rects for exact ceiling cover.
|
||
const rCorr = new Rng((sd ^ 0xc091) >>> 0 || 1);
|
||
const corrRects = [];
|
||
const carveCorr = (x, y) => carve(x, y, -1);
|
||
for (const e of links) {
|
||
const A = rooms[e.a], B = rooms[e.b];
|
||
const ax = rCorr.int(A.x, A.x + A.w - 1), ay = rCorr.int(A.y, A.y + A.h - 1);
|
||
const bx = rCorr.int(B.x, B.x + B.w - 1), by = rCorr.int(B.y, B.y + B.h - 1);
|
||
const horizFirst = rCorr.bool();
|
||
const x0 = Math.min(ax, bx), x1 = Math.max(ax, bx);
|
||
const y0 = Math.min(ay, by), y1 = Math.max(ay, by);
|
||
if (horizFirst) {
|
||
for (let x = x0; x <= x1; x++) carveCorr(x, ay);
|
||
for (let y = y0; y <= y1; y++) carveCorr(bx, y);
|
||
corrRects.push({ x: x0, y: ay, w: x1 - x0 + 1, h: 1, link: e });
|
||
corrRects.push({ x: bx, y: y0, w: 1, h: y1 - y0 + 1, link: e });
|
||
} else {
|
||
for (let y = y0; y <= y1; y++) carveCorr(ax, y);
|
||
for (let x = x0; x <= x1; x++) carveCorr(x, by);
|
||
corrRects.push({ x: ax, y: y0, w: 1, h: y1 - y0 + 1, link: e });
|
||
corrRects.push({ x: x0, y: by, w: x1 - x0 + 1, h: 1, link: e });
|
||
}
|
||
}
|
||
|
||
// --- 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) => {
|
||
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) {
|
||
for (let y = rm.y; y < rm.y + rm.h; y++) {
|
||
for (let x = rm.x; x < rm.x + rm.w; x++) {
|
||
zoneOf[idx(GW, x, y)] = rm.zone;
|
||
indoorOf[idx(GW, x, y)] = rm.indoor ? 1 : 0;
|
||
}
|
||
}
|
||
}
|
||
for (const cr of corrRects) {
|
||
const A = rooms[cr.link.a], B = rooms[cr.link.b];
|
||
const indoor = (A.indoor && B.indoor) ? 1 : 0;
|
||
const zone = indoor ? 'hall' : (A.indoor ? B.zone : A.zone);
|
||
const x1 = cr.x + cr.w - 1, y1 = cr.y + cr.h - 1;
|
||
for (let y = cr.y; y <= y1; y++) {
|
||
for (let x = cr.x; x <= x1; x++) {
|
||
if (!inBounds(GW, GH, x, y)) continue;
|
||
if (roomId[idx(GW, x, y)] === -1) {
|
||
zoneOf[idx(GW, x, y)] = zone;
|
||
indoorOf[idx(GW, x, y)] = indoor;
|
||
}
|
||
}
|
||
}
|
||
cr.indoor = indoor;
|
||
cr.zone = zone;
|
||
}
|
||
|
||
// --- route: closed travelling tour — start at room 0, repeatedly walk to
|
||
// the nearest unvisited room (BFS distance), then leg home. Closed by
|
||
// construction; every leg BFS-verified walkable.
|
||
const roomCenterCell = (rm) => ({
|
||
x: Math.max(rm.x, Math.min(rm.x + rm.w - 1, Math.round(rm.cx))),
|
||
y: Math.max(rm.y, Math.min(rm.y + rm.h - 1, Math.round(rm.cy))),
|
||
});
|
||
const routeCells = [];
|
||
{
|
||
const start = roomCenterCell(rooms[0]);
|
||
let cur = start;
|
||
routeCells.push({ ...cur });
|
||
const unvisited = new Set(rooms.map((rm) => rm.id));
|
||
unvisited.delete(rooms[0].id);
|
||
while (unvisited.size) {
|
||
let best = null, bestD = Infinity;
|
||
for (const id of unvisited) {
|
||
const c = roomCenterCell(rooms[id]);
|
||
const d = bfsDist(open, GW, GH, cur.x, cur.y, c.x, c.y);
|
||
if (d < bestD) { bestD = d; best = { id, c }; }
|
||
}
|
||
const leg = bfsPath(open, GW, GH, cur.x, cur.y, best.c.x, best.c.y) || [cur];
|
||
for (let k = 1; k < leg.length; k++) routeCells.push(leg[k]);
|
||
cur = best.c;
|
||
unvisited.delete(best.id);
|
||
}
|
||
const home = bfsPath(open, GW, GH, cur.x, cur.y, start.x, start.y) || [];
|
||
for (let k = 1; k < home.length; k++) routeCells.push(home[k]);
|
||
}
|
||
|
||
// --- smooth: closed Chaikin ×2 keeps corners inside the carved cells
|
||
// (cuts stay near the polyline; corridors are a full cell wide), then
|
||
// even 0.5-unit resampling with per-sample room/zone tags.
|
||
const toWorld = (cx, cy) => ({
|
||
x: (cx - GW / 2 + 0.5) * CELL,
|
||
z: (cy - GH / 2 + 0.5) * CELL,
|
||
});
|
||
let poly = routeCells.map((c) => toWorld(c.x, c.y));
|
||
for (let it = 0; it < 2; it++) {
|
||
const out = [];
|
||
for (let i = 0; i < poly.length; i++) {
|
||
const p = poly[i], q = poly[(i + 1) % poly.length];
|
||
out.push(
|
||
{ x: p.x * 0.75 + q.x * 0.25, z: p.z * 0.75 + q.z * 0.25 },
|
||
{ x: p.x * 0.25 + q.x * 0.75, z: p.z * 0.25 + q.z * 0.75 },
|
||
);
|
||
}
|
||
poly = out;
|
||
}
|
||
const pts = [];
|
||
{
|
||
// Even resample: walk the closed polyline in 0.5-unit steps.
|
||
const segLens = [];
|
||
let total = 0;
|
||
for (let i = 0; i < poly.length; i++) {
|
||
const p = poly[i], q = poly[(i + 1) % poly.length];
|
||
const l = Math.hypot(q.x - p.x, q.z - p.z);
|
||
segLens.push(l);
|
||
total += l;
|
||
}
|
||
const STEP = 0.5;
|
||
const n = Math.max(8, Math.round(total / STEP));
|
||
let si = 0, acc = 0;
|
||
for (let k = 0; k < n; k++) {
|
||
const target = (k / n) * total;
|
||
while (acc + segLens[si] < target && si < poly.length - 1) {
|
||
acc += segLens[si];
|
||
si++;
|
||
}
|
||
const p = poly[si], q = poly[(si + 1) % poly.length];
|
||
const f = segLens[si] > 1e-9 ? (target - acc) / segLens[si] : 0;
|
||
pts.push({
|
||
x: p.x + (q.x - p.x) * f,
|
||
z: p.z + (q.z - p.z) * f,
|
||
});
|
||
}
|
||
}
|
||
const cum = [0];
|
||
for (let i = 1; i <= pts.length; i++) {
|
||
const p = pts[i - 1], q = pts[i % pts.length];
|
||
cum.push(cum[i - 1] + Math.hypot(q.x - p.x, q.z - p.z));
|
||
}
|
||
const totalLen = cum[pts.length];
|
||
// Per-sample tags from the containing cell.
|
||
const cellOfPt = (p) => ({
|
||
x: Math.floor(p.x / CELL + GW / 2),
|
||
z: Math.floor(p.z / CELL + GH / 2),
|
||
});
|
||
const sampleRoom = pts.map((p) => {
|
||
const c = cellOfPt(p);
|
||
return inBounds(GW, GH, c.x, c.z) ? roomId[idx(GW, c.x, c.z)] : -1;
|
||
});
|
||
const sampleZone = pts.map((p) => {
|
||
const c = cellOfPt(p);
|
||
return inBounds(GW, GH, c.x, c.z) ? zoneOf[idx(GW, c.x, c.z)] : 'hall';
|
||
});
|
||
|
||
// --- walls: one run everywhere INDOOR open meets solid, merged along
|
||
// each axis. No holes possible: every indoor open edge is either
|
||
// open-neighboured or walled. Wall tops match the ceiling slabs exactly
|
||
// (per-zone heights — see ceilRects below), so walls always meet roofs.
|
||
// Outdoor edges grow vegetation instead of half-walls: the outside stays
|
||
// outside, lined with planting rather than masonry.
|
||
const wallRuns = [];
|
||
const vegCells = new Map();
|
||
const noteVeg = (x, y, zone) => {
|
||
if (!inBounds(GW, GH, x, y) || open[idx(GW, x, y)]) return;
|
||
const k = idx(GW, x, y);
|
||
if (!vegCells.has(k)) vegCells.set(k, { x, y, zone });
|
||
};
|
||
{
|
||
// Wall height follows the ceiling over the cell: 'room' zones stand
|
||
// 4.2, halls and indoor corridors 3.2.
|
||
const wallH = (x, y) => (zoneOf[idx(GW, x, y)] === 'room' ? 4.2 : 3.2);
|
||
const hEdges = []; // {z, x0, x1, h}
|
||
const vEdges = []; // {x, z0, z1, h}
|
||
for (let y = 0; y < GH; y++) {
|
||
for (let x = 0; x < GW; x++) {
|
||
if (!open[idx(GW, x, y)]) continue;
|
||
const w0 = toWorld(x, y);
|
||
if (!indoorOf[idx(GW, x, y)]) {
|
||
// Outside: plant the solid neighbours, wall nothing.
|
||
const z = zoneOf[idx(GW, x, y)];
|
||
noteVeg(x, y - 1, z); noteVeg(x, y + 1, z);
|
||
noteVeg(x - 1, y, z); noteVeg(x + 1, y, z);
|
||
continue;
|
||
}
|
||
const h = wallH(x, y);
|
||
if (!inBounds(GW, GH, x, y - 1) || !open[idx(GW, x, y - 1)]) {
|
||
hEdges.push({ z: w0.z - CELL / 2, x0: w0.x - CELL / 2, x1: w0.x + CELL / 2, h });
|
||
}
|
||
if (!inBounds(GW, GH, x, y + 1) || !open[idx(GW, x, y + 1)]) {
|
||
hEdges.push({ z: w0.z + CELL / 2, x0: w0.x - CELL / 2, x1: w0.x + CELL / 2, h });
|
||
}
|
||
if (!inBounds(GW, GH, x - 1, y) || !open[idx(GW, x - 1, y)]) {
|
||
vEdges.push({ x: w0.x - CELL / 2, z0: w0.z - CELL / 2, z1: w0.z + CELL / 2, h });
|
||
}
|
||
if (!inBounds(GW, GH, x + 1, y) || !open[idx(GW, x + 1, y)]) {
|
||
vEdges.push({ x: w0.x + CELL / 2, z0: w0.z - CELL / 2, z1: w0.z + CELL / 2, h });
|
||
}
|
||
}
|
||
}
|
||
const merge = (edges, fixed, lo, hi) => {
|
||
edges.sort((a, b) => a[fixed] - b[fixed] || a[lo] - b[lo]);
|
||
let cur = null;
|
||
const runs = [];
|
||
for (const e of edges) {
|
||
if (cur && e[fixed] === cur[fixed] && e.h === cur.h && e[lo] <= cur[hi] + 1e-6) {
|
||
cur[hi] = Math.max(cur[hi], e[hi]);
|
||
} else {
|
||
if (cur) runs.push(cur);
|
||
cur = { [fixed]: e[fixed], [lo]: e[lo], [hi]: e[hi], h: e.h };
|
||
}
|
||
}
|
||
if (cur) runs.push(cur);
|
||
return runs;
|
||
};
|
||
for (const e of merge(hEdges, 'z', 'x0', 'x1')) {
|
||
wallRuns.push({ x0: e.x0, z0: e.z, x1: e.x1, z1: e.z, h: e.h });
|
||
}
|
||
for (const e of merge(vEdges, 'x', 'z0', 'z1')) {
|
||
wallRuns.push({ x0: e.x, z0: e.z0, x1: e.x, z1: e.z1, h: e.h });
|
||
}
|
||
}
|
||
|
||
// --- ceilings: exact slabs over indoor rooms + indoor corridor rects.
|
||
const ceilRects = [];
|
||
{
|
||
const rect = (x, y, w, h, top) => {
|
||
const a = toWorld(x, y), b = toWorld(x + w - 1, y + h - 1);
|
||
ceilRects.push({
|
||
x0: a.x - CELL / 2, z0: a.z - CELL / 2,
|
||
x1: b.x + CELL / 2, z1: b.z + CELL / 2, y: top,
|
||
});
|
||
};
|
||
for (const rm of rooms) {
|
||
if (rm.indoor) rect(rm.x, rm.y, rm.w, rm.h, -1.15 + (rm.zone === 'room' ? 4.2 : 3.2));
|
||
}
|
||
for (const cr of corrRects) {
|
||
if (cr.indoor) rect(cr.x, cr.y, cr.w, cr.h, -1.15 + 3.2);
|
||
}
|
||
}
|
||
// --- route helpers over samples.
|
||
const routeCellsSet = new Set(routeCells.map((c) => idx(GW, c.x, c.y)));
|
||
const distToRoute = (cx, cy) => {
|
||
let best = Infinity;
|
||
for (const k of routeCellsSet) {
|
||
const dx = (k % GW) - cx, dy = Math.floor(k / GW) - cy;
|
||
best = Math.min(best, Math.abs(dx) + Math.abs(dy));
|
||
}
|
||
return best;
|
||
};
|
||
|
||
// --- watch cells: per room, the open cell farthest from the walked line
|
||
// that still sees it — the camera's chair, out of the walkway, eyes on
|
||
// the action. Plus a fallback look point on the route.
|
||
const watch = {};
|
||
for (const rm of rooms) {
|
||
const inRoomRoute = [];
|
||
for (let y = rm.y; y < rm.y + rm.h; y++) {
|
||
for (let x = rm.x; x < rm.x + rm.w; x++) {
|
||
if (routeCellsSet.has(idx(GW, x, y))) inRoomRoute.push({ x, y });
|
||
}
|
||
}
|
||
if (!inRoomRoute.length) continue;
|
||
let bestCell = null, bestD = -1;
|
||
for (let y = rm.y; y < rm.y + rm.h; y++) {
|
||
for (let x = rm.x; x < rm.x + rm.w; x++) {
|
||
const d = distToRoute(x, y);
|
||
if (d < 2) continue;
|
||
const sees = inRoomRoute.some((c) => losClear(open, GW, GH, x, y, c.x, c.y));
|
||
if (!sees) continue;
|
||
if (d > bestD) { bestD = d; bestCell = { x, y }; }
|
||
}
|
||
}
|
||
if (!bestCell) continue;
|
||
let lookCell = inRoomRoute[0], lookD = Infinity;
|
||
for (const c of inRoomRoute) {
|
||
if (!losClear(open, GW, GH, bestCell.x, bestCell.y, c.x, c.y)) continue;
|
||
const d = Math.hypot(c.x - rm.cx, c.y - rm.cy);
|
||
if (d < lookD) { lookD = d; lookCell = c; }
|
||
}
|
||
const wp = toWorld(bestCell.x, bestCell.y);
|
||
const lp = toWorld(lookCell.x, lookCell.y);
|
||
watch[rm.id] = { x: wp.x, z: wp.z, lookX: lp.x, lookZ: lp.z };
|
||
}
|
||
|
||
// --- items.
|
||
const rItems = new Rng((sd ^ 0x171e5) >>> 0 || 1);
|
||
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);
|
||
// Unsigned shifts throughout: signed >> would sign-extend the hash
|
||
// 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');
|
||
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.
|
||
let portal = null;
|
||
let portalCell = null;
|
||
{
|
||
const indoor = rooms.filter((rm) => rm.indoor).sort((a, b) => (b.w * b.h) - (a.w * a.h));
|
||
const host = indoor[0] || rooms.slice().sort((a, b) => (b.w * b.h) - (a.w * a.h))[0];
|
||
let bc = null, bd = -1;
|
||
for (let y = host.y; y < host.y + host.h; y++) {
|
||
for (let x = host.x; x < host.x + host.w; x++) {
|
||
const d = distToRoute(x, y);
|
||
if (d > bd) { bd = d; bc = { x, y }; }
|
||
}
|
||
}
|
||
if (bc && bd >= 1) {
|
||
portalCell = bc;
|
||
const p = toWorld(bc.x, bc.y);
|
||
// Face the nearest route cell.
|
||
let nc = null, nd = Infinity;
|
||
for (const k of routeCellsSet) {
|
||
const d = Math.hypot((k % GW) - bc.x, Math.floor(k / GW) - bc.y);
|
||
if (d < nd) { nd = d; nc = k; }
|
||
}
|
||
const np = nc !== null ? toWorld(nc % GW, Math.floor(nc / GW)) : { x: p.x, z: p.z - 1 };
|
||
portal = {
|
||
x: p.x, z: p.z,
|
||
yaw: Math.atan2(np.x - p.x, np.z - p.z),
|
||
roomId: host.id,
|
||
};
|
||
}
|
||
}
|
||
// Props: 2-4 per room, off the walked line, clear of the portal.
|
||
for (const rm of rooms) {
|
||
const n = rItems.int(2, 4);
|
||
let placed = 0, guard = 0;
|
||
while (placed < n && guard++ < 60) {
|
||
const x = rItems.int(rm.x, rm.x + rm.w - 1);
|
||
const y = rItems.int(rm.y, rm.y + rm.h - 1);
|
||
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(theme.propIndoor)
|
||
: rItems.pick(theme.propOutdoor);
|
||
items.props.push({ x: p.x, z: p.z, yaw: rItems.next() * Math.PI * 2, type });
|
||
placed++;
|
||
}
|
||
}
|
||
// Route furniture: torches on walls, slits, puddles, gates, gaps, patrols.
|
||
const stepOf = (du) => Math.max(1, Math.round(du / 0.5));
|
||
{
|
||
// Torches every ~7 units on indoor walls — sconces belong on stone,
|
||
// not on open ground (patrol lanterns light the outside instead).
|
||
// Few and steady: a torch every few steps reads as clutter.
|
||
for (let i = 0; i < pts.length && items.torches.length < 18; i += stepOf(7)) {
|
||
const p = pts[i], q = pts[(i + 1) % pts.length];
|
||
const rcx = Math.floor(p.x / CELL + GW / 2);
|
||
const rcy = Math.floor(p.z / CELL + GH / 2);
|
||
if (!inBounds(GW, GH, rcx, rcy) || !indoorOf[idx(GW, rcx, rcy)]) continue;
|
||
const dx = q.x - p.x, dz = q.z - p.z;
|
||
const l = Math.hypot(dx, dz) || 1;
|
||
const px = -dz / l, pz = dx / l;
|
||
for (const side of [1, -1]) {
|
||
const cx = Math.floor((p.x + px * side * 1.2) / CELL + GW / 2);
|
||
const cy = Math.floor((p.z + pz * side * 1.2) / CELL + GH / 2);
|
||
if (!inBounds(GW, GH, cx, cy) || open[idx(GW, cx, cy)]) continue;
|
||
items.torches.push({
|
||
x: p.x + px * side * 0.9, z: p.z + pz * side * 0.9,
|
||
yaw: Math.atan2(-px * side, -pz * side),
|
||
post: false,
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
// Window slits every ~6 units on indoor stretches with a high wall
|
||
// beside the route. Outdoor slits would float above the low parapet
|
||
// walls, so indoor-only (ceilings imply walls tall enough to slit).
|
||
for (let i = 2; i < pts.length && items.windows.length < 20; i += stepOf(6)) {
|
||
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;
|
||
const px = -dz / l, pz = dx / l;
|
||
const cx = Math.floor((p.x + px * 1.2) / CELL + GW / 2);
|
||
const cy = Math.floor((p.z + pz * 1.2) / CELL + GH / 2);
|
||
if (!inBounds(GW, GH, cx, cy) || open[idx(GW, cx, cy)]) continue;
|
||
const rcx = Math.floor(p.x / CELL + GW / 2);
|
||
const rcy = Math.floor(p.z / CELL + GH / 2);
|
||
if (!inBounds(GW, GH, rcx, rcy) || !indoorOf[idx(GW, rcx, rcy)]) continue;
|
||
items.windows.push({
|
||
x: p.x + px * 0.95, z: p.z + pz * 0.95,
|
||
yaw: Math.atan2(-px, -pz),
|
||
});
|
||
}
|
||
// 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;
|
||
items.puddles.push({ x: p.x + (-dz / l) * 0.5, z: p.z + (dx / l) * 0.5, r: 0.7 + rItems.next() * 0.4 });
|
||
}
|
||
// Gates where the route enters rooms (roomId flips corridor→room).
|
||
for (let i = 0; i < pts.length && items.gates.length < 4; i++) {
|
||
const prev = sampleRoom[(i + pts.length - 1) % pts.length];
|
||
const cur = sampleRoom[i];
|
||
if (prev !== -1 || cur === -1) continue;
|
||
const s = cum[i];
|
||
if (items.gates.length && s - items.gates[items.gates.length - 1].s < 12) continue;
|
||
const p = pts[i], q = pts[(i + 1) % pts.length];
|
||
items.gates.push({ x: p.x, z: p.z, yaw: Math.atan2(q.x - p.x, q.z - p.z), s });
|
||
}
|
||
// Gaps spread through rooms, off the gates.
|
||
for (let i = 0; i < pts.length && items.gaps.length < 2; i += stepOf(9)) {
|
||
if (sampleRoom[i] === -1) continue;
|
||
const s = cum[i];
|
||
if (items.gates.some((g) => Math.abs(s - g.s) < 6)) continue;
|
||
const p = pts[i], q = pts[(i + 1) % pts.length];
|
||
items.gaps.push({ x: p.x, z: p.z, yaw: Math.atan2(q.x - p.x, q.z - p.z), s });
|
||
}
|
||
// Patrol lanterns stand along the open ground every ~25 units — they
|
||
// are the outdoor light rhythm now that torches stay on stone.
|
||
for (let i = 0; i < pts.length && items.patrols.length < 6; i += stepOf(25)) {
|
||
const cx = Math.floor(pts[i].x / CELL + GW / 2);
|
||
const cy = Math.floor(pts[i].z / CELL + GH / 2);
|
||
if (!inBounds(GW, GH, cx, cy) || indoorOf[idx(GW, cx, cy)]) continue;
|
||
const s = cum[i];
|
||
if (items.patrols.length && s - items.patrols[items.patrols.length - 1].s < 18) continue;
|
||
const p = pts[i], q = pts[(i + 1) % pts.length];
|
||
// Stand beside the route, never in stone: try both shoulders,
|
||
// fall back to the walked line itself.
|
||
const dx = q.x - p.x, dz = q.z - p.z;
|
||
const l = Math.hypot(dx, dz) || 1;
|
||
let px = p.x, pz = p.z;
|
||
for (const side of [1.2, -1.2, 0]) {
|
||
const tx = p.x + (-dz / l) * side, tz = p.z + (dx / l) * side;
|
||
const tcx = Math.floor(tx / CELL + GW / 2), tcy = Math.floor(tz / CELL + GH / 2);
|
||
if (inBounds(GW, GH, tcx, tcy) && open[idx(GW, tcx, tcy)]) {
|
||
px = tx; pz = tz; break;
|
||
}
|
||
}
|
||
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
|
||
// odometer without ever reversing it (see odometer).
|
||
items.entities = [];
|
||
items.loot = [];
|
||
items.halts = [];
|
||
// Nearest route sample (cell, arclength, index) for any cell.
|
||
const cellRouteS = new Map();
|
||
pts.forEach((p, i) => {
|
||
const cx = Math.floor(p.x / CELL + GW / 2);
|
||
const cy = Math.floor(p.z / CELL + GH / 2);
|
||
const k = cy * GW + cx;
|
||
if (inBounds(GW, GH, cx, cy) && !cellRouteS.has(k)) {
|
||
cellRouteS.set(k, { s: cum[i], i });
|
||
}
|
||
});
|
||
const nearRoute = (cx, cy) => {
|
||
let best = null, bd = Infinity;
|
||
for (const [k, v] of cellRouteS) {
|
||
const dx = (k % GW) - cx, dy = Math.floor(k / GW) - cy;
|
||
const d = Math.abs(dx) + Math.abs(dy);
|
||
if (d < bd) { bd = d; best = v; }
|
||
}
|
||
return best ? { d: bd, ...best } : null;
|
||
};
|
||
const routeTangent = (i) => {
|
||
const p = pts[i % pts.length], q = pts[(i + 1) % pts.length];
|
||
const l = Math.hypot(q.x - p.x, q.z - p.z) || 1;
|
||
return { x: (q.x - p.x) / l, z: (q.z - p.z) / l };
|
||
};
|
||
const takenS = [];
|
||
const clearOf = (s, du) => takenS.every((o) => {
|
||
const L = totalLen;
|
||
const d = Math.abs(s - o) % L;
|
||
return Math.min(d, L - d) >= du;
|
||
});
|
||
// Lurkers pace across the walked line, menacing and sidestepped.
|
||
{
|
||
const cands = [];
|
||
for (let y = 0; y < GH && cands.length < 400; y++) {
|
||
for (let x = 0; x < GW; x++) {
|
||
if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue;
|
||
const nr = nearRoute(x, y);
|
||
if (!nr || nr.d !== 1) continue;
|
||
cands.push({ x, y, ...nr });
|
||
}
|
||
}
|
||
const order = rItems.shuffle(cands);
|
||
for (const c of order) {
|
||
if (items.entities.filter((e) => e.kind === 'lurker').length >= 2) break;
|
||
if (!clearOf(c.s, 25)) continue;
|
||
const p = toWorld(c.x, c.y);
|
||
const tn = routeTangent(c.i);
|
||
items.entities.push({
|
||
kind: 'lurker', x: p.x, z: p.z,
|
||
yaw: Math.atan2(-tn.z, tn.x),
|
||
s: c.s, roomId: roomId[idx(GW, c.x, c.y)],
|
||
ax: -tn.z, az: tn.x, range: 1.2, speed: 0.45,
|
||
phase: rItems.next() * 2,
|
||
});
|
||
takenS.push(c.s);
|
||
}
|
||
}
|
||
// A sentry stands the first gate; without a gate, a third lurker.
|
||
if (items.gates.length) {
|
||
const g = items.gates[0];
|
||
// Shoulder of the gate: perpendicular of the route heading.
|
||
const px = Math.cos(g.yaw), pz = -Math.sin(g.yaw);
|
||
let sx = g.x + px * 1.5, sz = g.z + pz * 1.5, placed = false;
|
||
for (const side of [1, -1]) {
|
||
const tx = g.x + px * 1.5 * side, tz = g.z + pz * 1.5 * side;
|
||
const tcx = Math.floor(tx / CELL + GW / 2), tcy = Math.floor(tz / CELL + GH / 2);
|
||
if (inBounds(GW, GH, tcx, tcy) && open[idx(GW, tcx, tcy)]) {
|
||
sx = tx; sz = tz; placed = true; break;
|
||
}
|
||
}
|
||
if (placed) {
|
||
items.entities.push({
|
||
kind: 'sentry', x: sx, z: sz,
|
||
yaw: g.yaw + Math.PI,
|
||
s: g.s, roomId: -1,
|
||
ax: 0, az: 0, range: 0, speed: 0, phase: 0,
|
||
});
|
||
takenS.push(g.s);
|
||
}
|
||
}
|
||
// Critters dart between hideouts; a wisp drifts for atmosphere.
|
||
{
|
||
const cands = [];
|
||
for (let y = 0; y < GH && cands.length < 400; y++) {
|
||
for (let x = 0; x < GW; x++) {
|
||
if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue;
|
||
const nr = nearRoute(x, y);
|
||
if (!nr || nr.d < 2 || nr.d > 3) continue;
|
||
cands.push({ x, y, ...nr });
|
||
}
|
||
}
|
||
const order = rItems.shuffle(cands);
|
||
for (const c of order) {
|
||
if (items.entities.filter((e) => e.kind === 'critter').length >= 2) break;
|
||
if (!clearOf(c.s, 20)) continue;
|
||
const p = toWorld(c.x, c.y);
|
||
const tn = routeTangent(c.i);
|
||
items.entities.push({
|
||
kind: 'critter', x: p.x, z: p.z,
|
||
yaw: Math.atan2(tn.x, tn.z),
|
||
s: c.s, roomId: roomId[idx(GW, c.x, c.y)],
|
||
ax: tn.x, az: tn.z, range: 2.0, speed: 1.4,
|
||
phase: rItems.next() * 2,
|
||
});
|
||
takenS.push(c.s);
|
||
}
|
||
const wcands = [];
|
||
for (let y = 0; y < GH && wcands.length < 200; y++) {
|
||
for (let x = 0; x < GW; x++) {
|
||
if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue;
|
||
const nr = nearRoute(x, y);
|
||
if (!nr || nr.d < 2) continue;
|
||
wcands.push({ x, y, ...nr });
|
||
}
|
||
}
|
||
if (wcands.length) {
|
||
const c = rItems.pick(wcands);
|
||
const p = toWorld(c.x, c.y);
|
||
items.entities.push({
|
||
kind: 'wisp', x: p.x, z: p.z, yaw: 0,
|
||
s: c.s, roomId: roomId[idx(GW, c.x, c.y)],
|
||
ax: 0, az: 0, range: 1.0, speed: 0.5,
|
||
phase: rItems.next() * 2,
|
||
});
|
||
}
|
||
}
|
||
// Loot worth stopping for: room cells a step off the walked line,
|
||
// spaced round the loop. Every cache earns its halt below.
|
||
{
|
||
const cands = [];
|
||
for (let y = 0; y < GH && cands.length < 400; y++) {
|
||
for (let x = 0; x < GW; x++) {
|
||
if (!open[idx(GW, x, y)] || roomId[idx(GW, x, y)] < 0) continue;
|
||
const nr = nearRoute(x, y);
|
||
if (!nr || nr.d < 1 || nr.d > 2) continue;
|
||
if (portalCell && Math.abs(portalCell.x - x) + Math.abs(portalCell.y - y) < 2) continue;
|
||
cands.push({ x, y, ...nr });
|
||
}
|
||
}
|
||
const kinds = rItems.shuffle(['cache', 'relic', 'hoard']);
|
||
const order = rItems.shuffle(cands);
|
||
for (const c of order) {
|
||
if (items.loot.length >= 3) break;
|
||
if (!clearOf(c.s, 30)) continue;
|
||
const p = toWorld(c.x, c.y);
|
||
// Face the nearest route point.
|
||
const rp = pts[c.i % pts.length];
|
||
items.loot.push({
|
||
kind: kinds[items.loot.length % kinds.length],
|
||
x: p.x, z: p.z,
|
||
yaw: Math.atan2(rp.x - p.x, rp.z - p.z),
|
||
s: c.s, roomId: roomId[idx(GW, c.x, c.y)],
|
||
});
|
||
takenS.push(c.s);
|
||
}
|
||
// Halts: one pause per cache, spaced by construction (loot holds
|
||
// 30u apart, pauses need 15) so pauses never overlap — the
|
||
// odometer can hold or advance, never retreat.
|
||
for (const l of items.loot) {
|
||
items.halts.push({ s: l.s, dur: 2.2 });
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
seed: sd, w: GW, h: GH, cell: CELL, grid: open, indoor: indoorOf,
|
||
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, character, theme: themeName,
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------- queries
|
||
|
||
function locate(plan, s) {
|
||
const { pts, cum } = plan.route;
|
||
const L = plan.route.totalLen;
|
||
const q = ((s % L) + L) % L;
|
||
let lo = 0, hi = cum.length - 1;
|
||
while (lo < hi - 1) {
|
||
const mid = (lo + hi) >> 1;
|
||
if (cum[mid] <= q) lo = mid;
|
||
else hi = mid;
|
||
}
|
||
const i = lo % pts.length;
|
||
const p = pts[i], nq = pts[(i + 1) % pts.length];
|
||
const span = cum[lo + 1] - cum[lo] || 1;
|
||
const f = Math.max(0, Math.min(1, (q - cum[lo]) / span));
|
||
return {
|
||
x: p.x + (nq.x - p.x) * f,
|
||
z: p.z + (nq.z - p.z) * f,
|
||
yaw: Math.atan2(nq.x - p.x, nq.z - p.z),
|
||
index: i,
|
||
};
|
||
}
|
||
|
||
/** World position + heading at arclength s (wraps). Pure, no state. */
|
||
export function pathAt(plan, s) {
|
||
const here = locate(plan, s);
|
||
const ahead = locate(plan, s + 0.75);
|
||
return {
|
||
x: here.x,
|
||
z: here.z,
|
||
yaw: Math.atan2(ahead.x - here.x, ahead.z - here.z),
|
||
};
|
||
}
|
||
|
||
/** Room id at arclength s, or -1 in a corridor. */
|
||
export function roomAt(plan, s) {
|
||
return plan.route.roomIds[locate(plan, s).index];
|
||
}
|
||
|
||
/** Zone name at arclength s. */
|
||
export function zoneAt(plan, s) {
|
||
return plan.route.zones[locate(plan, s).index];
|
||
}
|
||
|
||
/**
|
||
* The interrupted odometer: a constant-gait walk that pauses at halts.
|
||
*
|
||
* heroS(t) = cruise·(t − paused(t)), paused(t) = Σ clamp(t − s_h/cruise, 0, dur_h).
|
||
* Each halt freezes the walk for dur seconds at exactly its arclength, then
|
||
* resumes — velocity is piecewise {0, cruise}, so the walk can hold or
|
||
* advance but never retreat, whatever the params. Pure f(t): seeks agree
|
||
* with playback. Halts are spaced ≥15u apart at plan time (see items.halts),
|
||
* so pauses never overlap and the bound holds a priori.
|
||
*/
|
||
export function odometer(cruise, halts, L, t) {
|
||
const c = Math.max(0.05, cruise);
|
||
let paused = 0;
|
||
for (const h of halts || []) {
|
||
paused += Math.max(0, Math.min(t - h.s / c, h.dur));
|
||
}
|
||
return (((c * (t - paused)) % L) + L) % L;
|
||
}
|
||
|
||
function wrapDist(a, b, L) {
|
||
const d = Math.abs(a - b) % L;
|
||
return Math.min(d, L - d);
|
||
}
|
||
|
||
function tri01(x) {
|
||
const u = ((x % 2) + 2) % 2;
|
||
return u < 1 ? u : 2 - u;
|
||
}
|
||
|
||
function triSign(x) {
|
||
const u = ((x % 2) + 2) % 2;
|
||
return u < 1 ? 1 : -1;
|
||
}
|
||
|
||
function yawLerp(a, b, k) {
|
||
// Shortest-arc blend of two headings.
|
||
const sa = Math.sin(a), ca = Math.cos(a);
|
||
const sb = Math.sin(b), cb = Math.cos(b);
|
||
return Math.atan2(sa + (sb - sa) * k, ca + (cb - ca) * k);
|
||
}
|
||
|
||
/**
|
||
* Where an entity is and how alarmed, at time t with the hero at (heroS,
|
||
* heroX, heroZ). Pure f(...) — analytic triangle gaits, gaussian proximity,
|
||
* no integration, so every frame renders identically from any seek.
|
||
*
|
||
* Returns { x, z, yaw, alert } with alert ∈ [0, 1]: 1 while the hero is on
|
||
* top of it, 0 beyond 8 units of route behind/ahead.
|
||
*/
|
||
export function entityPose(e, t, heroS, heroX, heroZ, L) {
|
||
const prox = (e.s === undefined || e.s === null || L <= 0)
|
||
? 0
|
||
: smooth01(1 - wrapDist(heroS, e.s, L) / 8);
|
||
const faceHero = Math.atan2(heroX - e.x, heroZ - e.z);
|
||
if (e.kind === 'lurker') {
|
||
const ph = t * e.speed + e.phase;
|
||
const u = tri01(ph) * 2 - 1;
|
||
const paceYaw = Math.atan2(e.ax * triSign(ph), e.az * triSign(ph));
|
||
const amp = e.range * (1 - prox * 0.6);
|
||
return {
|
||
x: e.x + e.ax * u * amp,
|
||
z: e.z + e.az * u * amp,
|
||
yaw: yawLerp(paceYaw, faceHero, prox),
|
||
alert: prox,
|
||
};
|
||
}
|
||
if (e.kind === 'sentry') {
|
||
return { x: e.x, z: e.z, yaw: yawLerp(e.yaw, faceHero, prox), alert: prox };
|
||
}
|
||
if (e.kind === 'critter') {
|
||
const ph = t * e.speed + e.phase;
|
||
const u = tri01(ph) * 2 - 1;
|
||
let fx = e.x + e.ax * u * e.range;
|
||
let fz = e.z + e.az * u * e.range;
|
||
// Flees the hero's lantern: offset away, scaled by proximity.
|
||
const dx = fx - heroX, dz = fz - heroZ;
|
||
const dl = Math.hypot(dx, dz);
|
||
const flee = prox * 1.5;
|
||
if (dl > 1e-4) { fx += (dx / dl) * flee; fz += (dz / dl) * flee; }
|
||
else { fx += e.az * flee; fz += -e.ax * flee; }
|
||
return {
|
||
x: fx, z: fz,
|
||
yaw: Math.atan2(e.ax * triSign(ph), e.az * triSign(ph)),
|
||
alert: prox,
|
||
};
|
||
}
|
||
// wisp: slow orbit that leans toward a nearby hero.
|
||
const a = t * e.speed + e.phase;
|
||
const r = e.range * (1 - prox * 0.4);
|
||
let wx = e.x + Math.cos(a) * r;
|
||
let wz = e.z + Math.sin(a * 0.9) * r;
|
||
if (prox > 0) {
|
||
const dx = heroX - e.x, dz = heroZ - e.z;
|
||
const dl = Math.hypot(dx, dz) || 1;
|
||
wx += (dx / dl) * prox * 1.2;
|
||
wz += (dz / dl) * prox * 1.2;
|
||
}
|
||
return { x: wx, z: wz, yaw: 0, alert: prox };
|
||
}
|