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:
parent
8222b61f0a
commit
402293fea2
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue