diff --git a/public/src/games/tickettoride/TicketToRideGame.js b/public/src/games/tickettoride/TicketToRideGame.js index 8e8e538..ec1594b 100644 --- a/public/src/games/tickettoride/TicketToRideGame.js +++ b/public/src/games/tickettoride/TicketToRideGame.js @@ -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 = [];