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 ────────────────────────────────────────────────────
|
// ── human input handlers ────────────────────────────────────────────────────
|
||||||
onMarketClick(i) {
|
async onMarketClick(i) {
|
||||||
if (!this.canHumanAct()) return;
|
if (!this.canHumanAct()) return;
|
||||||
if (i >= this.gs.faceUp.length) return;
|
if (i >= this.gs.faceUp.length) return;
|
||||||
if (this.gs.faceUp[i] === 'locomotive' && this.gs.drawnThisTurn !== 0) {
|
if (this.gs.faceUp[i] === 'locomotive' && this.gs.drawnThisTurn !== 0) {
|
||||||
this.flash('Take a face-up locomotive only as your first draw'); return;
|
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 (!this.canHumanAct()) return;
|
||||||
if (!L.canDrawTrains(this.gs)) { this.flash('No cards left to draw'); 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() {
|
onTicketDeckClick() {
|
||||||
|
|
@ -387,21 +413,40 @@ export default class TicketToRideGame extends Phaser.Scene {
|
||||||
this.gs = L.drawTickets(this.gs, seat);
|
this.gs = L.drawTickets(this.gs, seat);
|
||||||
// pendingTickets now set for this AI seat — advance() routes to aiResolveTickets.
|
// pendingTickets now set for this AI seat — advance() routes to aiResolveTickets.
|
||||||
} else { // drawTrain
|
} 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);
|
this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index);
|
||||||
playSound(this, SFX.CARD_DEAL);
|
playSound(this, SFX.CARD_DEAL);
|
||||||
this.renderAll();
|
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.
|
// Second draw if the turn is still in progress.
|
||||||
if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound')
|
if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound')
|
||||||
&& this.gs.currentPlayer === seat && this.gs.drawnThisTurn === 1 && !this.gs.pendingTickets) {
|
&& 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 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);
|
this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index);
|
||||||
playSound(this, SFX.CARD_DEAL);
|
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();
|
this.renderAll();
|
||||||
await this.delay(450);
|
await this.delay(200);
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.advance();
|
this.advance();
|
||||||
}
|
}
|
||||||
|
|
@ -791,4 +836,79 @@ export default class TicketToRideGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
|
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