From 8c8bce814f11ba1cfc8ba7ee288877ad65bfc9ef Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Tue, 7 Jul 2026 17:41:26 -0600 Subject: [PATCH] feat: add fly-in animation for town hero and epic reveals Extend the card draw animation system to support town hero and epic reveals. Add hero and epic configurations to DRAW_FULL_W and DECK_PILE_POS. Update flyCardFromDeck to handle hero/epic cards and accept a configurable pause duration. Introduce _pendingHeroRevealUids to suppress town rendering until fly-in animations complete. Enhance heroRevealed event handling to trigger the full fly/flip/shrink sequence. Adjust event timing delays to accommodate the longer animation. --- src/games/dungeonboss/DungeonBossGame.js | 52 +++++++++++++++++++----- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index 586f62e..df75fb4 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -50,11 +50,15 @@ const TOWN_HERO_W = 106; const OPP_ENTRANCE_W = 46, OPP_ENTRANCE_PITCH = 30; const HUMAN_ENTRANCE_W = 64, HUMAN_ENTRANCE_PITCH = 38; // Width a drawn card grows to while paused, face down then flipped, in the -// center of the screen mid-draw-animation, before shrinking into the hand. -const DRAW_FULL_W = { room: 340, spell: 300 }; +// center of the screen mid-draw-animation, before shrinking into the hand +// (room/spell) or town row (hero/epic). +const DRAW_FULL_W = { room: 340, spell: 300, hero: 300, epic: 300 }; // Deck-pile screen positions the draw animation flies cards out of — must -// match renderDeckPiles()'s room/spell rows. -const DECK_PILE_POS = { room: { x: 60, y: 520, w: 46, h: 64 }, spell: { x: 60, y: 600, w: 46, h: 64 } }; +// match renderDeckPiles()'s room/spell/hero/epic rows. +const DECK_PILE_POS = { + room: { x: 60, y: 520, w: 46, h: 64 }, spell: { x: 60, y: 600, w: 46, h: 64 }, + hero: { x: 60, y: 680, w: 46, h: 64 }, epic: { x: 60, y: 760, w: 46, h: 64 }, +}; // ── adventure battle modal: layout constants ──────────────────────────────── // Event types the modal owns end-to-end once a walk starts (see @@ -292,6 +296,10 @@ export default class DungeonBossGame extends Phaser.Scene { // animation hasn't landed yet — see renderHand's placeholder and // eventFx's 'draw' case / renderHandOnly. this._pendingDrawUids = new Set(); + // Town reveal overlay: uids of freshly-drawn town heroes whose + // fly-in-from-deck animation hasn't landed yet — see renderTown's + // placeholder and eventFx's 'heroRevealed' case. + this._pendingHeroRevealUids = new Set(); } create() { @@ -931,7 +939,8 @@ export default class DungeonBossGame extends Phaser.Scene { // Flies one card from its deck pile to the center of the screen at full // size, flips it face-up there, then shrinks it into its resting spot in - // the hand. Used both for the initial deal and for any later draw. The + // the hand (room/spell) or town row (hero/epic). Used for the initial hand + // deal, any later room/spell draw, and hero/epic town reveals. The // container is built once at full size and only ever scaled (never // redrawn), so the back/front art stays crisp through every phase. // onComplete receives the landed container — it is NOT auto-destroyed, @@ -939,7 +948,7 @@ export default class DungeonBossGame extends Phaser.Scene { // hand render is suppressed until every card has landed); callers that // don't need that (e.g. a single mid-game draw, where the real card is // already rendered underneath) should destroy it themselves. - flyCardFromDeck(kind, inst, targetX, targetY, targetW, onComplete) { + flyCardFromDeck(kind, inst, targetX, targetY, targetW, onComplete, { pauseMs = 1200 } = {}) { const pile = DECK_PILE_POS[kind]; const { w: fw, h: fh } = this.fitCardBox(DRAW_FULL_W[kind], 0, CARD_ASPECT[kind]); const cont = this.add.container(pile.x, pile.y).setScale(pile.w / fw); @@ -956,11 +965,12 @@ export default class DungeonBossGame extends Phaser.Scene { back.destroy(); this.sfx(SFX.CARD_SHOW); if (kind === 'room') this.makeRoomCard(0, 0, inst, fw, fh, { parent: cont, showText: true, hover: false, noHoverPreview: true }); - else this.makeSpellCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true }); + else if (kind === 'spell') this.makeSpellCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true }); + else this.makeHeroCard(0, 0, inst, fw, fh, { parent: cont, hover: false, noHoverPreview: true }); this.tweens.add({ targets: cont, scaleX: 1, duration: 110, ease: 'Sine.easeOut', onComplete: () => { - this.time.delayedCall(1200, () => { + this.time.delayedCall(pauseMs, () => { const handScale = targetW / fw; this.tweens.add({ targets: cont, x: targetX, y: targetY, scale: handScale, @@ -1341,6 +1351,10 @@ export default class DungeonBossGame extends Phaser.Scene { roster.forEach((hero, i) => { const { x, y } = this.townPos(i, n); this._heroTokens.set(hero.uid, { x, y }); + // Still mid fly-in-from-deck reveal (see eventFx's 'heroRevealed' case) + // — leave this slot empty until it lands; _heroTokens above still + // records where it'll land. + if (this._pendingHeroRevealUids.has(hero.uid)) return; this.makeHeroCard(x, y, hero, TOWN_HERO_W, TOWN_HERO_W / CARD_ASPECT.hero, { parent: this.townLayer, highlight: this.isHeroTarget(hero.uid), @@ -3463,6 +3477,10 @@ export default class DungeonBossGame extends Phaser.Scene { const list = humanHand.hand.spells; for (let i = list.length - this._humanDrawBatch.spell; i < list.length; i++) this._pendingDrawUids.add(list[i].uid); } + // Freshly-drawn town heroes (already pushed into gs.town) stay hidden + // from the real town render until their own fly-in-from-deck animation + // lands — see renderTown's placeholder and eventFx's 'heroRevealed' case. + this._pendingHeroRevealUids = new Set(events.filter((e) => e.type === 'heroRevealed').map((e) => e.uid)); // Heroes baited to walk to an entrance this batch: keep each one's town // card in place and hide it from the (already-mutated) real entrance // render until its own walk step actually fires — see renderTown's @@ -3501,7 +3519,10 @@ export default class DungeonBossGame extends Phaser.Scene { switch (e.type) { case 'flip': return 260; case 'roomBuilt': return 240; - case 'heroRevealed': return 140; + // Full fly/flip/pause/shrink reveal (see eventFx) — long enough that if + // several heroes are revealed in the same round-start batch, each one's + // reveal finishes before the next one's flight begins. + case 'heroRevealed': return 1850; case 'heroWalks': return 1500; case 'heroEnters': return 200; case 'roomHits': return 340; @@ -3542,9 +3563,18 @@ export default class DungeonBossGame extends Phaser.Scene { if (r) this.popText(r.x, r.y, ROOMS[e.id]?.name || '', '#f2ead8'); break; } - case 'heroRevealed': - this.sfx(SFX.CARD_DEAL); + case 'heroRevealed': { + const idx = gs.town.findIndex((h) => h.uid === e.uid); + const hero = gs.town[idx]; + if (idx < 0 || !hero) { this.sfx(SFX.CARD_DEAL); break; } + const { x, y } = this.townPos(idx, gs.town.length); + this.flyCardFromDeck(e.epic ? 'epic' : 'hero', hero, x, y, TOWN_HERO_W, (cont) => { + this._pendingHeroRevealUids.delete(e.uid); + this.renderTownOnly(); + cont.destroy(); + }, { pauseMs: 1000 }); break; + } case 'draw': { if (e.seat !== this.humanSeat) { this.sfx(SFX.CARD_DEAL); break; } const p = gs.players[e.seat];