feat: add opponent hand visualization and update AI draw animations

- Introduce mini face-down card fans below opponent portraits to show hand size.
- Modify AI draw animations to target specific slots within the mini hand fan.
- Adjust layout constants (OPP_STEP) and text positions to accommodate the new UI.
- Simplify opponent info text to remove redundant card counts.
This commit is contained in:
Brian Fertig 2026-05-30 12:52:41 -06:00
parent 402293fea2
commit ae6c69e25c
1 changed files with 51 additions and 6 deletions

View File

@ -28,8 +28,9 @@ const TTR_CARD_FRAME = {
// Right-band + bottom-strip layout (1920×1080). The map occupies x<1400. // Right-band + bottom-strip layout (1920×1080). The map occupies x<1400.
const RB = { x0: 1408, cx: 1660, w: 512 }; const RB = { x0: 1408, cx: 1660, w: 512 };
const OPP_COL1_X = 1450, OPP_COL2_X = 1706, OPP_Y0 = 150, OPP_STEP = 100, OPP_R = 32; const OPP_COL1_X = 1450, OPP_COL2_X = 1706, OPP_Y0 = 150, OPP_STEP = 150, OPP_R = 32;
const MK_X = 1476, MK_Y0 = 507, MK_STEP = 104, CARD_W = 78, CARD_H = 112; const MK_X = 1476, MK_Y0 = 507, MK_STEP = 104, CARD_W = 78, CARD_H = 112;
const MINI_W = 22, MINI_H = 32, MINI_STEP = 14;
const PILE_X = 1646; const PILE_X = 1646;
const BOT_Y = 980; const BOT_Y = 980;
@ -51,6 +52,8 @@ export default class TicketToRideGame extends Phaser.Scene {
this.playerScoreText = null; this.playerScoreText = null;
this.scoreBadgeContainers = []; this.scoreBadgeContainers = [];
this.oppScoreTexts = {}; this.oppScoreTexts = {};
this.oppMiniHandGfx = [];
this.oppMiniHandCount = [];
} }
create() { create() {
@ -196,6 +199,10 @@ export default class TicketToRideGame extends Phaser.Scene {
this.scoreBadgeContainers.forEach((c) => c.destroy()); this.scoreBadgeContainers.forEach((c) => c.destroy());
this.scoreBadgeContainers = []; this.scoreBadgeContainers = [];
this.oppScoreTexts = {}; this.oppScoreTexts = {};
this.oppMiniHandGfx.forEach((g) => g?.destroy());
this.oppMiniHandGfx = [];
this.oppMiniHandCount.forEach((t) => t?.destroy());
this.oppMiniHandCount = [];
// Player score badge (above human portrait in the bottom HUD) // Player score badge (above human portrait in the bottom HUD)
const pb = this._makeScoreBadge(80, BOT_Y - 64, this.playerColor(0).hex, 16); const pb = this._makeScoreBadge(80, BOT_Y - 64, this.playerColor(0).hex, 16);
@ -209,10 +216,10 @@ export default class TicketToRideGame extends Phaser.Scene {
this.opponentPortraits.push(portrait); this.opponentPortraits.push(portrait);
const col = PLAYER_COLORS[this.gs.players[seat].colorIndex]; const col = PLAYER_COLORS[this.gs.players[seat].colorIndex];
this.add.circle(x, y, OPP_R + 4, col.hex, 0).setStrokeStyle(3, col.hex, 0.95).setDepth(D.hud + 2); this.add.circle(x, y, OPP_R + 4, col.hex, 0).setStrokeStyle(3, col.hex, 0.95).setDepth(D.hud + 2);
this.add.text(x + OPP_R + 12, y - 18, this.pname(seat), { this.add.text(x + OPP_R + 12, y - 10, this.pname(seat), {
fontFamily: 'Righteous', fontSize: '15px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud + 1); }).setOrigin(0, 0.5).setDepth(D.hud + 1);
const info = this.add.text(x + OPP_R + 12, y + 6, '', { const info = this.add.text(x + OPP_R + 12, y + 10, '', {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex, lineSpacing: 2, fontFamily: '"Julius Sans One"', fontSize: '12px', color: COLORS.mutedHex, lineSpacing: 2,
}).setOrigin(0, 0.5).setDepth(D.hud + 1); }).setOrigin(0, 0.5).setDepth(D.hud + 1);
this.oppPanelText[seat] = info; this.oppPanelText[seat] = info;
@ -221,6 +228,16 @@ export default class TicketToRideGame extends Phaser.Scene {
const ob = this._makeScoreBadge(x, y - OPP_R - 14, col.hex, 13); const ob = this._makeScoreBadge(x, y - OPP_R - 14, col.hex, 13);
this.oppScoreTexts[seat] = ob.text; this.oppScoreTexts[seat] = ob.text;
this.scoreBadgeContainers.push(ob.container); this.scoreBadgeContainers.push(ob.container);
// Mini face-down hand fan below portrait
const fanX = x - OPP_R;
const fanY = y + OPP_R + 20;
const miniGfx = this.add.graphics().setDepth(D.hud + 1);
this.oppMiniHandGfx[seat] = miniGfx;
const miniCount = this.add.text(fanX + 86, fanY, '×0', {
fontFamily: 'Righteous', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.hud + 2);
this.oppMiniHandCount[seat] = miniCount;
} }
} }
@ -414,17 +431,22 @@ export default class TicketToRideGame extends Phaser.Scene {
// pendingTickets now set for this AI seat — advance() routes to aiResolveTickets. // pendingTickets now set for this AI seat — advance() routes to aiResolveTickets.
} else { // drawTrain } else { // drawTrain
const { x: dX, y: dY } = this.oppPos(seat); const { x: dX, y: dY } = this.oppPos(seat);
const fanX = dX - OPP_R;
const fanY = dY + OPP_R + 20;
// First draw // First draw
const faceUpColor1 = a.source === 'faceUp' ? this.gs.faceUp[a.index] : null; const faceUpColor1 = a.source === 'faceUp' ? this.gs.faceUp[a.index] : null;
const srcX1 = a.source === 'faceUp' ? MK_X : PILE_X; const srcX1 = a.source === 'faceUp' ? MK_X : PILE_X;
const srcY1 = a.source === 'faceUp' ? MK_Y0 + a.index * (CARD_W + 10) : MK_Y0; const srcY1 = a.source === 'faceUp' ? MK_Y0 + a.index * (CARD_W + 10) : MK_Y0;
this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index); this.gs = L.drawTrainCard(this.gs, seat, a.source, a.index);
const slot1 = Math.min(L.handCount(this.gs.players[seat]) - 1, 4);
playSound(this, SFX.CARD_DEAL); playSound(this, SFX.CARD_DEAL);
this.renderAll(); this.renderAll();
await this.animateCardDraw({ await this.animateCardDraw({
srcX: srcX1, srcY: srcY1, srcAngle: -90, srcX: srcX1, srcY: srcY1, srcAngle: -90,
cardFrame: faceUpColor1 ? TTR_CARD_FRAME[faceUpColor1] : TTR_CARD_FRAME.back, cardFrame: faceUpColor1 ? TTR_CARD_FRAME[faceUpColor1] : TTR_CARD_FRAME.back,
shouldFlip: false, destX: dX, destY: dY, destW: 44, destH: 64, shouldFlip: false,
destX: fanX + slot1 * MINI_STEP + MINI_W / 2, destY: fanY,
destW: MINI_W, destH: MINI_H,
}); });
// Second draw if the turn is still in progress. // Second draw if the turn is still in progress.
if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound') if ((this.gs.phase === 'turn' || this.gs.phase === 'lastRound')
@ -435,12 +457,15 @@ export default class TicketToRideGame extends Phaser.Scene {
const srcX2 = b.source === 'faceUp' ? MK_X : PILE_X; const srcX2 = b.source === 'faceUp' ? MK_X : PILE_X;
const srcY2 = b.source === 'faceUp' ? MK_Y0 + b.index * (CARD_W + 10) : MK_Y0; const srcY2 = b.source === 'faceUp' ? MK_Y0 + b.index * (CARD_W + 10) : MK_Y0;
this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index); this.gs = L.drawTrainCard(this.gs, seat, b.source, b.index);
const slot2 = Math.min(L.handCount(this.gs.players[seat]) - 1, 4);
playSound(this, SFX.CARD_DEAL); playSound(this, SFX.CARD_DEAL);
this.renderAll(); this.renderAll();
await this.animateCardDraw({ await this.animateCardDraw({
srcX: srcX2, srcY: srcY2, srcAngle: -90, srcX: srcX2, srcY: srcY2, srcAngle: -90,
cardFrame: faceUpColor2 ? TTR_CARD_FRAME[faceUpColor2] : TTR_CARD_FRAME.back, cardFrame: faceUpColor2 ? TTR_CARD_FRAME[faceUpColor2] : TTR_CARD_FRAME.back,
shouldFlip: false, destX: dX, destY: dY, destW: 44, destH: 64, shouldFlip: false,
destX: fanX + slot2 * MINI_STEP + MINI_W / 2, destY: fanY,
destW: MINI_W, destH: MINI_H,
}); });
} }
} }
@ -576,7 +601,7 @@ export default class TicketToRideGame extends Phaser.Scene {
const p = this.gs.players[seat]; const p = this.gs.players[seat];
const info = this.oppPanelText[seat]; const info = this.oppPanelText[seat];
if (info) { if (info) {
info.setText(`Trains ${p.trainsLeft} Cards ${L.handCount(p)}\nTickets ${p.tickets.length}`); info.setText(`Trains ${p.trainsLeft}\nTickets ${p.tickets.length}`);
} }
this.oppScoreTexts[seat]?.setText(String(L.publicScore(this.gs, seat))); this.oppScoreTexts[seat]?.setText(String(L.publicScore(this.gs, seat)));
if (this.gs.currentPlayer === seat) { if (this.gs.currentPlayer === seat) {
@ -584,6 +609,26 @@ export default class TicketToRideGame extends Phaser.Scene {
this.oppRingGfx.lineStyle(4, COLORS.goldHex, 1); this.oppRingGfx.lineStyle(4, COLORS.goldHex, 1);
this.oppRingGfx.strokeCircle(x, y, OPP_R + 8); this.oppRingGfx.strokeCircle(x, y, OPP_R + 8);
} }
// Mini hand fan
const gfx = this.oppMiniHandGfx[seat];
if (gfx) {
const { x, y } = this.oppPos(seat);
const fanX = x - OPP_R;
const fanY = y + OPP_R + 20;
gfx.clear();
const count = L.handCount(p);
const n = Math.min(count, 5);
for (let c = 0; c < n; c++) {
const cx = fanX + c * MINI_STEP;
gfx.fillStyle(0x1a2750, 1);
gfx.fillRoundedRect(cx, fanY - MINI_H / 2, MINI_W, MINI_H, 3);
gfx.lineStyle(1.5, 0x8aaad4, 0.9);
gfx.strokeRoundedRect(cx, fanY - MINI_H / 2, MINI_W, MINI_H, 3);
gfx.lineStyle(1, 0xfdf3d8, 0.12);
gfx.strokeRoundedRect(cx + 2, fanY - MINI_H / 2 + 2, MINI_W - 4, MINI_H - 4, 2);
}
this.oppMiniHandCount[seat]?.setText(`×${count}`);
}
} }
} }