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.oppMiniHandCount = [];
this.marketSlotObjs = [];
this.trainCarObjs = [];
}
create() {
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) { /* no music json */ }
this.buildParticleTexture();
this.buildTrainCarTexture();
this.buildBackdrop();
this.buildStaticBoard();
this.buildRightPanel();
@ -74,6 +76,22 @@ export default class TicketToRideGame extends Phaser.Scene {
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 ─────────────────────────────────────────
buildBackdrop() {
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'),
{ variant: 'ghost', width: 120, height: 40, fontSize: 17 }).setDepth(D.banner);
this._buildTurnTriangle();
}
// ── helpers ───────────────────────────────────────────────────────────────────
@ -413,13 +433,23 @@ export default class TicketToRideGame extends Phaser.Scene {
this.renderHover();
}
onRouteClick(id) {
async onRouteClick(id) {
if (!this.canHumanAct()) 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; }
const opts = L.paymentOptions(this.gs.players[0], ROUTES[id]);
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);
}
@ -432,7 +462,9 @@ export default class TicketToRideGame extends Phaser.Scene {
const a = AI.chooseAction(this.gs, seat);
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);
this.opponentPortraits[seat - 1]?.playEmotion?.('happy');
} else if (a.type === 'drawTickets') {
@ -528,6 +560,8 @@ export default class TicketToRideGame extends Phaser.Scene {
}
renderRoutes() {
this.trainCarObjs.forEach((o) => o.destroy());
this.trainCarObjs = [];
const g = this.routeGfx;
g.clear();
ROUTES.forEach((r, id) => {
@ -535,7 +569,14 @@ export default class TicketToRideGame extends Phaser.Scene {
const segs = this.segCache[id];
if (owner != null) {
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 {
const fill = CARD_COLOR_HEX[r.color] ?? CARD_COLOR_HEX.gray;
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.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) {
@ -788,11 +868,19 @@ export default class TicketToRideGame extends Phaser.Scene {
}).setOrigin(0.5).setDepth(D.modal + 2);
this.modalObjs.push(overlay, panel, title);
const finish = (payment) => {
const finish = async (payment) => {
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;
if (payment) this.applyHuman(L.claimRoute(this.gs, 0, routeId, payment), SFX.CARD_PLACE);
else this.advance();
this.advance();
} else {
this.busy = false;
this.advance();
}
};
options.forEach((opt, i) => {
@ -928,6 +1016,78 @@ export default class TicketToRideGame extends Phaser.Scene {
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) {
const mkStep = CARD_W + 10;
const targets = [];