From 3e9ee8e47ba2c0840b5eb8f8cfc23c4b1e42b5da Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 7 Jul 2026 18:36:52 -0600 Subject: [PATCH] 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 --- src/games/dungeonboss/DungeonBossGame.js | 84 ++++++++++++++++++++---- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index be07338..662ae5a 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -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._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._advEntranceSnapshot = null; // per-seat pre-mutation entrance snapshot, refreshed alongside it (see seedHeroRowFromEntrance) // Bait-walk overlay (town -> entrance), refreshed every playEvents() batch // that contains heroWalks events — see renderTown/renderHumanBoard/ // 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) => ({ 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 { switch (d.kind) { 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 // than after a further (separate) return-home tween finishes too. 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.seedHeroRowFromEntrance(seat); this.syncHeroRow(firstBatch); const ab = this._advBattle; this.buildAdventureAttackedPortrait(seat); @@ -2567,12 +2578,47 @@ export default class DungeonBossGame extends Phaser.Scene { this.tweens.add({ targets: root, alpha: 1, duration: 220 }); } - // Adds any hero from `events` not already in the row (every heroEnters the - // first time; only genuinely new ones on a later, resumed batch), sized by - // the pitch formula for the CURRENT total queue length — mirrors the - // existing townPos pitch pattern. Existing cards are left at their original - // slot/size on a later batch rather than reflowed, a deliberate simplification - // since that only matters for the rare mid-session-decision resume case. + // Populates the ENTIRE hero row up front, from the pre-walk entrance + // snapshot (see applyDecision/applyAdventureWindowChoice), so every hero + // queued for this seat's dungeon this turn is visible in ADV_HERO_ROW the + // moment the modal opens — not just the ones whose heroEnters event has + // streamed in so far. Reading gs.players[seat].entrance directly wouldn't + // 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) { const ab = this._advBattle; const newUids = []; @@ -2586,7 +2632,7 @@ export default class DungeonBossGame extends Phaser.Scene { } } 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 pitch = Math.min(maxW + 14, (x1 - x0) / n); 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.continueBtn.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 { if (d.kind === 'react') actReact(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; } + // 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) { const ab = this._advBattle; const rec = ab.heroCards.get(step.uid); this.resetRoomDimming(); // fresh hero, fresh run — every room but a truly destroyed one is fair game again if (!rec) { onDone(); return; } - const hl = this.add.graphics(); - 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); - ab.root.add(hl); - this.tweens.add({ targets: hl, alpha: 0, duration: 500, ease: 'Cubic.easeOut', onComplete: () => { hl.destroy(); onDone(); } }); + this.moveToStage(step.uid, null, { duration: 280 }, () => { + const w = ADV_STAGE_HERO_W, h = w / CARD_ASPECT.hero; + const hl = this.add.graphics(); + hl.lineStyle(3, C.gold, 1); + 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); + 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" —