diff --git a/src/games/dungeonboss/DungeonBossGame.js b/src/games/dungeonboss/DungeonBossGame.js index c5501bc..f80bf13 100644 --- a/src/games/dungeonboss/DungeonBossGame.js +++ b/src/games/dungeonboss/DungeonBossGame.js @@ -43,6 +43,12 @@ const DEPTH = { board: 10, town: 20, hand: 40, fx: 60, ui: 80, overlay: 90, hove // Canonical card shapes (w/h) — every rendered instance of a card type keeps // this aspect ratio, whatever box a call site hands it (see fitCardBox). const CARD_ASPECT = { room: 280 / 390, spell: 260 / 360, hero: 280 / 390, boss: 300 / 430 }; +// 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 }; +// 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 } }; export default class DungeonBossGame extends Phaser.Scene { constructor() { super('DungeonBossGame'); } @@ -64,6 +70,7 @@ export default class DungeonBossGame extends Phaser.Scene { this._recorded = false; this.hoverTimer = null; this.hoverVisible = false; + this._dealing = false; // true while the initial-deal animation plays } create() { @@ -98,9 +105,12 @@ export default class DungeonBossGame extends Phaser.Scene { this.buildStaticUi(); this.gs = newGame(this.nPlayers, (Math.random() * 1e9) | 0); takeEvents(this.gs); // swallow gameStart + this._dealing = true; this.renderAll(); - this.showBanner('Discard 2 cards, then build your first room'); - this.pump(); + this.animateInitialDeal(() => { + this.showBanner('Discard 2 cards, then build your first room'); + this.pump(); + }); } // ── static chrome ────────────────────────────────────────────────────────── @@ -467,6 +477,20 @@ export default class DungeonBossGame extends Phaser.Scene { g.lineStyle(1.5, 0x4a3a5c, 0.7); g.strokeRoundedRect(-w / 2 + i * 3, -h / 2 + i * 3, w, h, 5); cont.add(g); } + this.makeDeckBackFace(0, 0, w, h, kind, cont); + if (count <= 0) { + cont.add(this.add.text(0, 0, '✕', { fontSize: `${Math.round(h * 0.5)}px`, color: '#c0392b' }).setOrigin(0.5)); + cont.setAlpha(0.45); + } + (parent || this.boardLayer).add(cont); + return cont; + } + + // The single card-back face (art if supplied, else a procedural + // plate+glyph), shared by the deck-pile display and the draw-flight + // animation so both show the same back design. + makeDeckBackFace(x, y, w, h, kind, parent) { + const cont = this.add.container(x, y); const art = this.artFor('deckBack', kind); if (art) { cont.add(this.add.image(0, 0, art.key, art.frame).setDisplaySize(w, h)); @@ -482,14 +506,57 @@ export default class DungeonBossGame extends Phaser.Scene { const glyph = { room: '⌂', spell: '✦', hero: '⚔', epic: '♛' }[kind] || '✦'; cont.add(this.add.text(0, 0, glyph, { fontSize: `${Math.round(h * 0.34)}px`, color: '#5c4a70' }).setOrigin(0.5)); } - if (count <= 0) { - cont.add(this.add.text(0, 0, '✕', { fontSize: `${Math.round(h * 0.5)}px`, color: '#c0392b' }).setOrigin(0.5)); - cont.setAlpha(0.45); - } (parent || this.boardLayer).add(cont); return cont; } + // 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 + // 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, + // since during the initial deal it needs to keep showing (as the real + // 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) { + 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); + this.fxLayer.add(cont); + const back = this.makeDeckBackFace(0, 0, fw, fh, kind, cont); + this.sfx(SFX.CARD_DEAL); + this.tweens.add({ + targets: cont, x: GAME_WIDTH / 2, y: GAME_HEIGHT / 2 - 60, scale: 1, + duration: 300, ease: 'Cubic.easeOut', + onComplete: () => { + this.tweens.add({ + targets: cont, scaleX: 0, duration: 110, ease: 'Sine.easeIn', + onComplete: () => { + 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 }); + this.tweens.add({ + targets: cont, scaleX: 1, duration: 110, ease: 'Sine.easeOut', + onComplete: () => { + this.time.delayedCall(1200, () => { + const handScale = targetW / fw; + this.tweens.add({ + targets: cont, x: targetX, y: targetY, scale: handScale, + duration: 280, ease: 'Cubic.easeIn', + onComplete: () => onComplete?.(cont), + }); + }); + }, + }); + }, + }); + }, + }); + } + // Left-side "deck piles" area: small representations of the four draw // decks with remaining counts, sitting in the gap between the deck-count // text (above) and the player portrait cluster (below). @@ -849,7 +916,47 @@ export default class DungeonBossGame extends Phaser.Scene { }).setOrigin(0.5)); } + // Deals the human player's starting hand (5 rooms + 2 spells, per + // newGame()) one card at a time, flying each from its deck pile to its + // real final hand slot. renderHand() is suppressed via this._dealing + // until every card has landed, so the reflow always matches where the + // flying copies land. Each landed card is kept on screen (not destroyed) + // so the hand visibly builds up card by card; once the last one lands, + // the real hand is rendered underneath and only then are the landed + // stand-ins destroyed, in the same tick, so there's no flicker. + animateInitialDeal(onDone) { + const p = this.gs.players[this.humanSeat]; + const rooms = p.hand.rooms, spells = p.hand.spells; + const total = rooms.length + spells.length; + if (!total) { this._dealing = false; onDone(); return; } + const roomW = 125, spellW = 104; + const pitch = Math.min(160, 1500 / total); + const width = (total - 1) * pitch; + const baseX = GAME_WIDTH / 2 - width / 2, y = 985; + const plan = [ + ...rooms.map((inst, i) => ({ inst, kind: 'room', x: baseX + i * pitch, y, w: roomW })), + ...spells.map((inst, i) => ({ inst, kind: 'spell', x: baseX + (rooms.length + i) * pitch, y: y - 16, w: spellW })), + ]; + const landed = []; + const dealNext = (i) => { + if (i >= plan.length) { + this._dealing = false; + this.renderAll(); + landed.forEach((c) => c.destroy()); + onDone(); + return; + } + const step = plan[i]; + this.flyCardFromDeck(step.kind, step.inst, step.x, step.y, step.w, (cont) => { + landed.push(cont); + dealNext(i + 1); + }); + }; + dealNext(0); + } + renderHand() { + if (this._dealing) return; const p = this.gs.players[this.humanSeat]; if (!p.alive) return; const rooms = p.hand.rooms; @@ -1340,6 +1447,13 @@ export default class DungeonBossGame extends Phaser.Scene { playEvents(events) { this.busy = true; this.setPrompt(''); + // A single action can emit several same-kind 'draw' events for the human + // (e.g. draw 2 rooms); the event carries no card uid, so figure out up + // front how many of each kind are coming, and consume them in order from + // the end of the (already fully mutated) hand array as each is played. + this._humanDrawBatch = { room: 0, spell: 0 }; + this._humanDrawSeen = { room: 0, spell: 0 }; + for (const e of events) if (e.type === 'draw' && e.seat === this.humanSeat) this._humanDrawBatch[e.card]++; const queue = events.filter((e) => this.eventDelay(e) > 0 || this.eventFx(e, true)); const step = () => { const e = queue.shift(); @@ -1376,6 +1490,7 @@ export default class DungeonBossGame extends Phaser.Scene { case 'roomDestroyed': return 320; case 'roomFrozen': return 300; case 'trapArmed': return 260; + case 'draw': return 260; default: return 0; } } @@ -1397,6 +1512,19 @@ export default class DungeonBossGame extends Phaser.Scene { case 'heroRevealed': this.sfx(SFX.CARD_DEAL); break; + case 'draw': { + if (e.seat !== this.humanSeat) { this.sfx(SFX.CARD_DEAL); break; } + const p = gs.players[e.seat]; + const list = e.card === 'room' ? p.hand.rooms : p.hand.spells; + const total = this._humanDrawBatch[e.card] || 1; + const seen = this._humanDrawSeen[e.card]++; + const inst = list[list.length - total + seen]; + const sprite = inst && this._handSprites.get(inst.uid); + if (inst && sprite) { + this.flyCardFromDeck(e.card, inst, sprite.x, sprite.y, e.card === 'room' ? 125 : 104, (cont) => cont.destroy()); + } else this.sfx(SFX.CARD_DEAL); + break; + } case 'heroWalks': { const from = this._heroTokens.get(e.uid); const hero = this.findHero(e.uid) || { uid: e.uid, id: e.id, hp: 0 };