Dungeon venue: grid-first traversable dungeon with cast, loot and encounters
New VenueGenerator 'dungeon' archetype. The floor plan is generated first on a 44x44 grid (rejection-sampled rooms, Kruskal MST + loop edges, L-shaped corridors, BFS-verified connectivity); walls are merged runs built exactly where open meets solid, so the shell has no holes, and the walk route is a closed travelling tour through every room. The camera either walks first-person (hero hidden to avoid clipping) or plants on a per-room watch cell with line-of-sight to the route and tracks the actor. Enemies (walker/structure/swarm ActorSpecs from the track's own summary) lurk, stand sentry, dart and drift; loot caches pause the analytic odometer, which can hold or advance but never retreat. Includes dungeon-debug.html (2D map + timeline stills), 14 map tests plus facade camera tests, and the portcullis-holds-for-the-whole-party regression test.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>flow-state · dungeon debug</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 20px 24px 60px; background:#0b0d12; color:#d6dae3; font: 13px/1.5 ui-monospace, monospace; }
|
||||
h1 { font-size:15px; color:#7d8698; margin:0 0 4px; }
|
||||
h2 { font-size:12px; color:#6b7280; margin:22px 0 8px; text-transform:uppercase; letter-spacing:.1em; }
|
||||
#status { color:#8b94a7; margin: 8px 0 16px; }
|
||||
#status b { color:#e6eaf2; }
|
||||
.wrap { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start; }
|
||||
#map { background:#05070a; border:1px solid #1c2030; image-rendering:pixelated; }
|
||||
#mapmeta { max-width:340px; }
|
||||
#mapmeta .kv { display:flex; justify-content:space-between; gap:12px; padding:1px 0; }
|
||||
#mapmeta .kv span { color:#8b94a7; }
|
||||
.legend { margin-top:10px; font-size:12px; color:#8b94a7; }
|
||||
.legend i { display:inline-block; width:10px; height:10px; margin-right:5px; vertical-align:baseline; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap:10px; }
|
||||
.card { background:#11141b; border:1px solid #1c2030; border-radius:4px; padding:8px; }
|
||||
.card h3 { margin:0 0 4px; font-size:12px; color:#e6eaf2; }
|
||||
.card h3 .mode-walk { color:#7dd3fc; } .card h3 .mode-room { color:#a3e635; } .card h3 .mode-portal { color:#f0abfc; }
|
||||
canvas.still { width:100%; display:block; background:#05070a; aspect-ratio:16/9; }
|
||||
a { color:#7dd3fc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>flow-state · dungeon debug</h1>
|
||||
<div id="status">starting…</div>
|
||||
<h2>map — <span id="maptitle"></span></h2>
|
||||
<div class="wrap">
|
||||
<canvas id="map" width="440" height="440"></canvas>
|
||||
<div id="mapmeta"></div>
|
||||
</div>
|
||||
<h2>stills — song timeline, both camera modes</h2>
|
||||
<div id="out" class="grid"></div>
|
||||
<script type="module">
|
||||
import { Engine } from '/src/engine/Engine.js';
|
||||
import { createLayer } from '/src/engine/Layer.js';
|
||||
import { song } from '/src/audio/songbank.js';
|
||||
import { generateLook } from '/src/look/LookGenerator.js';
|
||||
import { venues } from '/src/venues/registry.js';
|
||||
import { dungeon } from '/src/venues/dungeon.js';
|
||||
import { genDungeon } from '/src/venues/dungeon/map.js';
|
||||
import { hashString } from '/src/engine/rng.js';
|
||||
import { blit } from '/src/checks/blit.js';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const out = document.getElementById('out');
|
||||
const status = document.getElementById('status');
|
||||
const params = new URLSearchParams(location.search);
|
||||
const songName = params.get('song') || 'ember';
|
||||
const mapSeedParam = params.get('mapseed');
|
||||
const TIMES = (params.get('times') || '5,25,45,65,85,105').split(',').map(Number);
|
||||
|
||||
const { track } = song(songName);
|
||||
const look = generateLook(track, { seed: hashString(songName), venueEnabled: true });
|
||||
look.venue.module = dungeon;
|
||||
look.venue.name = dungeon.name;
|
||||
look.venue.archetype = dungeon.archetype;
|
||||
const mapSeed = mapSeedParam !== null ? Number(mapseedParam) : (look.venue.seed ?? 7);
|
||||
const map = genDungeon({ seed: mapSeed });
|
||||
look.venue.dungeon = { map };
|
||||
|
||||
// ---------------------------------------------------------- 2D map
|
||||
const ZC = { hall: '#232b3d', room: '#2b3450', forest: '#14301c', wallwalk: '#33302a' };
|
||||
const mc = document.getElementById('map');
|
||||
const mg = mc.getContext('2d');
|
||||
const S = mc.width / map.w;
|
||||
document.getElementById('maptitle').textContent = `${songName} · map seed ${mapSeed} · ${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u`;
|
||||
for (let y = 0; y < map.h; y++) {
|
||||
for (let x = 0; x < map.w; x++) {
|
||||
if (!map.grid[y * map.w + x]) { mg.fillStyle = '#07090d'; }
|
||||
else {
|
||||
const r = map.rooms.find((rm) => x >= rm.x && x < rm.x + rm.w && y >= rm.y && y < rm.y + rm.h);
|
||||
mg.fillStyle = r ? (ZC[r.zone] || '#222') : '#1a2030';
|
||||
}
|
||||
mg.fillRect(x * S, y * S, S, S);
|
||||
}
|
||||
}
|
||||
// Route.
|
||||
mg.strokeStyle = '#f5b942'; mg.lineWidth = 2; mg.beginPath();
|
||||
map.route.pts.forEach((p, i) => {
|
||||
const cx = (p.x / 2 + map.w / 2) * S, cy = (p.z / 2 + map.h / 2) * S;
|
||||
if (i === 0) mg.moveTo(cx, cy); else mg.lineTo(cx, cy);
|
||||
});
|
||||
mg.closePath(); mg.stroke();
|
||||
const dot = (x, z, color, r = 3) => {
|
||||
mg.fillStyle = color; mg.beginPath();
|
||||
mg.arc((x / 2 + map.w / 2) * S, (z / 2 + map.h / 2) * S, r, 0, 7); mg.fill();
|
||||
};
|
||||
for (const p of map.items.props) dot(p.x, p.z, '#8b94a7', 2);
|
||||
for (const p of map.items.puddles) dot(p.x, p.z, '#38bdf8', 3);
|
||||
for (const p of map.items.torches) dot(p.x, p.z, '#fb923c', 2);
|
||||
for (const p of map.items.windows) dot(p.x, p.z, '#22d3ee', 2);
|
||||
for (const p of map.items.gates) dot(p.x, p.z, '#ef4444', 3);
|
||||
for (const v of map.items.vegetation || []) {
|
||||
dot(v.x, v.z, v.kind === 'boulder' ? '#6b7280' : '#166534', 2);
|
||||
}
|
||||
const ECOL = { lurker: '#f87171', sentry: '#fb923c', critter: '#facc15', wisp: '#a5f3fc' };
|
||||
for (const e of map.items.entities || []) {
|
||||
dot(e.x, e.z, ECOL[e.kind] || '#f87171', 3);
|
||||
mg.fillStyle = '#0b0d12';
|
||||
mg.fillText(e.kind[0].toUpperCase(), (e.x / 2 + map.w / 2) * S - 3, (e.z / 2 + map.h / 2) * S + 3);
|
||||
}
|
||||
for (const l of map.items.loot || []) {
|
||||
dot(l.x, l.z, '#fde047', 3);
|
||||
mg.strokeStyle = '#fde047'; mg.lineWidth = 1; mg.beginPath();
|
||||
mg.arc((l.x / 2 + map.w / 2) * S, (l.z / 2 + map.h / 2) * S, 5, 0, 7); mg.stroke();
|
||||
}
|
||||
if (map.portal) {
|
||||
dot(map.portal.x, map.portal.z, '#f0abfc', 5);
|
||||
mg.strokeStyle = '#f0abfc'; mg.lineWidth = 1.5; mg.beginPath();
|
||||
mg.arc((map.portal.x / 2 + map.w / 2) * S, (map.portal.z / 2 + map.h / 2) * S, 7, 0, 7); mg.stroke();
|
||||
}
|
||||
mg.fillStyle = '#a3e635';
|
||||
for (const w of Object.values(map.watch)) {
|
||||
const cx = (w.x / 2 + map.w / 2) * S, cy = (w.z / 2 + map.h / 2) * S;
|
||||
mg.fillRect(cx - 3, cy - 3, 6, 6);
|
||||
}
|
||||
mg.fillStyle = '#fff';
|
||||
map.rooms.forEach((rm) => {
|
||||
mg.fillText(String(rm.id), (rm.x + 0.3) * S, (rm.y + 1) * S);
|
||||
});
|
||||
document.getElementById('mapmeta').innerHTML =
|
||||
map.rooms.map((rm) =>
|
||||
`<div class="kv"><span>room ${rm.id} · ${rm.zone}${rm.indoor ? '' : ' · open'}</span><b>${rm.w}×${rm.h}${map.watch[rm.id] ? ' · ⌂' : ''}${map.portal && map.portal.roomId === rm.id ? ' · ◉' : ''}</b></div>`).join('') +
|
||||
`<div class="legend">
|
||||
<div><i style="background:#f5b942"></i>route (closed tour)</div>
|
||||
<div><i style="background:#a3e635"></i>watch cell (room camera plant)</div>
|
||||
<div><i style="background:#f0abfc"></i>portal · <i style="background:#ef4444"></i>gate ·
|
||||
<i style="background:#fb923c"></i>torch · <i style="background:#22d3ee"></i>window ·
|
||||
<i style="background:#38bdf8"></i>puddle · <i style="background:#8b94a7"></i>prop ·
|
||||
<i style="background:#166534"></i>veg · <i style="background:#6b7280"></i>boulder ·
|
||||
<i style="background:#f87171"></i>lurker · <i style="background:#fb923c"></i>sentry ·
|
||||
<i style="background:#facc15"></i>critter · <i style="background:#a5f3fc"></i>wisp ·
|
||||
<i style="background:#fde047"></i>loot (halt)</div></div>`;
|
||||
|
||||
// ---------------------------------------------------------- 3D stills
|
||||
// Fake viz feed so windows/portal/puddles show their IMAGE (the compositor
|
||||
// would bind the real viz texture; the lab stands in a test pattern).
|
||||
function fakeViz() {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 256; c.height = 144;
|
||||
const g = c.getContext('2d');
|
||||
const grad = g.createLinearGradient(0, 0, 256, 144);
|
||||
grad.addColorStop(0, '#0ff'); grad.addColorStop(0.5, '#80f'); grad.addColorStop(1, '#f0f');
|
||||
g.fillStyle = grad; g.fillRect(0, 0, 256, 144);
|
||||
g.fillStyle = '#fff';
|
||||
for (let i = 0; i < 24; i++) g.fillRect((i * 53) % 256, (i * 97) % 144, 20, 8);
|
||||
return c;
|
||||
}
|
||||
const vizCanvas = fakeViz();
|
||||
|
||||
const engine = new Engine({ width: 480, height: 270 });
|
||||
const layer = createLayer(dungeon, {
|
||||
params: look.venue.params || {},
|
||||
seed: look.venue.seed ?? 7,
|
||||
opacity: 1, blend: 'normal',
|
||||
actorSpec: look.actors?.monolith || null,
|
||||
venueSpec: look.venue,
|
||||
});
|
||||
layer.setPalette(look.palette);
|
||||
layer.setPersonality(look.personality);
|
||||
if (layer.setActor && look.actors?.monolith) layer.setActor(look.actors.monolith);
|
||||
if (layer.setVenue) layer.setVenue(look.venue);
|
||||
engine.compositor.setLayers([layer]);
|
||||
|
||||
function bindFakeViz() {
|
||||
const tex = new THREE.CanvasTexture(vizCanvas);
|
||||
dungeon.bindVizTexture(layer.instance, tex);
|
||||
}
|
||||
bindFakeViz();
|
||||
|
||||
const fps = track.fps || 60;
|
||||
const modes = [
|
||||
{ target: 'walk', label: 'walk · first-person (actor hidden)' },
|
||||
{ target: 'room', label: 'room · plant watches actor' },
|
||||
];
|
||||
for (const t of TIMES) {
|
||||
const frame = Math.round(t * fps);
|
||||
const feat = track.at ? track.at(frame) : {};
|
||||
const tl = { frame, time: frame / fps, progress: frame / (track.frameCount || 1) };
|
||||
for (const m of modes) {
|
||||
layer.setFraming({
|
||||
scale: 1, shift: [0, 0],
|
||||
venueShot: { target: m.target, pos: [0, 1, 2], look: [0, 0.4, -5], size: 'normal', dolly: 1.0 },
|
||||
});
|
||||
for (let k = 0; k < 4; k++) {
|
||||
layer.render(engine.renderer, engine.compositor.layerTarget, {
|
||||
timeline: { ...tl, frame: frame - (3 - k), time: (frame - (3 - k)) / fps },
|
||||
features: feat, prevTexture: null,
|
||||
});
|
||||
}
|
||||
layer.render(engine.renderer, engine.compositor.layerTarget, {
|
||||
timeline: tl, features: feat, prevTexture: null,
|
||||
});
|
||||
const pix = Uint8Array.from(engine.readPixels(engine.compositor.layerTarget));
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
// Where the actor actually is: nearest route sample to the built hero.
|
||||
const hp = layer.instance.hero.position;
|
||||
let bi = 0, bd = Infinity;
|
||||
map.route.pts.forEach((p, i) => {
|
||||
const d = Math.hypot(p.x - hp.x, p.z - hp.z);
|
||||
if (d < bd) { bd = d; bi = i; }
|
||||
});
|
||||
const rid = map.route.roomIds[bi];
|
||||
const zn = map.route.zones[bi];
|
||||
card.innerHTML = `<h3>t=${t}s · <span class="mode-${m.target}">${m.target}</span> · ` +
|
||||
(rid >= 0 ? `room ${rid} (${zn})` : `corridor (${zn})`) + `</h3>`;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = 480; cv.height = 270; cv.className = 'still';
|
||||
card.appendChild(cv); out.appendChild(card);
|
||||
blit(cv, pix, 480, 270);
|
||||
}
|
||||
}
|
||||
status.innerHTML = `done — <b>${songName}</b> · map seed <b>${mapSeed}</b> · ` +
|
||||
`${map.rooms.length} rooms · route ${map.route.totalLen.toFixed(0)}u · ` +
|
||||
`${TIMES.length * modes.length} stills`;
|
||||
window.__DUNGEON_DEBUG_DONE__ = true;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -67,6 +67,19 @@ export const VENUE_STAGING = {
|
||||
box: { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 },
|
||||
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
|
||||
},
|
||||
// The dungeon: the "screen" is the way ahead (the party's lanterns, the
|
||||
// portal glow down the corridor); the "room" is the stone around the
|
||||
// walk. Marks mostly steer the facade's own walk/room/portal modes —
|
||||
// the pos/look bends the look path, the target picks the mode.
|
||||
dungeon: {
|
||||
screen: [0, 1.2, -8],
|
||||
fieldL: [-2.4, 0.2, -4.0],
|
||||
fieldR: [2.4, 0.2, -4.0],
|
||||
roomMid: [0, 0.6, -5],
|
||||
roomSide: 2.6,
|
||||
box: { x: 4.0, yLo: -0.2, yHi: 2.4, zLo: -6.0, zHi: 6.0 },
|
||||
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Shot sizes for the room — same three words as the viz, different numbers. */
|
||||
@@ -149,19 +162,24 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
|
||||
sectionSize = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== sectionSize));
|
||||
}
|
||||
if (sharpCut && prevMark) {
|
||||
// Flip the look direction outright: screen↔room, so the new
|
||||
// composition cannot be the old one from a step aside.
|
||||
// (Passerby/billboard have no opposite — re-draw instead.)
|
||||
// Flip the look direction outright: screen↔room (walk↔room
|
||||
// in the dungeon), so the new composition cannot be the old
|
||||
// one from a step aside.
|
||||
// (Passerby/billboard/portal have no opposite — re-draw instead.)
|
||||
const pt = prevMark.target;
|
||||
sectionTarget = pt === 'screen' ? 'room'
|
||||
: pt === 'room' ? 'screen'
|
||||
: pt === 'walk' ? 'room'
|
||||
: pt === 'room' ? 'walk'
|
||||
: archetype === 'dungeon' ? (rng.bool(0.5) ? 'walk' : 'room')
|
||||
: (rng.bool(0.5) ? 'screen' : 'room');
|
||||
} else {
|
||||
sectionTarget = pickTarget(prevMark, energy, story, rng,
|
||||
sectionTargets.map((t) => ({ target: t })), archetype);
|
||||
}
|
||||
// A billboard shot wants car + board in frame: force wide.
|
||||
if (sectionTarget === 'billboard' && !locked) sectionSize = 'wide';
|
||||
// A portal tableau wants the ring + procession in frame: wide too.
|
||||
if ((sectionTarget === 'billboard' || sectionTarget === 'portal') && !locked) sectionSize = 'wide';
|
||||
sectionTargets.push(sectionTarget);
|
||||
}
|
||||
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
|
||||
@@ -170,11 +188,13 @@ export function planVenueShots(cues, sections, archetype, style, isJump, rng, ga
|
||||
// rejoin the rotation, alternating away from whatever just played so
|
||||
// the cut out of the plant always changes the composition.
|
||||
let cueTarget = sectionTarget;
|
||||
if (cueTarget === 'passerby' || cueTarget === 'billboard') {
|
||||
if (cueTarget === 'passerby' || cueTarget === 'billboard' || cueTarget === 'portal') {
|
||||
sectionPlants++;
|
||||
if (sectionPlants > 1) {
|
||||
const pt = marks.length ? marks[marks.length - 1].target : null;
|
||||
cueTarget = pt === 'screen' ? 'room' : 'screen';
|
||||
cueTarget = archetype === 'dungeon'
|
||||
? (pt === 'walk' ? 'room' : 'walk')
|
||||
: (pt === 'screen' ? 'room' : 'screen');
|
||||
}
|
||||
}
|
||||
const mark = placeMark(staging, sectionSize, cueTarget, jump, rng, progress, gaze, roomSide);
|
||||
@@ -260,6 +280,31 @@ function pickTarget(previous, energy, story, rng, history, archetype = null) {
|
||||
&& last2[last2.length - 1] !== 'passerby' && rng.bool(0.3)) {
|
||||
return 'billboard';
|
||||
}
|
||||
// Dungeon-only: a portal section plants the camera on the party's path
|
||||
// and watches it process through the seated portal. Rarest plant (at most
|
||||
// a couple per video), same breath-only rule, never adjacent to itself —
|
||||
// two planted tableaux back to back read as stuck. Consumes its draw even
|
||||
// when blocked, like passerby.
|
||||
const portalDraw = archetype === 'dungeon' && act !== 'climax'
|
||||
&& (act === 'setup' || act === 'resolution' || energy < 0.42)
|
||||
&& rng.bool(0.3);
|
||||
if (portalDraw && last2[last2.length - 1] !== 'portal') {
|
||||
return 'portal';
|
||||
}
|
||||
// Dungeon travel: walk is the neutral tread, room is the watch lean —
|
||||
// first-person against planted. Same act/energy grammar as screen/room.
|
||||
if (archetype === 'dungeon') {
|
||||
let dpick;
|
||||
if (act === 'setup') dpick = 'walk';
|
||||
else if (act === 'resolution') dpick = rng.bool(0.3) ? 'room' : 'walk';
|
||||
else if (act === 'climax') dpick = 'room';
|
||||
else if (act === 'turn') dpick = rng.bool(0.75) ? 'room' : 'walk';
|
||||
else if (energy > 0.66 || tension > 0.7) dpick = 'room';
|
||||
else if (energy < 0.36) dpick = 'walk';
|
||||
else dpick = rng.bool(0.5) ? 'room' : 'walk';
|
||||
if (streak && dpick === streak) dpick = streak === 'room' ? 'walk' : 'room';
|
||||
return dpick;
|
||||
}
|
||||
let pick;
|
||||
if (act === 'setup') pick = 'room';
|
||||
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
|
||||
@@ -302,13 +347,28 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null
|
||||
look: [-side * 9.0, 3.0, -20],
|
||||
};
|
||||
}
|
||||
if (target === 'portal') {
|
||||
// Planted tableau: the camera stands off the path while the party
|
||||
// processes through the seated portal. The facade seats the portal
|
||||
// ring AT the look point each frame, so pos/look here only need to
|
||||
// agree with each other — a usable two-shot of ring + approach.
|
||||
// Side follows the video's roomSide: one consistent side all song.
|
||||
const side = roomSide || 1;
|
||||
return {
|
||||
pos: [side * 2.6, 1.5, 1.0],
|
||||
look: [-side * 0.5, 1.3, -8],
|
||||
};
|
||||
}
|
||||
// Dungeon travel modes share the room/screen anchors: walk reads the
|
||||
// way ahead (screen), room reads the place around the walk (room).
|
||||
const motive = target === 'walk' ? 'screen' : target === 'room' ? 'room' : target;
|
||||
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const { box, dolly } = staging;
|
||||
// Room visits alternate sides so consecutive room views face opposite
|
||||
// walls (the screen slides left, then right) instead of both staring down
|
||||
// the middle. Screen visits stay centred — the screen IS the axis.
|
||||
let anchor = target === 'screen' ? staging.screen : staging.roomMid;
|
||||
if (target === 'room') {
|
||||
let anchor = motive === 'screen' ? staging.screen : staging.roomMid;
|
||||
if (motive === 'room') {
|
||||
// ONE side per video (threaded in as roomSide): alternating walls is a
|
||||
// zigzag (left-right-left reads as churn, measured flat), while one
|
||||
// steady side gives the look path a consistent off-axis home it keeps
|
||||
@@ -336,7 +396,7 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null
|
||||
}
|
||||
// Close on the screen stands nearly centre (it fills the frame anyway);
|
||||
// wides take the sides (parallax over the furniture is the point).
|
||||
const cx = size === 'close' && target === 'screen' ? x * 0.3 : x;
|
||||
const cx = size === 'close' && motive === 'screen' ? x * 0.3 : x;
|
||||
// A fraction of the viz gaze relocation: both cameras cut the same way.
|
||||
// Kept small (a third) — full coupling threw the room around per section.
|
||||
const gx = gaze ? gaze[0] * 0.35 : 0;
|
||||
@@ -357,7 +417,7 @@ function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null
|
||||
// Consecutive anchors must differ ONLY by motive, never by noise.
|
||||
const screenAnchor = staging.screen;
|
||||
const forward = [0, 0.3, screenAnchor[2]];
|
||||
const k = target === 'room' ? 0.9 : 0.55;
|
||||
const k = motive === 'room' ? 0.9 : 0.55;
|
||||
const look = [
|
||||
lerp(forward[0], anchor[0], k),
|
||||
lerp(forward[1], anchor[1], k),
|
||||
@@ -396,11 +456,12 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
|
||||
// the look (the furniture swapped; the path would glide through the old
|
||||
// room). The reported target stays the cue's (HUD motive), the look itself
|
||||
// is where the path is.
|
||||
// Passerby/billboard hold their plants: the camera is a bystander, and
|
||||
// Passerby/billboard/portal hold their plants: the camera is a bystander, and
|
||||
// any drift of the look would read as a pan away from the road. driveT
|
||||
// (0→1 across the cue) is the car's cue — the module drives it from far
|
||||
// +z to far -z on it (passerby) or aims at the live panel (billboard).
|
||||
const look = (cur.target === 'passerby' || cur.target === 'billboard')
|
||||
const plantedMark = cur.target === 'passerby' || cur.target === 'billboard' || cur.target === 'portal';
|
||||
const look = plantedMark
|
||||
? cur.look.slice()
|
||||
: lookPathAt(marks, progress, cur.jump !== null ? cur : null);
|
||||
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
|
||||
@@ -429,7 +490,7 @@ export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame =
|
||||
// A planted camera holds its position too — the bystander does not drift
|
||||
// down the road while the car passes. (The look already holds above; the
|
||||
// module adds handheld micro on top.)
|
||||
const planted = cur.target === 'passerby' || cur.target === 'billboard';
|
||||
const planted = cur.target === 'passerby' || cur.target === 'billboard' || cur.target === 'portal';
|
||||
return {
|
||||
pos: planted ? cur.pos.slice()
|
||||
: [lerp(prev.pos[0], cur.pos[0], e), lerp(prev.pos[1], cur.pos[1], e), lerp(prev.pos[2], cur.pos[2], e)],
|
||||
|
||||
@@ -346,7 +346,7 @@ export function validateModule(module) {
|
||||
if (module.venue && typeof module.venue !== 'boolean') {
|
||||
errors.push(`${id}: \`venue\` must be boolean`);
|
||||
}
|
||||
if (module.archetype && !['chamber','stage','ride','passage','void'].includes(module.archetype)) {
|
||||
if (module.archetype && !['chamber','stage','ride','dungeon','passage','void'].includes(module.archetype)) {
|
||||
errors.push(`${id}: unknown venue archetype '${module.archetype}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { signatureWeight } from '../look/Personality.js';
|
||||
import { venues, ARCHETYPES } from './registry.js';
|
||||
import { genDungeon } from './dungeon/map.js';
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
@@ -42,6 +43,14 @@ export function generateVenue({ summary = {}, rng, personality = null, director
|
||||
if (v.archetype === 'chamber') w *= 0.7 + stillness * 0.8 + (1 - fast) * 0.35;
|
||||
if (v.archetype === 'stage') w *= 0.6 + fast * 0.6 + noisy * 0.35;
|
||||
if (v.archetype === 'ride') w *= 0.35 + drive * 1.4;
|
||||
// Dark, slow songs want a crypt: slowness gates the whole tilt, then
|
||||
// stillness and darkness push inside it. Fast songs barely feel it.
|
||||
// Tilt, not decide.
|
||||
if (v.archetype === 'dungeon') {
|
||||
const slow = clamp01((110 - (s.bpm ?? 120)) / 50);
|
||||
const dark = 1 - Math.min(1, (s.meanCentroid ?? 0.5) * 1.4);
|
||||
w *= 0.12 + slow * (0.3 + stillness * 1.0 + dark * 0.5);
|
||||
}
|
||||
tilt[v.name] = w;
|
||||
weights[i] = w;
|
||||
}
|
||||
@@ -62,19 +71,25 @@ export function generateVenue({ summary = {}, rng, personality = null, director
|
||||
};
|
||||
|
||||
// Projection — hybrid per archetype. The ride's screen is the car radio:
|
||||
// small, bright, watched up close.
|
||||
// small, bright, watched up close. The dungeon's is the portal stone:
|
||||
// architectural, bright, approached on foot.
|
||||
const projection = picked.archetype === 'chamber'
|
||||
? { kind: 'window', uvScale: 1, brightness: 0.55 }
|
||||
: picked.archetype === 'ride'
|
||||
? { kind: 'radio', uvScale: 1, brightness: 0.9 }
|
||||
: { kind: 'screen', uvScale: 1, brightness: 0.75 };
|
||||
: picked.archetype === 'dungeon'
|
||||
? { kind: 'portal', uvScale: 1, brightness: 0.9 }
|
||||
: { kind: 'screen', uvScale: 1, brightness: 0.75 };
|
||||
|
||||
// Population — lattice-like, deterministic count/spread/jitter. The ride
|
||||
// seats actor passengers (2-4), not a crowd.
|
||||
// seats actor passengers (2-4), not a crowd; the dungeon walks a delving
|
||||
// party (2-4), not a crowd either.
|
||||
const latticeKinds = ['grid', 'scatter', 'radial'];
|
||||
const pRng = rng.fork(`venue:pop:${picked.name}`);
|
||||
const population = picked.archetype === 'ride'
|
||||
? { kind: 'row', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) }
|
||||
: picked.archetype === 'dungeon'
|
||||
? { kind: 'party', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) }
|
||||
: {
|
||||
kind: pRng.pick(latticeKinds),
|
||||
count: pRng.int(picked.archetype === 'chamber' ? 3 : 4, picked.archetype === 'chamber' ? 9 : 12),
|
||||
@@ -153,10 +168,24 @@ export function generateVenue({ summary = {}, rng, personality = null, director
|
||||
}
|
||||
}
|
||||
|
||||
// Dungeon sub-plans: the floor plan itself — grid, rooms, walls, route,
|
||||
// items, portal, watch cells. Pure data, seeded, serialisable; the facade
|
||||
// builds it. Other archetypes get dungeon:null. Forked so existing draws
|
||||
// don't move (same rule as venue:pop).
|
||||
let dungeonPlan = null;
|
||||
if (picked.archetype === 'dungeon') {
|
||||
const dRng = rng.fork('dungeon:plan');
|
||||
dungeonPlan = {
|
||||
map: genDungeon({ seed, rng: dRng }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
archetype: picked.archetype,
|
||||
name: picked.name,
|
||||
seed,
|
||||
summary: { ...(summary || {}) },
|
||||
dungeon: dungeonPlan,
|
||||
shell,
|
||||
projection,
|
||||
population,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
// Hazards submodule — gates and set-pieces the walk appears to negotiate.
|
||||
//
|
||||
// Visual only: the facade adds avoidanceOffset() to the hero's lateral x.
|
||||
// Pure + headless-safe: no renderer, no wall-clock, no Math.random — seeded Rng
|
||||
// only, all motion analytic f(t) so seek === playback and preview === export.
|
||||
// Type is a discrete plan-time choice; footprints stay narrow so a hazard never
|
||||
// swallows the path. The world is STATIC (the camera walks), so hazard marks
|
||||
// sit at fixed arclength and the hero is compared against them directly.
|
||||
//
|
||||
// Contract (exact export names/signatures):
|
||||
// HAZARD_BUILDERS: registry object { gate, gap, patrol }
|
||||
// planHazards({ seed, rng, sections, span }) -> per-section [{s, type}]
|
||||
// buildHazards({ THREE, sectionPlan, seed }) -> { group, items }
|
||||
// updateHazards({ hazards, sectionPlan, t, light, opacity, pathPlan }) -> void
|
||||
// avoidanceOffset(heroS, sectionPlan, t) -> lateral nudge, |x| <= 1.0
|
||||
|
||||
import { smooth01 } from './shared.js';
|
||||
|
||||
const FLOOR_Y = -1.15;
|
||||
|
||||
function std(THREE, color, opts = {}) {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(color[0], color[1], color[2]),
|
||||
roughness: opts.roughness ?? 0.6,
|
||||
metalness: opts.metalness ?? 0.1,
|
||||
emissive: new THREE.Color(
|
||||
(opts.emissive ?? [0, 0, 0])[0],
|
||||
(opts.emissive ?? [0, 0, 0])[1],
|
||||
(opts.emissive ?? [0, 0, 0])[2]
|
||||
),
|
||||
emissiveIntensity: opts.emissiveIntensity ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
function stone(THREE, tint) {
|
||||
return std(THREE, tint, { roughness: 0.9, metalness: 0.02, emissive: tint, emissiveIntensity: 0.12 });
|
||||
}
|
||||
|
||||
function gate(THREE, rng, tint) {
|
||||
// Portcullis: two jambs + lintel + hanging bars. The walk passes through
|
||||
// the middle — the hero sidesteps via avoidanceOffset, never through iron.
|
||||
const g = new THREE.Group();
|
||||
const mat = stone(THREE, tint);
|
||||
const iron = std(THREE, [0.1, 0.1, 0.12], { roughness: 0.45, metalness: 0.7 });
|
||||
for (const side of [-1, 1]) {
|
||||
const jamb = new THREE.Mesh(new THREE.BoxGeometry(0.36, 3.4, 0.5), mat);
|
||||
jamb.position.set(side * 1.85, FLOOR_Y + 1.7, 0);
|
||||
g.add(jamb);
|
||||
}
|
||||
const lintel = new THREE.Mesh(new THREE.BoxGeometry(4.1, 0.5, 0.5), mat);
|
||||
lintel.position.set(0, FLOOR_Y + 3.6, 0);
|
||||
g.add(lintel);
|
||||
const n = 5;
|
||||
const bars = new THREE.Group();
|
||||
for (let k = 0; k < n; k++) {
|
||||
const bar = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 2.6, 6), iron);
|
||||
bar.position.set(-1.2 + k * (2.4 / (n - 1)), FLOOR_Y + 2.1, 0);
|
||||
bars.add(bar);
|
||||
}
|
||||
g.add(bars);
|
||||
// Lifted per frame by updateHazards: the gate opens as the hero nears
|
||||
// and falls behind them — the walk passes through iron, never through
|
||||
// bars. Analytic f(heroS), so a seek lands on the same opening.
|
||||
g.userData.liftBars = bars;
|
||||
return g;
|
||||
}
|
||||
|
||||
function gap(THREE, rng, tint) {
|
||||
// Broken floor: tilted flagstone shards in the FLOOR's own colours with
|
||||
// one dim stone crack breathing between them — damage in place, not a
|
||||
// black square. Pure visual — the hero steps around it via
|
||||
// avoidanceOffset. The facade re-tints all shard/crack materials per
|
||||
// frame from the floor (see the gapMats registry below) so the break
|
||||
// always matches its surroundings; the tints passed here are only the
|
||||
// build-time seed visible in headless checks.
|
||||
const g = new THREE.Group();
|
||||
const shardMat = stone(THREE, tint);
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const w = 0.4 + rng.next() * 0.35;
|
||||
const shard = new THREE.Mesh(new THREE.BoxGeometry(w, 0.07, 0.3 + rng.next() * 0.25), shardMat);
|
||||
const a = (k / 5) * Math.PI * 2 + rng.next() * 0.5;
|
||||
shard.position.set(Math.cos(a) * (0.55 + rng.next() * 0.25), FLOOR_Y + 0.045, Math.sin(a) * (0.65 + rng.next() * 0.3));
|
||||
shard.rotation.y = -a + (rng.next() - 0.5) * 0.6;
|
||||
shard.rotation.z = (rng.next() - 0.5) * 0.35;
|
||||
g.add(shard);
|
||||
}
|
||||
const edgeMat = std(THREE, tint, { roughness: 0.5, emissive: tint, emissiveIntensity: 0.8 });
|
||||
const crack = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.03, 1.1), edgeMat);
|
||||
crack.position.set((rng.next() - 0.5) * 0.3, FLOOR_Y + 0.03, 0);
|
||||
crack.rotation.y = (rng.next() - 0.5) * 0.4;
|
||||
g.add(crack);
|
||||
// Both materials re-tinted per frame — the facade owns the floor colours.
|
||||
g.userData.edgeMat = edgeMat;
|
||||
g.userData.gapMats = { shardMat, crackMat: edgeMat };
|
||||
return g;
|
||||
}
|
||||
|
||||
function patrol(THREE, rng, tint) {
|
||||
// Watch lantern: post + swinging glow cage. The sway is analytic f(t).
|
||||
const g = new THREE.Group();
|
||||
const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 });
|
||||
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.6, 6), iron);
|
||||
post.position.y = FLOOR_Y + 1.3;
|
||||
g.add(post);
|
||||
const cageMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 1.6 });
|
||||
const cage = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), cageMat);
|
||||
cage.position.y = FLOOR_Y + 2.7;
|
||||
g.add(cage);
|
||||
const arm = new THREE.Group();
|
||||
arm.position.y = FLOOR_Y + 2.7;
|
||||
const lampMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 1.2 });
|
||||
const lamp = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.7, 10, 1, true), lampMat);
|
||||
lamp.position.y = -0.5;
|
||||
arm.add(lamp);
|
||||
g.add(arm);
|
||||
g.userData.swing = { arm, phase: rng.next() * Math.PI * 2, cageMat, base: 1.6 };
|
||||
return g;
|
||||
}
|
||||
|
||||
export const HAZARD_BUILDERS = { gate, gap, patrol };
|
||||
|
||||
export const HAZARD_TINTS = {
|
||||
gate: [0.45, 0.45, 0.48],
|
||||
gap: [1.0, 0.4, 0.1],
|
||||
patrol: [0.95, 0.75, 0.4],
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-frame life for map-seated hazards. Positions are fixed (the map seats
|
||||
* everything once at build); only the portcullis lift, lantern swing and
|
||||
* ember glow animate, all analytic f(t, heroS) so seeks agree with playback.
|
||||
*
|
||||
* Each item carries userData.home = { x, z, yaw } (set by the facade at
|
||||
* build) and gates additionally carry userData.hazardS (route arclength).
|
||||
* partyS lists the route positions of the whole procession (hero + trailing
|
||||
* companions, lead members in first-person) so the portcullis stays up until
|
||||
* the last walker clears it — never hero-only.
|
||||
*/
|
||||
export function updateHazards({ hazards = null, t = 0, light = 1, opacity = 1, heroS = null, partyS = null } = {}) {
|
||||
const items = hazards?.items ?? (Array.isArray(hazards) ? hazards : []);
|
||||
for (const item of items) {
|
||||
item.visible = opacity > 0.001;
|
||||
if (!item.visible) continue;
|
||||
const home = item.userData.home;
|
||||
if (home) {
|
||||
item.position.set(home.x, 0, home.z);
|
||||
item.rotation.y = home.yaw || 0;
|
||||
}
|
||||
// Portcullis lift: shut at distance, fully raised while ANY walker
|
||||
// of the procession is under it. The hero leads in first-person and
|
||||
// trails in planted shots, so the lift keyed on the hero alone drops
|
||||
// on the trailers (or shuts on the leader). Opens over ~4 units of
|
||||
// approach on the near side and holds until the last member clears
|
||||
// the far side + ~2 margin — no bar ever fills the near plane and no
|
||||
// walker passes through iron.
|
||||
const lift = item.userData.liftBars;
|
||||
if (lift && typeof item.userData.hazardS === 'number') {
|
||||
const members = Array.isArray(partyS) && partyS.length
|
||||
? partyS
|
||||
: (typeof heroS === 'number' ? [heroS] : []);
|
||||
let liftK = 0;
|
||||
for (const ms of members) {
|
||||
if (typeof ms !== 'number') continue;
|
||||
const d = Math.abs(ms - item.userData.hazardS);
|
||||
liftK = Math.max(liftK, smooth01((4.5 - d) / 3.7));
|
||||
}
|
||||
lift.position.y = 2.4 * liftK;
|
||||
}
|
||||
const sw = item.userData.swing;
|
||||
if (sw) {
|
||||
sw.arm.rotation.z = Math.sin(t * 0.8 + sw.phase) * 0.35;
|
||||
sw.cageMat.emissiveIntensity = sw.base * (0.4 + 0.6 * light)
|
||||
* (0.85 + 0.15 * Math.sin(t * 3.1 + sw.phase));
|
||||
}
|
||||
const em = item.userData.edgeMat;
|
||||
if (em) em.emissiveIntensity = 0.8 * (0.4 + 0.6 * light);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lateral hero nudge so the walk appears to negotiate near hazards.
|
||||
* Pure f(heroS, sectionPlan, t): hazards sit at fixed s, the hero is compared
|
||||
* against them directly. Each nearby hazard pushes the hero away with a
|
||||
* gaussian falloff (sigma 3, amp 0.7); gates push to alternating sides by
|
||||
* index (through the bars' gaps, never through iron). Clamped to |x| <= 1.0
|
||||
* so the hero holds the corridor.
|
||||
*/
|
||||
export function avoidanceOffset(heroS = 0, sectionPlan = null, t = 0) {
|
||||
let list = sectionPlan ?? [];
|
||||
if (Array.isArray(list) && list.length && Array.isArray(list[0])) list = list.flat();
|
||||
if (!Array.isArray(list)) return 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const e = list[i] ?? {};
|
||||
const raw = (heroS - (e.s ?? 0)) / 3;
|
||||
const g = Math.exp(-(raw * raw));
|
||||
if (g < 0.001) continue;
|
||||
const dir = (i % 2 === 0 ? 1 : -1);
|
||||
sum += 0.7 * g * dir;
|
||||
}
|
||||
void t;
|
||||
return Math.max(-1.0, Math.min(1.0, sum));
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
// 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:
|
||||
// genDungeon({ seed, rng }) -> 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
|
||||
//
|
||||
// DungeonPlan:
|
||||
// { seed, w, h, grid, rooms, wallRuns, ceilRects, route, items, portal, watch }
|
||||
// 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} }
|
||||
|
||||
import { Rng } from '../../engine/rng.js';
|
||||
import { smooth01 } from './shared.js';
|
||||
|
||||
export const CELL = 2.0;
|
||||
const GW = 44;
|
||||
const GH = 44;
|
||||
const ROOM_TARGET = 8;
|
||||
const ROOM_MIN = 4;
|
||||
const ROOM_MAX = 8;
|
||||
|
||||
const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk'];
|
||||
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 } = {}) {
|
||||
const r = rng ? rng.fork('dungeon:map') : new Rng((seed >>> 0) || 1).fork('dungeon:map');
|
||||
const sd = (seed >>> 0) || 1;
|
||||
|
||||
// --- rooms: rejection-sampled rects with a 1-cell moat between them, so
|
||||
// corridors always have somewhere to go and rooms never share walls.
|
||||
const rooms = [];
|
||||
for (let attempt = 0; attempt < 240 && rooms.length < ROOM_TARGET; attempt++) {
|
||||
const w = r.int(ROOM_MIN, ROOM_MAX);
|
||||
const h = r.int(ROOM_MIN, ROOM_MAX);
|
||||
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: dealt round-robin off a seeded shuffle, so every map with
|
||||
// 4+ rooms holds halls, rooms, forest and castle walls alike.
|
||||
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]];
|
||||
}
|
||||
order.forEach((rm, i) => {
|
||||
rm.zone = ZONE_NAMES[i % ZONE_NAMES.length];
|
||||
rm.indoor = zoneIndoor(rm.zone);
|
||||
});
|
||||
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: [] };
|
||||
// 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.
|
||||
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;
|
||||
const rocky = zone === 'wallwalk';
|
||||
const kind = rocky
|
||||
? (hsh % 3 === 2 ? 'bush' : 'boulder')
|
||||
: (hsh % 3 === 0 ? 'tree' : 'bush');
|
||||
items.vegetation.push({
|
||||
x: p.x + jx, z: p.z + jz,
|
||||
kind,
|
||||
s: 0.8 + (((hsh >>> 16) % 100) / 100) * 0.6,
|
||||
});
|
||||
}
|
||||
// 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);
|
||||
const type = rm.indoor
|
||||
? rItems.pick(['rubble', 'furniture', 'rubble', 'banner', 'stalagmite'])
|
||||
: rItems.pick(['trees', 'rubble', 'rampart', 'trees']);
|
||||
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: 4 spread round the loop, slightly off the walked line.
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const i = Math.floor((k + 0.5) / 4 * pts.length) % pts.length;
|
||||
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 });
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- 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 };
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Props submodule — library of dungeon dressing + the props the walk meets.
|
||||
//
|
||||
// Pure + headless-safe: no renderer, no wall-clock, no Math.random — seeded Rng
|
||||
// only, all motion analytic f(t) so seek === playback and preview === export.
|
||||
// Type/side are discrete plan-time choices; footprints stay small so a prop
|
||||
// never swallows the path. The world is STATIC (the camera walks, unlike the
|
||||
// ride where the world streams), so props are seated once per section plan and
|
||||
// only their glow animates per frame.
|
||||
//
|
||||
// Contract (exact export names/signatures):
|
||||
// PROP_BUILDERS: registry object { rubble, furniture, trees, rampart, torch, banner, stalagmite }
|
||||
// planProps({ seed, rng, sections, span }) -> per-section [{s, side, type}]
|
||||
// planPortal({ seed, rng, span }) -> { s, side }
|
||||
// buildProps({ THREE, sectionPlan, palette, seed }) -> { group, items }
|
||||
// updateProps({ props, sectionPlan, t, light, popMul, opacity, palette, pathPlan }) -> void
|
||||
// buildPortal({ THREE, palette }) -> { group, discMat }
|
||||
// PORTAL_RING_R: portal ring radius (facade frames the shot around it)
|
||||
|
||||
|
||||
const FLOOR_Y = -1.15;
|
||||
|
||||
export const PORTAL_RING_R = 1.3;
|
||||
|
||||
function wrapS(s, span) {
|
||||
return (((s % span) + span) % span);
|
||||
}
|
||||
|
||||
function std(THREE, color, opts = {}) {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(color[0], color[1], color[2]),
|
||||
roughness: opts.roughness ?? 0.7,
|
||||
metalness: opts.metalness ?? 0.08,
|
||||
emissive: new THREE.Color(
|
||||
(opts.emissive ?? [0, 0, 0])[0],
|
||||
(opts.emissive ?? [0, 0, 0])[1],
|
||||
(opts.emissive ?? [0, 0, 0])[2]
|
||||
),
|
||||
emissiveIntensity: opts.emissiveIntensity ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
function stoneMat(THREE, tint) {
|
||||
return std(THREE, tint, { roughness: 0.9, metalness: 0.02, emissive: tint, emissiveIntensity: 0.12 });
|
||||
}
|
||||
|
||||
function box(THREE, g, w, h, d, material, x, y, z, ry = 0) {
|
||||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material);
|
||||
m.position.set(x, y, z);
|
||||
if (ry) m.rotation.y = ry;
|
||||
g.add(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function rubble(THREE, rng, tint) {
|
||||
const g = new THREE.Group();
|
||||
const mat = stoneMat(THREE, tint);
|
||||
const n = 2 + Math.floor(rng.next() * 3);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const r = 0.25 + rng.next() * 0.45;
|
||||
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(r, 0), mat);
|
||||
rock.position.set((rng.next() - 0.5) * 1.6, FLOOR_Y + r * 0.4, (rng.next() - 0.5) * 1.6);
|
||||
rock.scale.y = 0.7;
|
||||
rock.rotation.set(rng.next() * 3, rng.next() * 3, rng.next() * 3);
|
||||
g.add(rock);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function furniture(THREE, rng, tint) {
|
||||
// A broken table: top slab + legs, one leg short so it tilts.
|
||||
const g = new THREE.Group();
|
||||
const mat = stoneMat(THREE, tint);
|
||||
const top = box(THREE, g, 1.4, 0.09, 0.9, mat, 0, FLOOR_Y + 0.72, 0);
|
||||
top.rotation.z = 0.06;
|
||||
for (const [sx, sz, h] of [[-0.6, -0.35, 0.72], [0.6, -0.35, 0.72], [-0.6, 0.35, 0.72], [0.6, 0.35, 0.5]]) {
|
||||
box(THREE, g, 0.08, h, 0.08, mat, sx, FLOOR_Y + h / 2, sz);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function trees(THREE, rng, tint) {
|
||||
const g = new THREE.Group();
|
||||
const bark = stoneMat(THREE, tint);
|
||||
const leaf = std(THREE, tint, { roughness: 0.9, emissive: tint, emissiveIntensity: 0.3 });
|
||||
const n = 2 + Math.floor(rng.next() * 2);
|
||||
for (let k = 0; k < n; k++) {
|
||||
const h = 1.6 + rng.next() * 1.6;
|
||||
const x = (rng.next() - 0.5) * 3, z = (rng.next() - 0.5) * 3;
|
||||
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.13, 0.9, 5), bark);
|
||||
trunk.position.set(x, FLOOR_Y + 0.45, z);
|
||||
const crown = new THREE.Mesh(new THREE.ConeGeometry(0.65 + rng.next() * 0.4, h, 7), leaf);
|
||||
crown.position.set(x, FLOOR_Y + 0.9 + h / 2, z);
|
||||
crown.userData.swayPhase = rng.next() * Math.PI * 2;
|
||||
g.add(trunk); g.add(crown);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function rampart(THREE, rng, tint) {
|
||||
// Crenellated parapet block: wall + merlons, long axis with the path.
|
||||
const g = new THREE.Group();
|
||||
const mat = stoneMat(THREE, tint);
|
||||
box(THREE, g, 0.5, 1.0, 2.6, mat, 0, FLOOR_Y + 0.5, 0);
|
||||
const n = 3 + Math.floor(rng.next() * 2);
|
||||
for (let k = 0; k < n; k++) {
|
||||
box(THREE, g, 0.5, 0.4, 0.34, mat, 0, FLOOR_Y + 1.2, -1.1 + k * (2.2 / Math.max(1, n - 1)));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function torchProp(THREE, rng, tint) {
|
||||
// Wall bracket + flame: the flame material flickers in update.
|
||||
const g = new THREE.Group();
|
||||
const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 });
|
||||
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.5, 6), iron);
|
||||
arm.rotation.x = 0.7;
|
||||
arm.position.set(0, FLOOR_Y + 1.7, 0);
|
||||
g.add(arm);
|
||||
const cup = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.06, 0.14, 8), iron);
|
||||
cup.position.set(0, FLOOR_Y + 1.88, 0.17);
|
||||
g.add(cup);
|
||||
const flameMat = std(THREE, tint, { roughness: 0.4, emissive: tint, emissiveIntensity: 2.2 });
|
||||
const flame = new THREE.Mesh(new THREE.SphereGeometry(0.11, 10, 8), flameMat);
|
||||
flame.position.set(0, FLOOR_Y + 2.02, 0.17);
|
||||
flame.scale.y = 1.5;
|
||||
g.add(flame);
|
||||
g.userData.flame = { mat: flameMat, mesh: flame, phase: rng.next() * Math.PI * 2, base: 2.2 };
|
||||
return g;
|
||||
}
|
||||
|
||||
function banner(THREE, rng, tint) {
|
||||
const g = new THREE.Group();
|
||||
const iron = std(THREE, [0.08, 0.08, 0.09], { roughness: 0.5, metalness: 0.6 });
|
||||
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2.6, 6), iron);
|
||||
pole.position.set(0, FLOOR_Y + 1.3, 0);
|
||||
g.add(pole);
|
||||
const clothMat = std(THREE, tint, { roughness: 0.85, emissive: tint, emissiveIntensity: 0.35 });
|
||||
const cloth = new THREE.Mesh(new THREE.PlaneGeometry(0.7, 1.4), clothMat);
|
||||
cloth.position.set(0.36, FLOOR_Y + 1.7, 0);
|
||||
g.add(cloth);
|
||||
g.userData.cloth = { mesh: cloth, phase: rng.next() * Math.PI * 2 };
|
||||
return g;
|
||||
}
|
||||
|
||||
function stalagmite(THREE, rng, tint) {
|
||||
const g = new THREE.Group();
|
||||
const mat = stoneMat(THREE, tint);
|
||||
const h = 0.8 + rng.next() * 1.4;
|
||||
const cone = new THREE.Mesh(new THREE.ConeGeometry(0.3 + rng.next() * 0.2, h, 7), mat);
|
||||
cone.position.set(0, FLOOR_Y + h / 2, 0);
|
||||
g.add(cone);
|
||||
if (rng.next() < 0.5) {
|
||||
// Stalactite above: the cave bites both ways.
|
||||
const ch = 0.5 + rng.next() * 0.8;
|
||||
const up = new THREE.Mesh(new THREE.ConeGeometry(0.25, ch, 7), mat);
|
||||
up.rotation.x = Math.PI;
|
||||
up.position.set((rng.next() - 0.5) * 0.6, FLOOR_Y + 3.4 - ch / 2, 0);
|
||||
g.add(up);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function bush(THREE, rng, tint) {
|
||||
// One-mesh boundary planting: a squashed leafy blob, faintly emissive
|
||||
// so the green edge reads at night. Planted densely along outdoor
|
||||
// boundaries, so cheap matters more than pretty.
|
||||
const g = new THREE.Group();
|
||||
const leaf = std(THREE, tint, { roughness: 0.9, emissive: tint, emissiveIntensity: 0.3 });
|
||||
const blob = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 0), leaf);
|
||||
blob.scale.set(1 + rng.next() * 0.4, 0.65 + rng.next() * 0.2, 1 + rng.next() * 0.4);
|
||||
blob.position.y = FLOOR_Y + 0.3;
|
||||
blob.rotation.y = rng.next() * Math.PI;
|
||||
g.add(blob);
|
||||
return g;
|
||||
}
|
||||
|
||||
function boulder(THREE, rng, tint) {
|
||||
// One-mesh rocky barrier: a squashed gray mass for castle-wall edges.
|
||||
// Faint emissive like the junk stone so dark songs still read it.
|
||||
const g = new THREE.Group();
|
||||
const mat = stoneMat(THREE, tint);
|
||||
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(0.65, 0), mat);
|
||||
rock.scale.set(1 + rng.next() * 0.4, 0.7 + rng.next() * 0.25, 1 + rng.next() * 0.4);
|
||||
rock.position.y = FLOOR_Y + 0.42;
|
||||
rock.rotation.set(rng.next() * 3, rng.next() * 3, rng.next() * 3);
|
||||
g.add(rock);
|
||||
return g;
|
||||
}
|
||||
|
||||
export const PROP_BUILDERS = { rubble, furniture, trees, rampart, torch: torchProp, banner, stalagmite, bush, boulder };
|
||||
|
||||
export const PROP_TINTS = {
|
||||
rubble: [0.45, 0.44, 0.46],
|
||||
furniture: [0.4, 0.33, 0.26],
|
||||
trees: [0.16, 0.32, 0.16],
|
||||
rampart: [0.48, 0.47, 0.5],
|
||||
torch: [1.0, 0.55, 0.18],
|
||||
banner: [0.6, 0.14, 0.16],
|
||||
stalagmite: [0.5, 0.5, 0.54],
|
||||
bush: [0.14, 0.3, 0.14],
|
||||
boulder: [0.45, 0.44, 0.46],
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytic glow for placed dressing: flames breathe, cloth sways. Positions
|
||||
* are fixed (the map seats everything once); only light animates, as pure
|
||||
* f(t). Works on any group carrying userData.flame / userData.cloth, i.e.
|
||||
* torch and banner builder output.
|
||||
*/
|
||||
export function animateDressing(item, t, light = 1) {
|
||||
const fl = item.userData.flame;
|
||||
if (fl && fl.mat) {
|
||||
fl.mat.emissiveIntensity = fl.base * (0.35 + 0.65 * light)
|
||||
* (0.85 + 0.15 * Math.sin(t * 9.3 + fl.phase));
|
||||
if (fl.mesh) fl.mesh.scale.y = 1.5 + Math.sin(t * 7.1 + fl.phase) * 0.12;
|
||||
}
|
||||
const cl = item.userData.cloth;
|
||||
if (cl && cl.mesh) {
|
||||
cl.mesh.rotation.y = Math.sin(t * 0.9 + cl.phase) * 0.18;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The portal: a standing stone ring with a viz-fed disc. The disc material is
|
||||
* returned so the facade can bind the viz texture (emissiveMap) per frame —
|
||||
* the IMAGE is the video, the ring is the song's stone.
|
||||
*/
|
||||
export function buildPortal({ THREE, palette = null } = {}) {
|
||||
const pal = Array.isArray(palette) && palette.length ? palette : [[0.7, 0.8, 1.0]];
|
||||
const group = new THREE.Group();
|
||||
group.name = 'portal';
|
||||
const stone = stoneMat(THREE, [0.42, 0.42, 0.46]);
|
||||
const ring = new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RING_R, 0.22, 10, 28), stone);
|
||||
ring.position.y = FLOOR_Y + PORTAL_RING_R + 0.2;
|
||||
group.add(ring);
|
||||
for (const side of [-1, 1]) {
|
||||
const foot = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.6, 0.5), stone);
|
||||
foot.position.set(side * (PORTAL_RING_R + 0.1), FLOOR_Y + 0.3, 0);
|
||||
group.add(foot);
|
||||
}
|
||||
const discMat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(0.02, 0.02, 0.03),
|
||||
emissive: new THREE.Color(1, 1, 1),
|
||||
emissiveIntensity: 1.2,
|
||||
roughness: 0.5, metalness: 0.1,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const disc = new THREE.Mesh(new THREE.CircleGeometry(PORTAL_RING_R - 0.1, 28), discMat);
|
||||
disc.position.y = FLOOR_Y + PORTAL_RING_R + 0.2;
|
||||
disc.name = 'portalDisc';
|
||||
group.add(disc);
|
||||
// A seeded accent arc on the ring so the portal belongs to the song.
|
||||
const c = pal[1 % pal.length] || pal[0];
|
||||
const arcMat = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(0.06, 0.06, 0.07),
|
||||
emissive: new THREE.Color(c[0], c[1], c[2]),
|
||||
emissiveIntensity: 0.9,
|
||||
roughness: 0.5,
|
||||
});
|
||||
const arc = new THREE.Mesh(new THREE.TorusGeometry(PORTAL_RING_R + 0.02, 0.07, 8, 12, Math.PI * 0.7), arcMat);
|
||||
arc.position.copy(ring.position);
|
||||
arc.rotation.z = Math.PI * 0.15;
|
||||
group.add(arc);
|
||||
group.userData.arcMat = arcMat;
|
||||
return { group, discMat };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Shared helpers for the dungeon venue submodules (layout / zones / props /
|
||||
// hazards). Pure, no THREE, no wall-clock, no Math.random — seeded Rng only,
|
||||
// all motion analytic f(t, seed) so seek === playback.
|
||||
//
|
||||
// A submodule owns ONE portion of the image and the 3D space its objects live
|
||||
// in. The facade (../dungeon.js) owns the single walk clock and the path;
|
||||
// every submodule reads (t, plan, pathAt) and never integrates.
|
||||
|
||||
import { Rng } from '../../engine/rng.js';
|
||||
|
||||
/** Independent seeded stream that never perturbs sibling streams. */
|
||||
export function forkDungeon(seed, label) {
|
||||
return new Rng(hashSeed(seed) ^ 0x9e3779b9).fork(String(label));
|
||||
}
|
||||
|
||||
function hashSeed(seed) {
|
||||
return (seed >>> 0) || 1;
|
||||
}
|
||||
|
||||
/** Analytic wrap: position of a base offset walking at rate dz after time t. */
|
||||
export function streamWrap(s0, t, dz, span) {
|
||||
const s = s0 + t * dz;
|
||||
return s0 + ((((s - s0) % span) + span) % span);
|
||||
}
|
||||
|
||||
/** Smoothstep 0..1. */
|
||||
export function smooth01(x) {
|
||||
const t = Math.max(0, Math.min(1, x));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crew-style visibility fade: how many of a population with `count` members
|
||||
* show at a continuous level in [0, ~1.1]. Never toggles — pure f(level,index).
|
||||
* Same pattern as ride.js crew fade (DESIGN §4: furniture never hard-toggles
|
||||
* on interpolated state).
|
||||
*/
|
||||
export function fadeFor(level, index, count, soft = 0.12) {
|
||||
const showIndex = index / Math.max(1, count);
|
||||
return Math.max(0, Math.min(1, (level - showIndex) / soft));
|
||||
}
|
||||
|
||||
/** Segment kinds the layout planner may emit. */
|
||||
export const SEGMENT_KINDS = [
|
||||
'hall',
|
||||
'room',
|
||||
'forest',
|
||||
'wallwalk',
|
||||
];
|
||||
|
||||
/** Zone names the zone planner may emit (one per section). */
|
||||
export const ZONE_NAMES = ['hall', 'room', 'forest', 'wallwalk'];
|
||||
|
||||
/** Prop types in the prop library (primitives only, no glTF). */
|
||||
export const PROP_TYPES = ['rubble', 'furniture', 'trees', 'rampart', 'torch', 'banner', 'stalagmite'];
|
||||
|
||||
/** Hazard types (visual only — the walk sidesteps them). */
|
||||
export const HAZARD_TYPES = ['gate', 'gap', 'patrol'];
|
||||
@@ -7,10 +7,11 @@ import { validateModule } from '../params/schema.js';
|
||||
import { chamber } from './chamber.js';
|
||||
import { stage } from './stage.js';
|
||||
import { ride } from './ride.js';
|
||||
import { dungeon } from './dungeon.js';
|
||||
|
||||
export const ARCHETYPES = ['chamber', 'stage', 'ride'];
|
||||
export const ARCHETYPES = ['chamber', 'stage', 'ride', 'dungeon'];
|
||||
|
||||
const MODULES = [chamber, stage, ride];
|
||||
const MODULES = [chamber, stage, ride, dungeon];
|
||||
|
||||
const errors = [];
|
||||
for (const m of MODULES) {
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { Rng } from '../src/engine/rng.js';
|
||||
import { genDungeon, pathAt, roomAt, zoneAt, odometer, entityPose, CELL } from '../src/venues/dungeon/map.js';
|
||||
import { PROP_BUILDERS } from '../src/venues/dungeon/props.js';
|
||||
import { HAZARD_BUILDERS, avoidanceOffset } from '../src/venues/dungeon/hazards.js';
|
||||
import { generateVenue } from '../src/venues/VenueGenerator.js';
|
||||
import { generatePersonality } from '../src/look/Personality.js';
|
||||
|
||||
test('dungeon map is deterministic with rooms, zones and a closed route', () => {
|
||||
const a = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const b = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.deepEqual(a, b, 'same seed must generate the same dungeon');
|
||||
assert.ok(a.rooms.length >= 3, `only ${a.rooms.length} rooms`);
|
||||
const zones = new Set(a.rooms.map((r) => r.zone));
|
||||
assert.ok(zones.size >= 3, `zone coverage thin: ${[...zones].join(',')}`);
|
||||
assert.ok(a.route.totalLen > 80, `route too short: ${a.route.totalLen}`);
|
||||
// pathAt wraps and is pure.
|
||||
const s1 = pathAt(a, 3.7), s2 = pathAt(a, 3.7 + a.route.totalLen);
|
||||
assert.deepEqual(s1, s2, 'pathAt must wrap seamlessly');
|
||||
assert.ok(Number.isFinite(s1.yaw), 'heading must be finite');
|
||||
});
|
||||
|
||||
test('every room is reachable: BFS over open cells hits all rooms', () => {
|
||||
for (const seed of [5, 7, 4242]) {
|
||||
const m = genDungeon({ seed, rng: new Rng(seed) });
|
||||
const seen = new Set([0]);
|
||||
const r0 = m.rooms[0];
|
||||
const qx = [r0.x + Math.floor(r0.w / 2)], qy = [r0.y + Math.floor(r0.h / 2)];
|
||||
const seenCells = new Set([qy[0] * m.w + qx[0]]);
|
||||
const roomOf = new Int16Array(m.w * m.h).fill(-1);
|
||||
for (const rm of m.rooms) {
|
||||
for (let y = rm.y; y < rm.y + rm.h; y++) {
|
||||
for (let x = rm.x; x < rm.x + rm.w; x++) roomOf[y * m.w + x] = rm.id;
|
||||
}
|
||||
}
|
||||
let head = 0;
|
||||
while (head < qx.length) {
|
||||
const cx = qx[head], cy = qy[head];
|
||||
head++;
|
||||
if (roomOf[cy * m.w + cx] >= 0) seen.add(roomOf[cy * m.w + cx]);
|
||||
for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue;
|
||||
if (!m.grid[ny * m.w + nx] || seenCells.has(ny * m.w + nx)) continue;
|
||||
seenCells.add(ny * m.w + nx);
|
||||
qx.push(nx); qy.push(ny);
|
||||
}
|
||||
}
|
||||
assert.equal(seen.size, m.rooms.length,
|
||||
`seed ${seed}: reachable ${seen.size}/${m.rooms.length} rooms`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the route only walks open cells and visits every room', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
// Route samples sit on open cells (checked at cell granularity).
|
||||
for (let s = 0; s < m.route.totalLen; s += 2) {
|
||||
const p = pathAt(m, s);
|
||||
const cx = Math.floor(p.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(p.z / CELL + m.h / 2);
|
||||
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `route off the grid at s=${s}`);
|
||||
assert.ok(m.grid[cy * m.w + cx], `route crosses a solid cell at s=${s.toFixed(1)}`);
|
||||
}
|
||||
const visited = new Set(m.route.roomIds.filter((id) => id >= 0));
|
||||
assert.equal(visited.size, m.rooms.length, 'the tour must enter every room');
|
||||
});
|
||||
|
||||
test('no holes indoors, green edges outdoors: every open edge is walled or planted', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const hRuns = m.wallRuns.filter((r) => r.z0 === r.z1);
|
||||
const vRuns = m.wallRuns.filter((r) => r.x0 === r.x1);
|
||||
const coveredH = (x, z) => hRuns.some((r) =>
|
||||
Math.abs(r.z0 - z) < 1e-6 && x + 1e-6 >= r.x0 && x - 1e-6 <= r.x1);
|
||||
const coveredV = (x, z) => vRuns.some((r) =>
|
||||
Math.abs(r.x0 - x) < 1e-6 && z + 1e-6 >= r.z0 && z - 1e-6 <= r.z1);
|
||||
const plantedNear = (x, z) => m.items.vegetation.some((v) =>
|
||||
Math.hypot(v.x - x, v.z - z) < 1.6);
|
||||
const w0 = (cx) => (cx - m.w / 2) * CELL; // west edge of cell cx
|
||||
const z0 = (cy) => (cy - m.h / 2) * CELL;
|
||||
// Indoor-ness is the map's own flag (rooms by rect, corridors by the
|
||||
// rooms they join) — the test reads it, never re-derives it, so a
|
||||
// corridor the tour never walks still judges correctly.
|
||||
const cellIndoor = (x, y) => m.indoor[y * m.w + x] === 1;
|
||||
let walled = 0, planted = 0;
|
||||
for (let y = 0; y < m.h; y++) {
|
||||
for (let x = 0; x < m.w; x++) {
|
||||
if (!m.grid[y * m.w + x]) continue;
|
||||
const openN = (nx, ny) => nx >= 0 && ny >= 0 && nx < m.w && ny < m.h && m.grid[ny * m.w + nx];
|
||||
const edges = [];
|
||||
if (!openN(x - 1, y)) edges.push(['v', w0(x), z0(y) + CELL / 2]);
|
||||
if (!openN(x + 1, y)) edges.push(['v', w0(x) + CELL, z0(y) + CELL / 2]);
|
||||
if (!openN(x, y - 1)) edges.push(['h', w0(x) + CELL / 2, z0(y)]);
|
||||
if (!openN(x, y + 1)) edges.push(['h', w0(x) + CELL / 2, z0(y) + CELL]);
|
||||
for (const [kind, ex, ez] of edges) {
|
||||
const covered = kind === 'h' ? coveredH(ex, ez) : coveredV(ex, ez);
|
||||
if (cellIndoor(x, y)) {
|
||||
assert.ok(covered, `hole on ${kind === 'h' ? 'horizontal' : 'vertical'} edge of (${x},${y})`);
|
||||
walled++;
|
||||
} else {
|
||||
assert.ok(plantedNear(ex, ez) || covered,
|
||||
`outdoor edge of (${x},${y}) neither walled nor planted`);
|
||||
planted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(walled > 100, `only ${walled} wall edges checked — map too open?`);
|
||||
assert.ok(planted > 20, `only ${planted} planted edges — outdoor bare?`);
|
||||
for (const v of m.items.vegetation) {
|
||||
assert.ok(['tree', 'bush', 'boulder'].includes(v.kind), `unknown planting ${v.kind}`);
|
||||
const cx = Math.floor(v.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(v.z / CELL + m.h / 2);
|
||||
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, 'planting off the grid');
|
||||
assert.ok(!m.grid[cy * m.w + cx], 'planting on an open cell — it would block the walk');
|
||||
}
|
||||
});
|
||||
|
||||
test('watch cells stand on open ground with line-of-sight to the route', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.ok(Object.keys(m.watch).length >= m.rooms.length - 1,
|
||||
'nearly every room needs a camera chair');
|
||||
for (const [id, w] of Object.entries(m.watch)) {
|
||||
const cx = Math.floor(w.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(w.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx], `watch cell of room ${id} is solid`);
|
||||
const rm = m.rooms.find((r) => r.id === Number(id));
|
||||
assert.ok(cx >= rm.x && cx < rm.x + rm.w && cy >= rm.y && cy < rm.y + rm.h,
|
||||
`watch cell of room ${id} is outside its room`);
|
||||
}
|
||||
});
|
||||
|
||||
test('portal sits off-route in a room, facing the walk', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.ok(m.portal, 'no portal planned');
|
||||
const cx = Math.floor(m.portal.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(m.portal.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx], 'portal cell is solid');
|
||||
const rm = m.rooms.find((r) => r.id === m.portal.roomId);
|
||||
assert.ok(rm && rm.indoor, 'portal belongs in an indoor room');
|
||||
assert.ok(Number.isFinite(m.portal.yaw), 'portal yaw must be finite');
|
||||
});
|
||||
|
||||
test('items sit on open cells, off the walked line', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const onOpen = (x, z, what) => {
|
||||
const cx = Math.floor(x / CELL + m.w / 2);
|
||||
const cy = Math.floor(z / CELL + m.h / 2);
|
||||
assert.ok(cx >= 0 && cy >= 0 && cx < m.w && cy < m.h, `${what} off the grid`);
|
||||
assert.ok(m.grid[cy * m.w + cx], `${what} on a solid cell`);
|
||||
};
|
||||
for (const p of m.items.props) {
|
||||
assert.ok(PROP_BUILDERS[p.type], `unknown prop ${p.type}`);
|
||||
onOpen(p.x, p.z, `prop ${p.type}`);
|
||||
}
|
||||
assert.ok(m.items.torches.length >= 2, `only ${m.items.torches.length} torches`);
|
||||
assert.ok(m.items.torches.length <= 18, 'torch cap blown — clutter');
|
||||
for (const tc of m.items.torches) {
|
||||
// Sconces hang on indoor stone: an indoor open cell within a step,
|
||||
// with a solid neighbour in reach of the bracket — never floating,
|
||||
// never buried deep.
|
||||
const cx = Math.floor(tc.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(tc.z / CELL + m.h / 2);
|
||||
let indoorNear = false, solidNear = false, openNear = false;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = cx + dx, ny = cy + dy;
|
||||
if (nx < 0 || ny < 0 || nx >= m.w || ny >= m.h) continue;
|
||||
if (m.grid[ny * m.w + nx]) {
|
||||
openNear = true;
|
||||
if (m.indoor[ny * m.w + nx]) indoorNear = true;
|
||||
} else solidNear = true;
|
||||
}
|
||||
}
|
||||
assert.ok(openNear, 'torch buried in stone');
|
||||
assert.ok(indoorNear, 'torch burning outdoors');
|
||||
assert.ok(solidNear, 'torch with no wall in reach — floating');
|
||||
}
|
||||
assert.ok(m.items.patrols.length >= 1, 'no patrol lanterns for the open ground');
|
||||
assert.ok(m.items.windows.length >= 4, `only ${m.items.windows.length} windows`);
|
||||
assert.ok(m.items.puddles.length === 4, 'want exactly 4 puddles');
|
||||
for (const g of [...m.items.gates, ...m.items.gaps, ...m.items.patrols]) {
|
||||
onOpen(g.x, g.z, 'hazard');
|
||||
}
|
||||
for (const g of m.items.gates) {
|
||||
assert.ok(HAZARD_BUILDERS.gate, 'gate builder missing');
|
||||
assert.ok(typeof g.s === 'number', 'gate needs route arclength for the lift');
|
||||
}
|
||||
// Avoidance stays bounded on the real gate list.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const off = avoidanceOffset(i * 1.7, m.items.gates, i * 0.63);
|
||||
assert.ok(Number.isFinite(off) && Math.abs(off) <= 1.0, `avoidance out of bounds: ${off}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('portcullis stays up for the whole procession, not just the hero', async () => {
|
||||
const T = await import('three');
|
||||
const { updateHazards, HAZARD_BUILDERS } = await import('../src/venues/dungeon/hazards.js');
|
||||
const gate = HAZARD_BUILDERS.gate(T, new Rng(3), [0.45, 0.45, 0.48]);
|
||||
gate.userData.home = { x: 0, z: 0, yaw: 0 };
|
||||
gate.userData.hazardS = 100;
|
||||
const liftOf = (partyS) => {
|
||||
updateHazards({ hazards: { items: [gate] }, t: 10, heroS: partyS[0], partyS });
|
||||
return gate.userData.liftBars.position.y;
|
||||
};
|
||||
// Hero under the gate: fully raised.
|
||||
assert.ok(liftOf([100]) > 2.3, 'gate not raised for the hero');
|
||||
// Hero through, one trailer still under it: must stay fully raised.
|
||||
assert.ok(liftOf([104, 100.5, 108]) > 2.3, 'gate dropped on the trailer');
|
||||
// Whole party clear: back down.
|
||||
assert.ok(liftOf([112, 116, 120]) < 0.1, 'gate stays up with nobody near');
|
||||
// Hero-only fallback still behaves (no partyS passed).
|
||||
updateHazards({ hazards: { items: [gate] }, t: 10, heroS: 100 });
|
||||
assert.ok(gate.userData.liftBars.position.y > 2.3, 'hero-only lift broken');
|
||||
});
|
||||
|
||||
test('roomAt/zoneAt follow the route through rooms and corridors', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const seenRooms = new Set(), seenZones = new Set();
|
||||
let corridor = false;
|
||||
for (let s = 0; s < m.route.totalLen; s += 1) {
|
||||
const r = roomAt(m, s);
|
||||
const z = zoneAt(m, s);
|
||||
assert.ok(Number.isInteger(r) && r >= -1 && r < m.rooms.length, `bad room ${r}`);
|
||||
assert.ok(typeof z === 'string' && z.length > 0, `bad zone ${z}`);
|
||||
if (r >= 0) seenRooms.add(r);
|
||||
else corridor = true;
|
||||
seenZones.add(z);
|
||||
}
|
||||
assert.equal(seenRooms.size, m.rooms.length, 'route samples must cover every room');
|
||||
assert.ok(corridor, 'route never leaves rooms — corridors unused?');
|
||||
assert.ok(seenZones.size >= 2, 'route crosses a single zone only');
|
||||
});
|
||||
|
||||
test('the cast assembles: enemies, critters and a wisp on open cells', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const kinds = m.items.entities.map((e) => e.kind);
|
||||
assert.ok(kinds.filter((k) => k === 'lurker').length >= 2, `lurkers: ${kinds.join(',')}`);
|
||||
assert.ok(kinds.includes('critter'), 'no critters');
|
||||
assert.ok(kinds.includes('wisp'), 'no wisp');
|
||||
assert.equal(m.items.entities.length, 6, `cast size ${m.items.entities.length}`);
|
||||
for (const e of m.items.entities) {
|
||||
assert.ok(['lurker', 'sentry', 'critter', 'wisp'].includes(e.kind), `unknown entity ${e.kind}`);
|
||||
const cx = Math.floor(e.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(e.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx] === 1, `${e.kind} on a solid cell`);
|
||||
assert.ok(Number.isFinite(e.s) && Number.isFinite(e.yaw), `${e.kind} missing s/yaw`);
|
||||
assert.ok(e.range >= 0 && Number.isFinite(e.speed) && Number.isFinite(e.phase),
|
||||
`${e.kind} missing gait params`);
|
||||
}
|
||||
});
|
||||
|
||||
test('loot sits a step off the walk, each cache earning its halt', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
assert.ok(m.items.loot.length >= 2, `only ${m.items.loot.length} loot spots`);
|
||||
assert.ok(m.items.loot.length <= 3, 'loot glut');
|
||||
for (const l of m.items.loot) {
|
||||
assert.ok(['cache', 'relic', 'hoard'].includes(l.kind), `unknown loot ${l.kind}`);
|
||||
const cx = Math.floor(l.x / CELL + m.w / 2);
|
||||
const cy = Math.floor(l.z / CELL + m.h / 2);
|
||||
assert.ok(m.grid[cy * m.w + cx] === 1, `loot ${l.kind} on a solid cell`);
|
||||
}
|
||||
assert.equal(m.items.halts.length, m.items.loot.length, 'every cache earns a halt');
|
||||
for (let i = 0; i < m.items.halts.length; i++) {
|
||||
const h = m.items.halts[i];
|
||||
assert.ok(h.dur > 0 && h.dur <= 3, `halt dur ${h.dur}`);
|
||||
assert.equal(h.s, m.items.loot[i].s, 'halt must sit on its loot');
|
||||
for (let j = i + 1; j < m.items.halts.length; j++) {
|
||||
const L = m.route.totalLen;
|
||||
const d = Math.abs(h.s - m.items.halts[j].s) % L;
|
||||
assert.ok(Math.min(d, L - d) >= 15, 'halts overlap — the odometer could retreat');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('odometer holds at halts and never retreats', () => {
|
||||
const cruise = 1.4, L = 200;
|
||||
const halts = [{ s: 50, dur: 2.2 }, { s: 150, dur: 2.2 }];
|
||||
// Deterministic.
|
||||
assert.equal(odometer(cruise, halts, L, 10), odometer(cruise, halts, L, 10));
|
||||
// Unpaused stretch advances at gait.
|
||||
assert.ok(Math.abs(odometer(cruise, halts, L, 10) - cruise * 10) < 1e-9, 'gait wrong');
|
||||
// Arrival holds: from t_a to t_a+dur the reading stays on the loot.
|
||||
const ta = 50 / cruise;
|
||||
assert.ok(Math.abs(odometer(cruise, halts, L, ta + 1.1) - 50) < 1e-9, 'halt does not hold');
|
||||
assert.ok(Math.abs(odometer(cruise, halts, L, ta + 2.2) - 50) < 1e-9, 'halt releases early');
|
||||
// Monotonic everywhere, both gaits, both halt sets.
|
||||
for (const c of [0.4, 1.4, 2.6]) {
|
||||
let prev = odometer(c, halts, L, 0);
|
||||
for (let t = 0.05; t < 300; t += 0.05) {
|
||||
const s = odometer(c, halts, L, t);
|
||||
const d = (s - prev + L) % L;
|
||||
assert.ok(d < c * 0.06 + 1e-9, `retreat at t=${t.toFixed(2)} (cruise ${c})`);
|
||||
prev = s;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('entity poses are deterministic, bounded and hero-aware', () => {
|
||||
const m = genDungeon({ seed: 7, rng: new Rng(7) });
|
||||
const L = m.route.totalLen;
|
||||
for (const e of m.items.entities) {
|
||||
const p1 = entityPose(e, 10, 5, 0, 0, L);
|
||||
const p2 = entityPose(e, 10, 5, 0, 0, L);
|
||||
assert.deepEqual(p1, p2, `${e.kind} pose unstable`);
|
||||
assert.ok(Math.hypot(p1.x - e.x, p1.z - e.z) <= e.range + 1.6,
|
||||
`${e.kind} wanders ${Math.hypot(p1.x - e.x, p1.z - e.z).toFixed(2)} from home`);
|
||||
assert.ok(p1.alert >= 0 && p1.alert <= 1, `${e.kind} alert out of range`);
|
||||
// Near the hero it notices; far away it doesn't.
|
||||
const near = entityPose(e, 10, e.s, e.x, e.z, L);
|
||||
const far = entityPose(e, 10, (e.s + L / 2) % L, e.x, e.z, L);
|
||||
assert.ok(near.alert >= far.alert, `${e.kind} ignores the hero`);
|
||||
assert.ok(near.alert > 0.5, `${e.kind} cold at point blank`);
|
||||
}
|
||||
});
|
||||
|
||||
const SLOW_DARK = {
|
||||
meanCentroid: 0.3, meanFlatness: 0.1, bpm: 68,
|
||||
dynamicRange: 0.7, meanLoudness: 0.3,
|
||||
};
|
||||
|
||||
function slowPersonalityAt(seed) {
|
||||
const rng = new Rng(seed);
|
||||
return generatePersonality(SLOW_DARK, rng.fork('personality'), () => 10, 4);
|
||||
}
|
||||
|
||||
test('VenueGenerator plans the dungeon map when the dungeon is picked', () => {
|
||||
const rng = new Rng(5);
|
||||
const story = {
|
||||
sections: [{ act: 'setup' }, { act: 'development' }, { act: 'climax' }, { act: 'resolution' }],
|
||||
};
|
||||
const vs = generateVenue({
|
||||
summary: SLOW_DARK, rng: rng.fork('venue'), personality: slowPersonalityAt(5),
|
||||
director: null, story, sections: [{}, {}, {}, {}],
|
||||
});
|
||||
assert.equal(vs.archetype, 'dungeon');
|
||||
assert.ok(vs.dungeon && vs.dungeon.map, 'dungeon venue must carry the map');
|
||||
assert.ok(vs.dungeon.map.rooms.length >= 3, 'map has no rooms');
|
||||
assert.ok(vs.dungeon.map.route.totalLen > 80, 'route too short');
|
||||
assert.equal(vs.population.kind, 'party', 'dungeon walks a party, not a crowd');
|
||||
assert.equal(vs.projection.kind, 'portal', 'dungeon projects on the portal');
|
||||
// Deterministic across regenerations.
|
||||
const again = generateVenue({
|
||||
summary: SLOW_DARK, rng: new Rng(5).fork('venue'), personality: slowPersonalityAt(5),
|
||||
director: null, story, sections: [{}, {}, {}, {}],
|
||||
});
|
||||
assert.deepEqual(again.dungeon, vs.dungeon);
|
||||
// Non-dungeon venues carry no dungeon plan.
|
||||
const fast = generateVenue({
|
||||
summary: { ...SLOW_DARK, bpm: 140 }, rng: new Rng(5).fork('venue'),
|
||||
personality: slowPersonalityAt(5), director: null, story, sections: [{}, {}, {}, {}],
|
||||
});
|
||||
if (fast.archetype !== 'dungeon') assert.equal(fast.dungeon, null);
|
||||
});
|
||||
@@ -262,3 +262,126 @@ test('ride declares directCamera and drives its own rig deterministically', () =
|
||||
function awaitImportThree() {
|
||||
return import('three');
|
||||
}
|
||||
|
||||
test('dungeon declares directCamera and plants its cameras on open cells', () => {
|
||||
const THREE = awaitImportThree();
|
||||
return THREE.then((T) => {
|
||||
const dungeonMod = venues.find((v) => v.name === 'Dungeon');
|
||||
assert.ok(dungeonMod, 'Dungeon venue not registered');
|
||||
assert.equal(dungeonMod.directCamera, true, 'Dungeon must own its camera rig');
|
||||
const personality = personalityAt(11);
|
||||
const identity = generateIdentity(SUMMARY, new Rng(11).fork('identity'), 4);
|
||||
personality.identity = identity;
|
||||
const actors = generateActorSet(SUMMARY, new Rng(11).fork('actors'), personality, identity);
|
||||
const scene = new T.Scene();
|
||||
const dungeonSpec = {
|
||||
seed: 5, population: { count: 3, spread: 0.5, jitter: 0.1 },
|
||||
paletteMap: [0, 1, 2, 3],
|
||||
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
|
||||
};
|
||||
const instance = dungeonMod.build({
|
||||
scene, seed: 4242, params: {}, actorSpec: actors.monolith,
|
||||
venueSpec: dungeonSpec, THREE: T,
|
||||
});
|
||||
const stubCam = () => ({
|
||||
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
|
||||
rotation: {},
|
||||
look: null,
|
||||
lookAt(x, y, z) { this.look = [x, y, z]; },
|
||||
updateMatrixWorld() {},
|
||||
});
|
||||
const drive = (time, framing) => {
|
||||
const cam = stubCam();
|
||||
dungeonMod.update({
|
||||
instance, scene, camera: cam,
|
||||
timeline: { time, frame: Math.round(time * 60), progress: time / 200 },
|
||||
features: {}, params: { light: 0.85, speed: 0.7 },
|
||||
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.5, 0.4]],
|
||||
personality, framing, opacity: 1,
|
||||
actorSpec: actors.monolith,
|
||||
venueSpec: instance.venueSpec,
|
||||
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
|
||||
THREE: T,
|
||||
});
|
||||
const { x, y, z } = cam.position;
|
||||
return { x, y, z, look: cam.look };
|
||||
};
|
||||
const framing = { scale: 1, shift: [0, 0] };
|
||||
const a = drive(10, framing), b = drive(10, framing);
|
||||
assert.deepEqual(a, b, 'same inputs must place the camera identically');
|
||||
const c = drive(40, framing);
|
||||
const travelled = Math.hypot(c.x - a.x, c.y - a.y, c.z - a.z);
|
||||
assert.ok(travelled > 0.15, `dungeon camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
|
||||
// Every camera — walk or room plant — stands on an open cell at eye
|
||||
// height, and always looks somewhere. The map is 88 units wide, so
|
||||
// positions roam; openness is the invariant, not a small box.
|
||||
const cellOpen = (x, z) => {
|
||||
const map = instance.map;
|
||||
const cx = Math.floor(x / 2 + map.w / 2);
|
||||
const cy = Math.floor(z / 2 + map.h / 2);
|
||||
return cx >= 0 && cy >= 0 && cx < map.w && cy < map.h && map.grid[cy * map.w + cx] === 1;
|
||||
};
|
||||
const watches = [
|
||||
framing,
|
||||
{ scale: 1, shift: [0, 0], venueShot: { pos: [0, 1, 2], look: [0, 0.2, -5], size: 'normal', target: 'walk', dolly: 1.0 } },
|
||||
{ scale: 1, shift: [0, 0], venueShot: { pos: [0, 1.5, 3], look: [0, 0.9, -3], size: 'normal', target: 'room', dolly: 1.0 } },
|
||||
{ scale: 1, shift: [0, 0], venueShot: { pos: [2.6, 1.5, 1.0], look: [-0.5, 1.3, -8], size: 'wide', target: 'portal', dolly: 1.3, drive: 0.5 } },
|
||||
];
|
||||
for (const t of [0, 30, 90, 200, 400]) {
|
||||
for (const f of watches) {
|
||||
const p = drive(t, f);
|
||||
assert.ok(cellOpen(p.x, p.z), `camera inside a wall at t=${t} (${p.x.toFixed(2)},${p.z.toFixed(2)})`);
|
||||
assert.ok(p.y >= 0.2 && p.y <= 3.4, `y=${p.y.toFixed(2)} off eye height at t=${t}`);
|
||||
assert.ok(p.look && p.look.length === 3, 'camera must look somewhere');
|
||||
// The bearer hides in exactly the frames the camera stands
|
||||
// where he is: first-person never renders him, plants do.
|
||||
assert.equal(instance.hero.visible, instance.watchRoom < 0 ? false : true,
|
||||
`hero visibility disagrees with watchRoom at t=${t}`);
|
||||
}
|
||||
}
|
||||
// Walk vs room aim apart: the modes are the coverage.
|
||||
const m1 = drive(60, watches[1]);
|
||||
const m2 = drive(60, watches[2]);
|
||||
const m3 = drive(60, watches[3]);
|
||||
assert.ok(Math.hypot(m1.x - m2.x, m1.z - m2.z) > 0.2,
|
||||
'walk vs room must place the camera apart');
|
||||
assert.ok(Math.hypot(m1.look[0] - m3.look[0], m1.look[2] - m3.look[2]) > 0.2,
|
||||
'walk vs portal must aim apart');
|
||||
// The portal ring sits on an open cell and the tableau camera aims
|
||||
// at the live actor, not at a conventional mark.
|
||||
const ring = instance.portal.group.position;
|
||||
assert.ok(cellOpen(ring.x, ring.z), 'portal ring inside a wall');
|
||||
const hero = instance.hero.position;
|
||||
assert.deepEqual([m3.look[0], m3.look[2]], [hero.x, hero.z],
|
||||
'tableau camera must track the actor');
|
||||
// Enemies wear the song's actor pool: grown bodies from this track's
|
||||
// walker/structure/swarm ActorSpecs, darkened hostile with ember
|
||||
// eyes set into the face the geometry actually has.
|
||||
const grown = (instance.cast || []).filter((en) => en.grown);
|
||||
assert.ok(grown.length > 0, 'cast never grew its song bodies');
|
||||
// Grown from the song's actor pool, not the venue monolith: the
|
||||
// cached specs carry walker/structure/swarm archetypes.
|
||||
const specs = instance.enemySpecs || {};
|
||||
for (const [kind, arch] of [['lurker', 'walker'], ['sentry', 'structure'], ['critter', 'swarm']]) {
|
||||
if (!specs[kind]) continue;
|
||||
assert.equal(specs[kind].archetype, arch,
|
||||
`${kind} enemy grown from ${specs[kind].archetype}, not ${arch}`);
|
||||
assert.notEqual(specs[kind].seed, actors.monolith.seed,
|
||||
`${kind} enemy reuses the monolith seed`);
|
||||
}
|
||||
for (const en of grown) {
|
||||
if (en.spec.kind === 'wisp') continue;
|
||||
assert.ok(en.bodyMesh && en.bodyMesh.isMesh, `${en.spec.kind} has no body`);
|
||||
assert.ok(en.eyes.length >= 2, `${en.spec.kind} has no eyes`);
|
||||
// The body is this song's cast, not a stock primitive: its part
|
||||
// is tagged with the kind it plays.
|
||||
assert.equal(en.bodyMesh.userData.kind, en.spec.kind,
|
||||
`${en.spec.kind} body is not tagged from the actor pool`);
|
||||
const bb = en.bodyMesh.geometry.boundingBox;
|
||||
assert.ok(bb, `${en.spec.kind} body has no measured box`);
|
||||
const h = (bb.max.y - bb.min.y) * en.bodyMesh.scale.x;
|
||||
assert.ok(Math.abs(h - en.height) < 0.05,
|
||||
`${en.spec.kind} stands ${h.toFixed(2)} tall, not ${en.height}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'url';
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const VENUES = join(root, 'src/venues');
|
||||
const REGISTRY = join(root, 'src/venues/registry.js');
|
||||
const ARCHETYPES = ['chamber','stage','ride'];
|
||||
const ARCHETYPES = ['chamber','stage','ride','dungeon'];
|
||||
const args = process.argv.slice(2);
|
||||
const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
|
||||
const name = args.find(a=>!a.startsWith('--'));
|
||||
|
||||
Reference in New Issue
Block a user