fix: show complete hero row upfront in adventure modal

Store pre-walk entrance snapshot and seed the entire hero row when the
adventure modal opens, rather than waiting for heroEnters events to
stream in. This fixes the issue where only a subset of heroes were
visible initially, since the engine may have already shifted heroes off
the entrance queue before events reach the UI.

- Add _advEntranceSnapshot to capture pre-mutation hero queue state
- Add seedHeroRowFromEntrance() to populate all heroes at modal open
- Fix hero row pitch (heroRowN) so it never reflows mid-session
- Refresh snapshots in react/window decision path (bypasses applyDecision)
- Use moveToStage() in playEnterStep for consistent hero positioning
This commit is contained in:
Brian Fertig 2026-07-07 18:36:52 -06:00
parent 4b35876a34
commit 3e9ee8e47b
1 changed files with 72 additions and 12 deletions

View File

@ -295,6 +295,7 @@ export default class DungeonBossGame extends Phaser.Scene {
this._hoverSuppressCont = null; // card container currently mid-drag — its own hover popup stays disabled until dropped this._hoverSuppressCont = null; // card container currently mid-drag — its own hover popup stays disabled until dropped
this._advBattle = null; // adventure battle modal session state (see openAdventureModal) while open this._advBattle = null; // adventure battle modal session state (see openAdventureModal) while open
this._advDungeonSnapshot = null; // per-seat pre-mutation dungeon snapshot, refreshed every applyDecision (see there) this._advDungeonSnapshot = null; // per-seat pre-mutation dungeon snapshot, refreshed every applyDecision (see there)
this._advEntranceSnapshot = null; // per-seat pre-mutation entrance snapshot, refreshed alongside it (see seedHeroRowFromEntrance)
// Bait-walk overlay (town -> entrance), refreshed every playEvents() batch // Bait-walk overlay (town -> entrance), refreshed every playEvents() batch
// that contains heroWalks events — see renderTown/renderHumanBoard/ // that contains heroWalks events — see renderTown/renderHumanBoard/
// renderOpponents and eventFx's 'heroWalks' case. // renderOpponents and eventFx's 'heroWalks' case.
@ -1811,6 +1812,11 @@ export default class DungeonBossGame extends Phaser.Scene {
this._advDungeonSnapshot = this.gs.players.map((p) => p.dungeon.map((s) => ({ this._advDungeonSnapshot = this.gs.players.map((p) => p.dungeon.map((s) => ({
room: { id: s.room.id, uid: s.room.uid }, deactivated: s.deactivated, armed: s.armed, room: { id: s.room.id, uid: s.room.uid }, deactivated: s.deactivated, armed: s.armed,
}))); })));
// Same reasoning, for the hero queue: seedHeroRowFromEntrance needs the
// FULL pre-walk entrance roster, but stepAdventure shifts heroes off it
// one at a time as it computes events, so live entrance may already be
// partially (or fully) drained by the time the modal opens.
this._advEntranceSnapshot = this.gs.players.map((p) => p.entrance.map((h) => ({ uid: h.uid, id: h.id })));
try { try {
switch (d.kind) { switch (d.kind) {
case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break; case 'setupDiscard': actSetupDiscard(this.gs, d.seat, choice); break;
@ -2532,8 +2538,13 @@ export default class DungeonBossGame extends Phaser.Scene {
// can appear the instant the step's own damage animation ends, rather // can appear the instant the step's own damage animation ends, rather
// than after a further (separate) return-home tween finishes too. // than after a further (separate) return-home tween finishes too.
pendingSettleStep: null, pendingSettleStep: null,
// Fixed hero-row pitch count, set once by seedHeroRowFromEntrance so
// later additions (syncHeroRow's rare safety-net case) don't reflow
// everyone already laid out.
heroRowN: 0,
}; };
this.buildAdventureRoomRow(seat); this.buildAdventureRoomRow(seat);
this.seedHeroRowFromEntrance(seat);
this.syncHeroRow(firstBatch); this.syncHeroRow(firstBatch);
const ab = this._advBattle; const ab = this._advBattle;
this.buildAdventureAttackedPortrait(seat); this.buildAdventureAttackedPortrait(seat);
@ -2567,12 +2578,47 @@ export default class DungeonBossGame extends Phaser.Scene {
this.tweens.add({ targets: root, alpha: 1, duration: 220 }); this.tweens.add({ targets: root, alpha: 1, duration: 220 });
} }
// Adds any hero from `events` not already in the row (every heroEnters the // Populates the ENTIRE hero row up front, from the pre-walk entrance
// first time; only genuinely new ones on a later, resumed batch), sized by // snapshot (see applyDecision/applyAdventureWindowChoice), so every hero
// the pitch formula for the CURRENT total queue length — mirrors the // queued for this seat's dungeon this turn is visible in ADV_HERO_ROW the
// existing townPos pitch pattern. Existing cards are left at their original // moment the modal opens — not just the ones whose heroEnters event has
// slot/size on a later batch rather than reflowed, a deliberate simplification // streamed in so far. Reading gs.players[seat].entrance directly wouldn't
// since that only matters for the rare mid-session-decision resume case. // work: by the time any of these events reach the UI, the engine may have
// already shifted several heroes off entrance in the same advance() call
// (see the CRITICAL CORRECTNESS NOTE above splitAdventureWalk) — the
// snapshot is the only reliable source for the true starting roster.
// Fixes ab.heroRowN so the row's pitch never changes later, whether a hero
// leaves (starts walking, or dies) or a rare mid-session addition shows up
// via syncHeroRow's safety net.
seedHeroRowFromEntrance(seat) {
const ab = this._advBattle;
const roster = (this._advEntranceSnapshot && this._advEntranceSnapshot[seat]) || [];
if (!roster.length) return;
const n = roster.length;
ab.heroRowN = n;
const { x0, x1, y, maxW } = ADV_HERO_ROW;
const pitch = Math.min(maxW + 14, (x1 - x0) / n);
const w = Math.max(46, Math.min(maxW, pitch - 14));
const h = w / CARD_ASPECT.hero;
roster.forEach((entry, i) => {
if (ab.heroMeta.has(entry.uid)) return;
const def = heroDef({ id: entry.id });
ab.heroMeta.set(entry.uid, { id: entry.id, def });
ab.heroHp.set(entry.uid, def.hp);
ab.heroOrder.push(entry.uid);
const x = x0 + pitch / 2 + i * pitch;
const cont = this.makeHeroCard(x, y, { uid: entry.uid, id: entry.id, hp: def.hp }, w, h,
{ parent: ab.root, hover: false, noHoverPreview: true });
ab.heroCards.set(entry.uid, { cont, x, y, w, h, id: entry.id, def });
});
}
// Safety net for any hero heroEnters reveals that ISN'T already in the row
// (seedHeroRowFromEntrance normally covers the whole session up front) —
// e.g. a hero added to this seat's entrance mid-adventure-phase by some
// other seat's room ability. Reuses ab.heroRowN (fixed at seed time) for
// the pitch so it never reflows heroes already laid out; falls back to the
// current count only if seeding never ran (e.g. no snapshot available).
syncHeroRow(events) { syncHeroRow(events) {
const ab = this._advBattle; const ab = this._advBattle;
const newUids = []; const newUids = [];
@ -2586,7 +2632,7 @@ export default class DungeonBossGame extends Phaser.Scene {
} }
} }
if (!newUids.length) return; if (!newUids.length) return;
const n = Math.max(1, ab.heroOrder.length); const n = ab.heroRowN || Math.max(1, ab.heroOrder.length);
const { x0, x1, y, maxW } = ADV_HERO_ROW; const { x0, x1, y, maxW } = ADV_HERO_ROW;
const pitch = Math.min(maxW + 14, (x1 - x0) / n); const pitch = Math.min(maxW + 14, (x1 - x0) / n);
const w = Math.max(46, Math.min(maxW, pitch - 14)); const w = Math.max(46, Math.min(maxW, pitch - 14));
@ -3033,6 +3079,13 @@ export default class DungeonBossGame extends Phaser.Scene {
ab.pendingWindowDecision = null; ab.pendingWindowDecision = null;
ab.continueBtn.setVisible(false); ab.continueBtn.setVisible(false);
ab.spellsBtn.setVisible(false); ab.spellsBtn.setVisible(false);
// See applyDecision — this bypasses it, so the pre-mutation snapshots it
// normally refreshes need refreshing here too (a mid-walk cast/pass can
// itself cascade straight into a brand-new seat's walk beginning).
this._advDungeonSnapshot = this.gs.players.map((p) => p.dungeon.map((s) => ({
room: { id: s.room.id, uid: s.room.uid }, deactivated: s.deactivated, armed: s.armed,
})));
this._advEntranceSnapshot = this.gs.players.map((p) => p.entrance.map((h) => ({ uid: h.uid, id: h.id })));
try { try {
if (d.kind === 'react') actReact(this.gs, d.seat, choice); if (d.kind === 'react') actReact(this.gs, d.seat, choice);
else actWindow(this.gs, d.seat, choice); else actWindow(this.gs, d.seat, choice);
@ -3328,16 +3381,23 @@ export default class DungeonBossGame extends Phaser.Scene {
return C.ink; return C.ink;
} }
// A fresh hero's turn: bring it down from the (still-full) upper hero row
// into the battle zone, where it stays for its whole run (playRoomHitStep's
// own moveToStage calls become harmless no-ops once it's already there) —
// every other queued-but-not-yet-walked hero stays put up top, undisturbed.
playEnterStep(step, onDone) { playEnterStep(step, onDone) {
const ab = this._advBattle; const ab = this._advBattle;
const rec = ab.heroCards.get(step.uid); const rec = ab.heroCards.get(step.uid);
this.resetRoomDimming(); // fresh hero, fresh run — every room but a truly destroyed one is fair game again this.resetRoomDimming(); // fresh hero, fresh run — every room but a truly destroyed one is fair game again
if (!rec) { onDone(); return; } if (!rec) { onDone(); return; }
this.moveToStage(step.uid, null, { duration: 280 }, () => {
const w = ADV_STAGE_HERO_W, h = w / CARD_ASPECT.hero;
const hl = this.add.graphics(); const hl = this.add.graphics();
hl.lineStyle(3, C.gold, 1); hl.lineStyle(3, C.gold, 1);
hl.strokeRoundedRect(rec.x - rec.w / 2 - 5, rec.y - rec.h / 2 - 5, rec.w + 10, rec.h + 10, 8); hl.strokeRoundedRect(ADV_STAGE_HERO.x - w / 2 - 5, ADV_STAGE_HERO.y - h / 2 - 5, w + 10, h + 10, 8);
ab.root.add(hl); ab.root.add(hl);
this.tweens.add({ targets: hl, alpha: 0, duration: 500, ease: 'Cubic.easeOut', onComplete: () => { hl.destroy(); onDone(); } }); this.tweens.add({ targets: hl, alpha: 0, duration: 500, ease: 'Cubic.easeOut', onComplete: () => { hl.destroy(); onDone(); } });
});
} }
// Rooms only get dimmed to mark "already passed through THIS hero's run" — // Rooms only get dimmed to mark "already passed through THIS hero's run" —