// 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 }; }