refactor: restructure adventure step progression and fix bossWound window handling

Remove lastStep state in favor of pendingSettleStep, deferring room card
settlement until the next step advances. This allows Continue/Spells UI to
appear immediately after a room's damage animation ends, rather than waiting
for a separate return-home tween.

- Add flushPendingSettle() to defer and execute card return-to-row animations
- Extract playNextAdventureStep() from advanceAdventureStep() for clearer flow
- Auto-chain bossWound steps without opening mid-walk windows (boss wounds
  are final and shouldn't pause for reactions)
- Add skipAdventurePauseMarkers() to correctly detect when event queue is
  truly drained past pause markers
- Flush pending settle in driveAdventureWindowDecision() before processing
  spell-window decisions
- Sync hero row for heroes entering after a live window pause

Fixes timing issues where UI state and card animations were out of sync,
and ensures heroes entering after an adventure pause get their cards shown.
This commit is contained in:
Brian Fertig 2026-07-07 17:10:02 -06:00
parent cc2461c6b5
commit da483541a4
2 changed files with 90 additions and 23 deletions

View File

@ -2495,7 +2495,7 @@ export default class DungeonBossGame extends Phaser.Scene {
roomCards: new Map(), bossCard: null, liveOrder: [], attackedPortrait: [],
stage: null, logLines: [], continueBtn: null, spellsBtn: null,
events: [], cursor: 0, currentLiveIdx: null, activeHeroUid: null,
lastStep: null, onSessionDrained: null,
onSessionDrained: null,
// Set only while a live 'window' decision is showing for the human
// (driveAdventureWindowDecision) — disambiguates onAdventureContinue's
// two meanings (settle+advance vs. pass this window). Deliberately NOT
@ -2503,6 +2503,11 @@ export default class DungeonBossGame extends Phaser.Scene {
// can already read true (the engine itself is that far along) before
// the human has visually caught up to it — see advanceAdventureStep.
pendingWindowDecision: null,
// A just-played roomHit/trapKill step whose card hasn't been sent back
// to its row yet — see flushPendingSettle. Deferred so Continue/Spells
// can appear the instant the step's own damage animation ends, rather
// than after a further (separate) return-home tween finishes too.
pendingSettleStep: null,
};
this.buildAdventureRoomRow(seat);
this.syncHeroRow(firstBatch);
@ -2763,17 +2768,63 @@ export default class DungeonBossGame extends Phaser.Scene {
}
}
// Sends a just-played roomHit/trapKill step's room card back to its row
// (see settleAdventureStepIntoTopRows) if one is still owed, THEN calls
// onDone. Called at the top of advanceAdventureStep (so both a plain
// Continue and a post-cast/pass resume flush it) and directly wherever
// else the modal moves past a pause without going through that function —
// deferred specifically so Continue/Spells can appear the instant a room's
// own damage animation ends, not after a further, separate return-home
// tween also has to finish first (see advanceAdventureStep).
flushPendingSettle(onDone) {
const ab = this._advBattle;
const step = ab.pendingSettleStep;
ab.pendingSettleStep = null;
if (!step) { onDone(); return; }
this.settleAdventureStepIntoTopRows(step, onDone);
}
advanceAdventureStep() {
this.flushPendingSettle(() => this.playNextAdventureStep());
}
// Consumes any adventurePause marker(s) sitting right at the cursor. These
// are pure housekeeping (see openAdvRoomWindow) that nextAdventureStep only
// ever eats on ITS OWN next call, never as part of returning the step that
// precedes them — so without this, checking ab.cursor < ab.events.length
// right after that step finishes sees the marker still sitting there and
// wrongly concludes more real steps are already queued, when there's only
// this leftover marker (with the real decision, if any, right behind it).
skipAdventurePauseMarkers() {
const ab = this._advBattle;
while (ab.cursor < ab.events.length && ab.events[ab.cursor].type === 'adventurePause') ab.cursor++;
}
playNextAdventureStep() {
const ab = this._advBattle;
ab.pendingWindowDecision = null;
ab.continueBtn.setVisible(false);
ab.spellsBtn.setVisible(false);
const step = this.nextAdventureStep();
if (!step) { this.onAdventureQueueDrained(); return; }
ab.lastStep = step;
this.playAdventureStep(step, () => {
if (step.kind === 'enter' || step.kind === 'pass') { this.advanceAdventureStep(); return; }
ab.continueBtn.setVisible(true);
// bossWound never opens a mid-walk window (see stepAdventure) — always
// auto-chain, same as enter/pass.
if (step.kind === 'enter' || step.kind === 'pass' || step.kind === 'bossWound') { this.advanceAdventureStep(); return; }
// Don't settle this step's card into its row yet (see flushPendingSettle)
// — check for a spell-window decision right now, at the exact moment
// the damage animation itself ends. This is only safe once ab.cursor
// has caught up to ab.events.length: advance() always halts at the
// FIRST pause anyone can react to, so if more steps are already queued
// behind this one, THIS pause is guaranteed to have had nothing to
// react to (see onAdventureQueueDrained's header comment) — only once
// the cursor is genuinely caught up does this.gs's live
// pendingDecision() correspond to this exact pause rather than one the
// player hasn't visually reached yet.
ab.pendingSettleStep = step;
this.skipAdventurePauseMarkers();
if (ab.cursor < ab.events.length) { ab.continueBtn.setVisible(true); return; }
this.onAdventureQueueDrained();
});
}
@ -2785,8 +2836,9 @@ export default class DungeonBossGame extends Phaser.Scene {
onAdventureContinue() {
const ab = this._advBattle;
// Same physical button, two meanings: settle+advance the last narrated
// step (ordinary case), or — once the queue has fully drained into a
// Same physical button, two meanings: a plain advance to the next step
// (ordinary case — advanceAdventureStep flushes the last step's deferred
// settle before moving on), or — once the queue has fully drained into a
// live advRoom window for the human (pendingWindowDecision set by
// driveAdventureWindowDecision) — pass on casting anything this window.
if (ab.pendingWindowDecision) {
@ -2794,18 +2846,17 @@ export default class DungeonBossGame extends Phaser.Scene {
return;
}
ab.continueBtn.setVisible(false);
this.settleAdventureStepIntoTopRows(ab.lastStep, () => this.advanceAdventureStep());
this.advanceAdventureStep();
}
// Return-to-top / dim / advance, per the spec: a survived room hit sends
// just the room back up and dims it — the hero stays put in the battle
// zone for its whole run, only ever leaving once it's defeated (removeHeroCard,
// already run inside playRoomHitStep) or wounds the boss (playBossWoundStep);
// a trap kill sends just the room back up as a destroyed placeholder (the
// Return-to-top / dim, per the spec: a survived room hit sends just the
// room back up and dims it — the hero stays put in the battle zone for its
// whole run, only ever leaving once it's defeated (removeHeroCard, already
// run inside playRoomHitStep) or wounds the boss (playBossWoundStep); a
// trap kill sends just the room back up as a destroyed placeholder (the
// hero already faded away in playTrapKillStep); bossWound and pass need no
// further settling (already fully resolved inline in their own play functions).
settleAdventureStepIntoTopRows(step, onDone) {
if (!step) { onDone(); return; } // the opening Continue click — nothing has played yet to settle
switch (step.kind) {
case 'roomHit':
this.returnHome(null, step.displayKey, () => { this.dimRoomCard(step.displayKey); onDone(); });
@ -2829,6 +2880,11 @@ export default class DungeonBossGame extends Phaser.Scene {
const ab = this._advBattle;
const d = pendingDecision(this.gs);
if (d && d.seat === ab.seat && ['target', 'discard', 'roomDraw'].includes(d.kind)) {
// This branch is reached directly from playNextAdventureStep (not via
// advanceAdventureStep), so any deferred settle from the step that
// triggered this decision hasn't run yet — flush it before hiding, so
// the room card is in the right state whenever this session resumes.
this.flushPendingSettle(() => {
ab.pendingWindowDecision = null;
ab.root.setVisible(false);
ab.continueBtn.setVisible(false);
@ -2837,6 +2893,7 @@ export default class DungeonBossGame extends Phaser.Scene {
this.setAdventureVideosVisible(true); // the decision UI (showPickModal etc.) is a normal board overlay, fine to show these under
this.busy = false;
this.pump();
});
return;
}
// A live spell-casting window (any alive seat, not just ab.seat's own
@ -2965,6 +3022,14 @@ export default class DungeonBossGame extends Phaser.Scene {
const boundary = events.findIndex((e) => ADV_RESUME_MARKERS.includes(e.type));
const narration = boundary === -1 ? events : events.slice(0, boundary);
const resumed = boundary === -1 ? [] : events.slice(boundary);
// Unlike runAdventureWalk's two call sites, `resumed` never passes through
// splitAdventureWalk, so any hero who first enters AFTER this window's
// pause (i.e. its heroEnters lands here rather than in the initial batch)
// would otherwise never get added to ab.heroCards — it'd walk and take
// damage with no card ever shown for it. Filtered to this seat in case a
// no-op window auto-closes straight through to the next seat's own walk
// in the same takeEvents() drain.
if (resumed.length) this.syncHeroRow(resumed.filter((e) => e.seat === ab.seat));
this.playAdventureNarration(narration, () => {
ab.events.push(...resumed);
this.advanceAdventureStep();

View File

@ -1154,8 +1154,10 @@ function stepAdventure(state) {
emit(state, { type: 'bossWounded', seat, uid: hero.uid, id: hero.id, wounds, total: p.wounds });
a.walking = null;
if (p.wounds >= WOUNDS_TO_DIE) { eliminate(state, seat); nextAdventureSeat(state); return; }
openAdvRoomWindow(state, seat);
return;
// No mid-walk window here (unlike every other real per-room event) —
// a boss wound is final and shouldn't pause for a reaction; go straight
// to the next queued hero, or nextAdventureSeat if none remain.
continue;
}
const slot = p.dungeon[w.roomIdx];
if (!slot) { w.roomIdx = Math.min(w.roomIdx - 1, p.dungeon.length - 1); continue; }