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:
parent
97befdb502
commit
2521146579
|
|
@ -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();
|
||||
this.busy = false;
|
||||
if (payment) this.applyHuman(L.claimRoute(this.gs, 0, routeId, payment), SFX.CARD_PLACE);
|
||||
else this.advance();
|
||||
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.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 = [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue