feat: add market card sliding and refill animations to Ticket to Ride

- Implement `animateMarketSlideUp` to slide remaining cards up when one is drawn.
- Add `animateRefillMarketSlot` to animate a new card appearing from the deck.
- Update human and AI draw handlers to orchestrate card draw, slide, and refill animations.
- Introduce `marketSlotObjs` to track UI objects per slot for targeted tweens.
- Create `renderAllExceptMarket` to render the rest of the game while market animations play.
- Refactor `renderMarket` to group objects by slot index.
This commit is contained in:
Brian Fertig 2026-05-30 13:17:49 -06:00
parent ae6c69e25c
commit 97befdb502
1 changed files with 92 additions and 11 deletions

View File

@ -54,6 +54,7 @@ export default class TicketToRideGame extends Phaser.Scene {
this.oppScoreTexts = {};
this.oppMiniHandGfx = [];
this.oppMiniHandCount = [];
this.marketSlotObjs = [];
}
create() {
@ -356,16 +357,24 @@ export default class TicketToRideGame extends Phaser.Scene {
const srcX = MK_X;
const srcY = MK_Y0 + i * (CARD_W + 10);
const color = this.gs.faceUp[i];
const origFaceUpLength = this.gs.faceUp.length;
const newGs = L.drawTrainCard(this.gs, 0, 'faceUp', i);
const refillHappened = newGs.faceUp.length === origFaceUpLength;
const refillSlot = origFaceUpLength - 1;
const refillColor = refillHappened ? newGs.faceUp[refillSlot] : undefined;
this.busy = true;
this.gs = newGs;
this.renderAll();
this.marketSlotObjs[i]?.forEach((o) => o.setVisible(false));
this.renderAllExceptMarket();
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,
});
await this.animateMarketSlideUp(i, origFaceUpLength - 1);
if (refillColor !== undefined) await this.animateRefillMarketSlot(refillSlot, refillColor);
this.renderAll();
this.busy = false;
this.advance();
}
@ -434,13 +443,18 @@ export default class TicketToRideGame extends Phaser.Scene {
const fanX = dX - OPP_R;
const fanY = dY + OPP_R + 20;
// 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;
const firstFaceUp = a.source === 'faceUp';
const faceUpColor1 = firstFaceUp ? this.gs.faceUp[a.index] : null;
const srcX1 = firstFaceUp ? MK_X : PILE_X;
const srcY1 = firstFaceUp ? MK_Y0 + a.index * (CARD_W + 10) : MK_Y0;
const origFaceUpLen1 = firstFaceUp ? this.gs.faceUp.length : 0;
if (firstFaceUp) this.marketSlotObjs[a.index]?.forEach((o) => o.setVisible(false));
this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index);
const slot1 = Math.min(L.handCount(this.gs.players[seat]) - 1, 4);
const refillHappened1 = firstFaceUp && this.gs.faceUp.length === origFaceUpLen1;
const refillColor1 = refillHappened1 ? this.gs.faceUp[origFaceUpLen1 - 1] : undefined;
playSound(this, SFX.CARD_DEAL);
this.renderAll();
if (firstFaceUp) this.renderAllExceptMarket(); else this.renderAll();
await this.animateCardDraw({
srcX: srcX1, srcY: srcY1, srcAngle: -90,
cardFrame: faceUpColor1 ? TTR_CARD_FRAME[faceUpColor1] : TTR_CARD_FRAME.back,
@ -448,18 +462,28 @@ export default class TicketToRideGame extends Phaser.Scene {
destX: fanX + slot1 * MINI_STEP + MINI_W / 2, destY: fanY,
destW: MINI_W, destH: MINI_H,
});
if (firstFaceUp) {
await this.animateMarketSlideUp(a.index, origFaceUpLen1 - 1);
if (refillColor1 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen1 - 1, refillColor1);
this.renderAll();
}
// 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(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;
const secondFaceUp = b.source === 'faceUp';
const faceUpColor2 = secondFaceUp ? this.gs.faceUp[b.index] : null;
const srcX2 = secondFaceUp ? MK_X : PILE_X;
const srcY2 = secondFaceUp ? MK_Y0 + b.index * (CARD_W + 10) : MK_Y0;
const origFaceUpLen2 = secondFaceUp ? this.gs.faceUp.length : 0;
if (secondFaceUp) this.marketSlotObjs[b.index]?.forEach((o) => o.setVisible(false));
this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index);
const slot2 = Math.min(L.handCount(this.gs.players[seat]) - 1, 4);
const refillHappened2 = secondFaceUp && this.gs.faceUp.length === origFaceUpLen2;
const refillColor2 = refillHappened2 ? this.gs.faceUp[origFaceUpLen2 - 1] : undefined;
playSound(this, SFX.CARD_DEAL);
this.renderAll();
if (secondFaceUp) this.renderAllExceptMarket(); else this.renderAll();
await this.animateCardDraw({
srcX: srcX2, srcY: srcY2, srcAngle: -90,
cardFrame: faceUpColor2 ? TTR_CARD_FRAME[faceUpColor2] : TTR_CARD_FRAME.back,
@ -467,6 +491,11 @@ export default class TicketToRideGame extends Phaser.Scene {
destX: fanX + slot2 * MINI_STEP + MINI_W / 2, destY: fanY,
destW: MINI_W, destH: MINI_H,
});
if (secondFaceUp) {
await this.animateMarketSlideUp(b.index, origFaceUpLen2 - 1);
if (refillColor2 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen2 - 1, refillColor2);
this.renderAll();
}
}
}
@ -539,6 +568,7 @@ export default class TicketToRideGame extends Phaser.Scene {
renderMarket() {
this.marketObjs.forEach((o) => o.destroy());
this.marketObjs = [];
this.marketSlotObjs = [];
const myTurn = this.canHumanAct();
const mkStep = CARD_W + 10; // rotated visual height + padding
for (let i = 0; i < 5; i++) {
@ -548,16 +578,17 @@ export default class TicketToRideGame extends Phaser.Scene {
const g = this.add.graphics().setDepth(D.market);
g.lineStyle(2, COLORS.mutedHex, 0.5);
g.strokeRoundedRect(MK_X - CARD_H / 2, y - CARD_W / 2, CARD_H, CARD_W, 8);
this.marketSlotObjs[i] = [g];
this.marketObjs.push(g);
continue;
}
const objs = this.makeCardFace(MK_X, y, CARD_W, CARD_H, color, D.market, '');
objs.forEach((o) => o.setAngle(-90));
this.marketObjs.push(...objs);
const z = this.add.zone(MK_X, y, CARD_H, CARD_W).setDepth(D.market + 2);
if (myTurn) z.setInteractive({ useHandCursor: true });
z.on('pointerdown', () => this.onMarketClick(i));
this.marketObjs.push(z);
this.marketSlotObjs[i] = [...objs, z];
this.marketObjs.push(...objs, z);
}
}
@ -882,11 +913,61 @@ export default class TicketToRideGame extends Phaser.Scene {
delay(ms) { return new Promise((res) => this.time.delayedCall(ms, res)); }
renderAllExceptMarket() {
if (!this.gs) return;
this.renderRoutes();
this.renderHover();
this.renderHand();
this.renderPiles();
this.renderPanels();
this.renderStatus();
}
handDestForColor(colorKey) {
const i = HAND_ORDER.indexOf(colorKey);
return { x: 400 + i * 105, y: 1000 };
}
async animateMarketSlideUp(fromSlot, lastSlot) {
const mkStep = CARD_W + 10;
const targets = [];
for (let j = fromSlot + 1; j <= lastSlot; j++) {
if (this.marketSlotObjs[j]) targets.push(...this.marketSlotObjs[j]);
}
if (targets.length === 0) return;
await new Promise((res) => this.tweens.add({
targets, y: `-=${mkStep}`, duration: 200, ease: 'Cubic.easeInOut', onComplete: res,
}));
}
async animateRefillMarketSlot(slotIndex, color) {
const ANIM_DEPTH = D.banner + 10;
const destX = MK_X;
const destY = MK_Y0 + slotIndex * (CARD_W + 10);
const origScaleX = CARD_W / 270;
const card = this.add.image(PILE_X, MK_Y0, 'ttr-cards', TTR_CARD_FRAME.back)
.setDisplaySize(CARD_W, CARD_H)
.setAngle(-90)
.setDepth(ANIM_DEPTH);
// Slide from deck to market slot
await new Promise((res) => this.tweens.add({
targets: card, x: destX, y: destY, duration: 350, ease: 'Cubic.easeOut', onComplete: res,
}));
// Flip at slot: collapse scaleX (height at -90°), swap frame, restore
await new Promise((res) => this.tweens.add({
targets: card, scaleX: 0, duration: 150, ease: 'Linear', onComplete: res,
}));
card.setFrame(TTR_CARD_FRAME[color] ?? TTR_CARD_FRAME.back);
await new Promise((res) => this.tweens.add({
targets: card, scaleX: origScaleX, duration: 150, ease: 'Linear', onComplete: res,
}));
card.destroy();
}
async animateCardDraw({ srcX, srcY, srcAngle = 0, cardFrame, shouldFlip = false, destX, destY, destW, destH }) {
const ANIM_DEPTH = D.banner + 10;
const CX = GAME_WIDTH / 2;