Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 1753f43f2e add animated impact text for hero wounds and deaths in dungeon boss
Introduce showAdventureImpactText() to display centered, animated text
when heroes are wounded or killed during adventure battles. Text pops in
with scale, shakes/pulses, then fades out before continuing.

- Show "Soul Captured" message when hero dies
- Show "was wounded" message on hero defeat
- Chain onDone after full animation completes for proper beat sequencing
2026-07-07 19:07:26 -06:00
Brian Fertig 3e9ee8e47b 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
2026-07-07 18:36:52 -06:00
1 changed files with 113 additions and 17 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._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" —
@ -3390,8 +3450,11 @@ export default class DungeonBossGame extends Phaser.Scene {
onUpdate: () => heroRec.cont.hpText.setText(`${Math.round(hpObj.v)}`),
});
}
if (step.heroDies) this.time.delayedCall(480, () => this.removeHeroCard(uid, onDone));
else this.time.delayedCall(480, onDone);
if (step.heroDies) {
this.time.delayedCall(480, () => this.removeHeroCard(uid, () => {
this.showAdventureImpactText(`${this.advHeroName(uid)}'s Soul Captured`, '#39ff6a', onDone);
}));
} else this.time.delayedCall(480, onDone);
});
});
}
@ -3415,7 +3478,9 @@ export default class DungeonBossGame extends Phaser.Scene {
ab.stage.add(flash);
this.tweens.add({ targets: flash, alpha: 0, duration: 260, onComplete: () => flash.destroy() });
}
this.time.delayedCall(300, () => this.removeHeroCard(uid, onDone));
this.time.delayedCall(300, () => this.removeHeroCard(uid, () => {
this.showAdventureImpactText(`${this.advHeroName(uid)}'s Soul Captured`, '#39ff6a', onDone);
}));
});
}
@ -3441,8 +3506,39 @@ export default class DungeonBossGame extends Phaser.Scene {
ab.heroCards.delete(uid);
const idx = ab.heroOrder.indexOf(uid);
if (idx >= 0) ab.heroOrder.splice(idx, 1);
if (step.eliminated) this.showAdventureEliminatedBanner(this.seatName(ab.seat), onDone);
else onDone();
this.showAdventureImpactText(`${this.seatName(ab.seat)} was wounded`, '#ff3b3b', () => {
if (step.eliminated) this.showAdventureEliminatedBanner(this.seatName(ab.seat), onDone);
else onDone();
});
},
});
},
});
}
// Big punchy center-screen text for a boss wound or a hero's death — pops
// in, shakes/pulses for about a second, then shrinks away to nothing.
// onDone only fires once the whole sequence completes, so callers can
// chain it as a blocking beat before the walk proceeds.
showAdventureImpactText(text, color, onDone) {
const ab = this._advBattle;
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const t = this.add.text(cx, cy, text, {
fontFamily: 'Righteous', fontSize: '60px', color, align: 'center',
stroke: '#120e16', strokeThickness: 8,
}).setOrigin(0.5).setScale(0);
ab.root.add(t);
this.tweens.add({
targets: t, scale: 1, duration: 220, ease: 'Back.easeOut',
onComplete: () => {
this.tweens.add({ targets: t, x: cx - 5, duration: 55, ease: 'Sine.easeInOut', yoyo: true, repeat: 9 });
this.tweens.add({
targets: t, scale: 1.08, duration: 150, ease: 'Sine.easeInOut', yoyo: true, repeat: 2,
onComplete: () => {
this.tweens.add({
targets: t, scale: 0, alpha: 0, duration: 260, ease: 'Cubic.easeIn',
onComplete: () => { t.destroy(); onDone(); },
});
},
});
},