From da483541a41686f2d442400b3198f6d7420b62ae Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 7 Jul 2026 17:10:02 -0600 Subject: [PATCH] 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. --- src/games/dungeonboss/DungeonBossGame.js | 107 +++++++++++++++++----- src/games/dungeonboss/DungeonBossLogic.js | 6 +- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index 8ebe8cf..586f62e 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -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,14 +2880,20 @@ 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)) { - ab.pendingWindowDecision = null; - ab.root.setVisible(false); - ab.continueBtn.setVisible(false); - ab.spellsBtn.setVisible(false); - ab.attackedPortrait.forEach((o) => o.setVisible?.(false)); // scene-root objects, not children of ab.root - this.setAdventureVideosVisible(true); // the decision UI (showPickModal etc.) is a normal board overlay, fine to show these under - this.busy = false; - this.pump(); + // 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); + ab.spellsBtn.setVisible(false); + ab.attackedPortrait.forEach((o) => o.setVisible?.(false)); // scene-root objects, not children of ab.root + 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(); diff --git a/src/games/dungeonboss/DungeonBossLogic.js b/src/games/dungeonboss/DungeonBossLogic.js index 84ca886..5240402 100644 --- a/src/games/dungeonboss/DungeonBossLogic.js +++ b/src/games/dungeonboss/DungeonBossLogic.js @@ -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; }