feat: add cinematic card drawing animations to Ticket to Ride

- Implement `animateCardDraw` with a multi-phase animation: flip, fly to center,
  fireworks particle effect, and fly to destination.
- Update human market and deck click handlers to play animations when drawing cards.
- Add card draw animations for AI turns, flying cards to opponent positions.
- Refactor turn logic to await animations before advancing game state.
- Add `handDestForColor` helper for calculating hand layout positions.
This commit is contained in:
Brian Fertig 2026-05-30 12:41:27 -06:00
parent 8222b61f0a
commit 402293fea2
1 changed files with 126 additions and 6 deletions

View File

@ -330,19 +330,45 @@ export default class TicketToRideGame extends Phaser.Scene {
}
// ── human input handlers ────────────────────────────────────────────────────
onMarketClick(i) {
async onMarketClick(i) {
if (!this.canHumanAct()) return;
if (i >= this.gs.faceUp.length) return;
if (this.gs.faceUp[i] === 'locomotive' && this.gs.drawnThisTurn !== 0) {
this.flash('Take a face-up locomotive only as your first draw'); return;
}
this.applyHuman(L.drawTrainCard(this.gs, 0, 'faceUp', i), SFX.CARD_DEAL);
const srcX = MK_X;
const srcY = MK_Y0 + i * (CARD_W + 10);
const color = this.gs.faceUp[i];
const newGs = L.drawTrainCard(this.gs, 0, 'faceUp', i);
this.busy = true;
this.gs = newGs;
this.renderAll();
playSound(this, SFX.CARD_DEAL);
const dest = this.handDestForColor(color);
await this.animateCardDraw({
srcX, srcY, srcAngle: -90, cardFrame: TTR_CARD_FRAME[color],
shouldFlip: false, destX: dest.x, destY: dest.y, destW: 97, destH: 140,
});
this.busy = false;
this.advance();
}
onDeckClick() {
async onDeckClick() {
if (!this.canHumanAct()) return;
if (!L.canDrawTrains(this.gs)) { this.flash('No cards left to draw'); return; }
this.applyHuman(L.drawTrainCard(this.gs, 0, 'deck'), SFX.CARD_DEAL);
const newGs = L.drawTrainCard(this.gs, 0, 'deck');
const drawnColor = HAND_ORDER.find((k) => newGs.players[0].hand[k] > this.gs.players[0].hand[k]);
this.busy = true;
this.gs = newGs;
this.renderAll();
playSound(this, SFX.CARD_DEAL);
const dest = this.handDestForColor(drawnColor);
await this.animateCardDraw({
srcX: PILE_X, srcY: MK_Y0, srcAngle: -90, cardFrame: TTR_CARD_FRAME[drawnColor],
shouldFlip: true, destX: dest.x, destY: dest.y, destW: 97, destH: 140,
});
this.busy = false;
this.advance();
}
onTicketDeckClick() {
@ -387,21 +413,40 @@ export default class TicketToRideGame extends Phaser.Scene {
this.gs = L.drawTickets(this.gs, seat);
// pendingTickets now set for this AI seat — advance() routes to aiResolveTickets.
} else { // drawTrain
const { x: dX, y: dY } = this.oppPos(seat);
// First draw
const faceUpColor1 = a.source === 'faceUp' ? this.gs.faceUp[a.index] : null;
const srcX1 = a.source === 'faceUp' ? MK_X : PILE_X;
const srcY1 = a.source === 'faceUp' ? MK_Y0 + a.index * (CARD_W + 10) : MK_Y0;
this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index);
playSound(this, SFX.CARD_DEAL);
this.renderAll();
await this.animateCardDraw({
srcX: srcX1, srcY: srcY1, srcAngle: -90,
cardFrame: faceUpColor1 ? TTR_CARD_FRAME[faceUpColor1] : TTR_CARD_FRAME.back,
shouldFlip: false, destX: dX, destY: dY, destW: 44, destH: 64,
});
// Second draw if the turn is still in progress.
if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound')
&& this.gs.currentPlayer === seat && this.gs.drawnThisTurn === 1 && !this.gs.pendingTickets) {
await this.delay(450);
await this.delay(150);
const b = AI.chooseSecondDraw(this.gs, seat);
const faceUpColor2 = b.source === 'faceUp' ? this.gs.faceUp[b.index] : null;
const srcX2 = b.source === 'faceUp' ? MK_X : PILE_X;
const srcY2 = b.source === 'faceUp' ? MK_Y0 + b.index * (CARD_W + 10) : MK_Y0;
this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index);
playSound(this, SFX.CARD_DEAL);
this.renderAll();
await this.animateCardDraw({
srcX: srcX2, srcY: srcY2, srcAngle: -90,
cardFrame: faceUpColor2 ? TTR_CARD_FRAME[faceUpColor2] : TTR_CARD_FRAME.back,
shouldFlip: false, destX: dX, destY: dY, destW: 44, destH: 64,
});
}
}
this.renderAll();
await this.delay(450);
await this.delay(200);
this.busy = false;
this.advance();
}
@ -791,4 +836,79 @@ export default class TicketToRideGame extends Phaser.Scene {
}
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
handDestForColor(colorKey) {
const i = HAND_ORDER.indexOf(colorKey);
return { x: 400 + i * 105, y: 1000 };
}
async animateCardDraw({ srcX, srcY, srcAngle = 0, cardFrame, shouldFlip = false, destX, destY, destW, destH }) {
const ANIM_DEPTH = D.banner + 10;
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2;
const origScaleX = CARD_W / 270;
const card = this.add.image(srcX, srcY, 'ttr-cards', shouldFlip ? TTR_CARD_FRAME.back : cardFrame)
.setDisplaySize(CARD_W, CARD_H)
.setAngle(srcAngle)
.setDepth(ANIM_DEPTH);
// Phase 0: flip (player from deck only)
if (shouldFlip) {
// Straighten to portrait before flip
await new Promise((res) => this.tweens.add({
targets: card, angle: 0, duration: 250, ease: 'Cubic.easeOut', onComplete: res,
}));
// First half: collapse width
await new Promise((res) => this.tweens.add({
targets: card, scaleX: 0, duration: 180, ease: 'Linear', onComplete: res,
}));
card.setFrame(cardFrame);
// Second half: restore width
await new Promise((res) => this.tweens.add({
targets: card, scaleX: origScaleX, duration: 180, ease: 'Linear', onComplete: res,
}));
await this.delay(150);
}
// Phase 1: fly to center at full sprite size
await new Promise((res) => this.tweens.add({
targets: card,
x: CX, y: CY,
scaleX: 1, scaleY: 1,
angle: 0,
duration: 600,
ease: 'Cubic.easeOut',
onComplete: res,
}));
// Phase 2: hold at center with fireworks
const fw = this.add.particles(CX, CY, 'ttrParticle', {
speed: { min: 80, max: 420 },
lifespan: 700,
scale: { start: 1, end: 0 },
alpha: { start: 1, end: 0 },
quantity: 2,
frequency: 35,
tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0x88ff44],
angle: { min: 0, max: 360 },
emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-160, -220, 320, 440) },
}).setDepth(ANIM_DEPTH + 1);
await this.delay(500);
fw.stop();
// Phase 3: fly to destination at final size
await new Promise((res) => this.tweens.add({
targets: card,
x: destX, y: destY,
scaleX: destW / 270, scaleY: destH / 390,
duration: 500,
ease: 'Cubic.easeIn',
onComplete: res,
}));
fw.destroy();
card.destroy();
}
}