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
+74 -13
View File
@@ -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)],