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:
Dejvino
2026-10-04 22:13:25 +02:00
parent 3df4b3545b
commit c96e810d44
13 changed files with 3309 additions and 20 deletions
+123
View File
@@ -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}`);
}
});
});