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:
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user