Files
music-video-gen/flow-state/src/venues/dungeon/map.js
T
Dejvino 6a2af2e45f 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.
2026-10-08 22:20:04 +02:00

1250 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}