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.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 = [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue