feat: replace route cars with animated train car sprites and card animations

- Generate a `ttr-train-car` texture and use images instead of procedural drawing for owned routes.
- Add `animateClaimRoute` to animate cards flying from hand to a lineup, transforming into train cars, then placing on the route.
- Update human and AI route claim flows to use the new animation.
- Add a pulsing turn indicator triangle that moves to the current player's portrait.
- Track `trainCarObjs` for proper cleanup during route re-renders.
This commit is contained in:
Brian Fertig 2026-05-30 13:41:14 -06:00
parent 97befdb502
commit 2521146579
1 changed files with 168 additions and 8 deletions

View File

@ -55,11 +55,13 @@ export default class TicketToRideGame extends Phaser.Scene {
this.oppMiniHandGfx = []; this.oppMiniHandGfx = [];
this.oppMiniHandCount = []; this.oppMiniHandCount = [];
this.marketSlotObjs = []; this.marketSlotObjs = [];
this.trainCarObjs = [];
} }
create() { create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) { /* no music json */ } try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) { /* no music json */ }
this.buildParticleTexture(); this.buildParticleTexture();
this.buildTrainCarTexture();
this.buildBackdrop(); this.buildBackdrop();
this.buildStaticBoard(); this.buildStaticBoard();
this.buildRightPanel(); this.buildRightPanel();
@ -74,6 +76,22 @@ export default class TicketToRideGame extends Phaser.Scene {
g.destroy(); g.destroy();
} }
buildTrainCarTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1); g.fillRoundedRect(1, 3, 118, 34, 4); // body
g.fillStyle(0xdddddd, 1); g.fillRect(5, 3, 110, 9); // roof
g.fillStyle(0x181818, 1); g.fillRect(14, 14, 92, 12); // window glass
g.fillStyle(0xdddddd, 1); g.fillRect(42, 14, 4, 12); // mullion 1
g.fillRect(74, 14, 4, 12); // mullion 2
g.fillStyle(0xaaaaaa, 1); g.fillRoundedRect(10, 30, 22, 9, 2); // left truck
g.fillRoundedRect(88, 30, 22, 9, 2); // right truck
g.fillStyle(0x999999, 1); g.fillRect(30, 29, 60, 4); // underframe
g.fillStyle(0xeeeeee, 1); g.fillRect(1, 13, 3, 14); // left end cap
g.fillRect(116, 13, 3, 14); // right end cap
g.generateTexture('ttr-train-car', 120, 40);
g.destroy();
}
// ── backdrop: sea + land silhouette ───────────────────────────────────────── // ── backdrop: sea + land silhouette ─────────────────────────────────────────
buildBackdrop() { buildBackdrop() {
if (this.playfield?.key && this.textures.exists(this.playfield.key)) { if (this.playfield?.key && this.textures.exists(this.playfield.key)) {
@ -293,6 +311,8 @@ export default class TicketToRideGame extends Phaser.Scene {
new Button(this, 80, 40, 'Leave', () => this.scene.start('GameMenu'), new Button(this, 80, 40, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 120, height: 40, fontSize: 17 }).setDepth(D.banner); { variant: 'ghost', width: 120, height: 40, fontSize: 17 }).setDepth(D.banner);
this._buildTurnTriangle();
} }
// ── helpers ─────────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
@ -413,13 +433,23 @@ export default class TicketToRideGame extends Phaser.Scene {
this.renderHover(); this.renderHover();
} }
onRouteClick(id) { async onRouteClick(id) {
if (!this.canHumanAct()) return; if (!this.canHumanAct()) return;
if (this.gs.drawnThisTurn > 0) { this.flash('You are drawing cards this turn'); return; } if (this.gs.drawnThisTurn > 0) { this.flash('You are drawing cards this turn'); return; }
if (!L.routeClaimable(this.gs, 0, id)) { this.flash('That route is not available'); return; } if (!L.routeClaimable(this.gs, 0, id)) { this.flash('That route is not available'); return; }
const opts = L.paymentOptions(this.gs.players[0], ROUTES[id]); const opts = L.paymentOptions(this.gs.players[0], ROUTES[id]);
if (opts.length === 0) { this.flash('Not enough matching cards'); return; } if (opts.length === 0) { this.flash('Not enough matching cards'); return; }
if (opts.length === 1) { this.applyHuman(L.claimRoute(this.gs, 0, id, opts[0]), SFX.CARD_PLACE); return; } if (opts.length === 1) {
const payment = opts[0];
const newGs = L.claimRoute(this.gs, 0, id, payment);
this.busy = true;
await this.animateClaimRoute(0, id, payment);
this.gs = newGs;
playSound(this, SFX.CARD_PLACE);
this.busy = false;
this.advance();
return;
}
this.promptClaimPayment(id, opts); this.promptClaimPayment(id, opts);
} }
@ -432,7 +462,9 @@ export default class TicketToRideGame extends Phaser.Scene {
const a = AI.chooseAction(this.gs, seat); const a = AI.chooseAction(this.gs, seat);
if (a.type === 'claimRoute') { if (a.type === 'claimRoute') {
this.gs = L.claimRoute(this.gs, seat, a.routeId, a.payment); const newGs = L.claimRoute(this.gs, seat, a.routeId, a.payment);
await this.animateClaimRoute(seat, a.routeId, a.payment);
this.gs = newGs;
playSound(this, SFX.CARD_PLACE); playSound(this, SFX.CARD_PLACE);
this.opponentPortraits[seat - 1]?.playEmotion?.('happy'); this.opponentPortraits[seat - 1]?.playEmotion?.('happy');
} else if (a.type === 'drawTickets') { } else if (a.type === 'drawTickets') {
@ -528,6 +560,8 @@ export default class TicketToRideGame extends Phaser.Scene {
} }
renderRoutes() { renderRoutes() {
this.trainCarObjs.forEach((o) => o.destroy());
this.trainCarObjs = [];
const g = this.routeGfx; const g = this.routeGfx;
g.clear(); g.clear();
ROUTES.forEach((r, id) => { ROUTES.forEach((r, id) => {
@ -535,7 +569,14 @@ export default class TicketToRideGame extends Phaser.Scene {
const segs = this.segCache[id]; const segs = this.segCache[id];
if (owner != null) { if (owner != null) {
const col = this.playerColor(owner); const col = this.playerColor(owner);
for (const seg of segs) this.drawCar(g, seg, col.hex, col.hexDark, 0xffffff); for (const seg of segs) {
const img = this.add.image(seg.cx, seg.cy, 'ttr-train-car')
.setDisplaySize(seg.w, seg.h)
.setRotation(seg.angle)
.setTint(col.hex)
.setDepth(D.train);
this.trainCarObjs.push(img);
}
} else { } else {
const fill = CARD_COLOR_HEX[r.color] ?? CARD_COLOR_HEX.gray; const fill = CARD_COLOR_HEX[r.color] ?? CARD_COLOR_HEX.gray;
for (const seg of segs) this.drawCar(g, seg, fill, 0x2a2118, null); for (const seg of segs) this.drawCar(g, seg, fill, 0x2a2118, null);
@ -677,6 +718,45 @@ export default class TicketToRideGame extends Phaser.Scene {
} }
this.statusText.setText(msg); this.statusText.setText(msg);
this.logText.setText(s.log[s.log.length - 1] ?? ''); this.logText.setText(s.log[s.log.length - 1] ?? '');
this._updateTurnIndicator();
}
_buildTurnTriangle() {
const g = this.add.graphics().setDepth(D.hud + 8);
g.fillStyle(0xffdd00, 1);
g.fillTriangle(-10, -13, -10, 13, 10, 0);
g.setPosition(-9999, -9999);
this.turnIndicator = g;
this._turnSeat = null;
this.tweens.add({
targets: g, scaleX: 1.4, scaleY: 1.4,
duration: 700, yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
}
_seatPortraitPos(seat) {
if (seat === 0) return { x: 80, y: BOT_Y, r: 46 };
const { x, y } = this.oppPos(seat);
return { x, y, r: OPP_R };
}
_updateTurnIndicator() {
const seat = this.gs?.currentPlayer;
if (seat == null || !this.turnIndicator) return;
const pos = this._seatPortraitPos(seat);
if (!pos) return;
const tx = pos.x - pos.r - 20;
const ty = pos.y;
if (this._turnSeat === seat) return;
this._turnSeat = seat;
if (this.turnIndicator.x < 0) {
this.turnIndicator.setPosition(tx, ty);
} else {
this.tweens.add({
targets: this.turnIndicator, x: tx, y: ty,
duration: 600, ease: 'Cubic.Out',
});
}
} }
flash(message) { flash(message) {
@ -788,11 +868,19 @@ export default class TicketToRideGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.modal + 2); }).setOrigin(0.5).setDepth(D.modal + 2);
this.modalObjs.push(overlay, panel, title); this.modalObjs.push(overlay, panel, title);
const finish = (payment) => { const finish = async (payment) => {
this.closeModal(); this.closeModal();
if (payment) {
const newGs = L.claimRoute(this.gs, 0, routeId, payment);
await this.animateClaimRoute(0, routeId, payment);
this.gs = newGs;
playSound(this, SFX.CARD_PLACE);
this.busy = false; this.busy = false;
if (payment) this.applyHuman(L.claimRoute(this.gs, 0, routeId, payment), SFX.CARD_PLACE); this.advance();
else this.advance(); } else {
this.busy = false;
this.advance();
}
}; };
options.forEach((opt, i) => { options.forEach((opt, i) => {
@ -928,6 +1016,78 @@ export default class TicketToRideGame extends Phaser.Scene {
return { x: 400 + i * 105, y: 1000 }; return { x: 400 + i * 105, y: 1000 };
} }
async animateClaimRoute(seat, routeId, payment) {
const ANIM_DEPTH = D.banner + 10;
const CX = GAME_WIDTH / 2, CY = GAME_HEIGHT / 2;
const GAP = 20;
const segs = this.segCache[routeId];
const playerCol = this.playerColor(seat);
const cards = [];
for (let c = 0; c < (payment.colorCount ?? 0); c++) cards.push(payment.color);
for (let c = 0; c < (payment.locos ?? 0); c++) cards.push('locomotive');
const N = cards.length;
const totalW = N * 270 + (N - 1) * GAP;
const lineupStartX = CX - totalW / 2 + 135;
const srcForColor = (colorKey) => {
if (seat === 0) return this.handDestForColor(colorKey);
const { x, y } = this.oppPos(seat);
return { x: x - OPP_R + 39, y: y + OPP_R + 20 };
};
// Phase 1: cards fly to lineup one at a time
const cardObjs = [];
for (let i = 0; i < N; i++) {
const colorKey = cards[i];
const src = srcForColor(colorKey);
const img = this.add.image(src.x, src.y, 'ttr-cards', TTR_CARD_FRAME[colorKey] ?? TTR_CARD_FRAME.back)
.setDisplaySize(270, 390)
.setDepth(ANIM_DEPTH + i);
cardObjs.push(img);
await new Promise((res) => this.tweens.add({
targets: img,
x: lineupStartX + i * (270 + GAP), y: CY,
duration: 400, ease: 'Cubic.easeOut', onComplete: res,
}));
}
// Phase 2: hold
await this.delay(500);
// Phase 3: transform all cards to train cars simultaneously
await Promise.all(cardObjs.map((img) => this._transformToTrainCar(img, playerCol.hex)));
// Phase 4: brief pause as train cars
await this.delay(150);
// Phase 5: fly to route segments simultaneously
await Promise.all(segs.map((seg, i) => {
const img = cardObjs[i];
if (!img) return Promise.resolve();
return new Promise((res) => this.tweens.add({
targets: img,
x: seg.cx, y: seg.cy,
scaleX: seg.w / 120, scaleY: 16 / 40,
rotation: seg.angle,
duration: 600, ease: 'Cubic.easeIn', onComplete: res,
}));
}));
cardObjs.forEach((img) => img.destroy());
}
async _transformToTrainCar(img, tintColor) {
await new Promise((res) => this.tweens.add({
targets: img, scaleX: 0, scaleY: 0, duration: 200, ease: 'Cubic.easeIn', onComplete: res,
}));
img.setTexture('ttr-train-car').setTint(tintColor);
await new Promise((res) => this.tweens.add({
targets: img, scaleX: 1, scaleY: 1, duration: 200, ease: 'Cubic.easeOut', onComplete: res,
}));
}
async animateMarketSlideUp(fromSlot, lastSlot) { async animateMarketSlideUp(fromSlot, lastSlot) {
const mkStep = CARD_W + 10; const mkStep = CARD_W + 10;
const targets = []; const targets = [];