diff --git a/public/src/games/tickettoride/TicketToRideGame.js b/public/src/games/tickettoride/TicketToRideGame.js index a515efc..c943838 100644 --- a/public/src/games/tickettoride/TicketToRideGame.js +++ b/public/src/games/tickettoride/TicketToRideGame.js @@ -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(); + } }