feat: upgrade Ticket to Ride UI with card sprites and improved layout

- Replace procedural card drawing with spritesheet images for better visuals
- Rotate cards to landscape orientation and adjust right-panel dimensions
- Reorganize opponent panels into a two-column grid for better screen utilization
- Add circular score badges above player and opponent portraits
- Display actual top card on discard pile instead of generic face-down graphic
- Move game log to bottom-right corner
- Update PreloadScene to load new card assets
This commit is contained in:
Brian Fertig 2026-05-30 12:05:20 -06:00
parent 975d40b4b0
commit 8222b61f0a
2 changed files with 87 additions and 71 deletions

View File

@ -21,11 +21,16 @@ const D = {
const HAND_ORDER = [...TRAIN_COLORS, 'locomotive']; const HAND_ORDER = [...TRAIN_COLORS, 'locomotive'];
const TTR_CARD_FRAME = {
red: 0, orange: 1, yellow: 2, green: 3, blue: 4,
purple: 5, black: 6, white: 7, locomotive: 8, back: 9,
};
// 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_X = 1452, OPP_Y0 = 70, OPP_STEP = 96, OPP_R = 32; const OPP_COL1_X = 1450, OPP_COL2_X = 1706, OPP_Y0 = 150, OPP_STEP = 100, OPP_R = 32;
const MK_X = 1470, MK_Y0 = 392, MK_STEP = 86, CARD_W = 78, CARD_H = 74; const MK_X = 1476, MK_Y0 = 507, MK_STEP = 104, CARD_W = 78, CARD_H = 112;
const PILE_X = 1640; const PILE_X = 1646;
const BOT_Y = 980; const BOT_Y = 980;
export default class TicketToRideGame extends Phaser.Scene { export default class TicketToRideGame extends Phaser.Scene {
@ -43,6 +48,9 @@ export default class TicketToRideGame extends Phaser.Scene {
this.handObjs = []; this.handObjs = [];
this.hoverRouteId = null; this.hoverRouteId = null;
this.bannerShownFor = -1; this.bannerShownFor = -1;
this.playerScoreText = null;
this.scoreBadgeContainers = [];
this.oppScoreTexts = {};
} }
create() { create() {
@ -133,25 +141,25 @@ export default class TicketToRideGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex, fontFamily: 'Righteous', fontSize: '20px', color: COLORS.accentHex,
}).setOrigin(0, 0.5).setDepth(D.hud); }).setOrigin(0, 0.5).setDepth(D.hud);
// Draw pile (face-down) — clickable. // Draw pile (face-down, rotated landscape) — clickable.
this.deckCard = this.add.graphics().setDepth(D.hud); this.deckCard = this.add.image(PILE_X, MK_Y0, 'ttr-cards', TTR_CARD_FRAME.back)
this.drawPileFace(this.deckCard, PILE_X, MK_Y0, 'Deck'); .setDisplaySize(CARD_W, CARD_H).setAngle(-90).setDepth(D.hud);
this.deckCount = this.add.text(PILE_X, MK_Y0 + 4, '', { this.deckCount = this.add.text(PILE_X, MK_Y0 + 4, '', {
fontFamily: 'Righteous', fontSize: '24px', color: '#fdf3d8', fontFamily: 'Righteous', fontSize: '24px', color: '#fdf3d8',
}).setOrigin(0.5).setDepth(D.hud + 1); }).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(PILE_X, MK_Y0 - CARD_H / 2 - 14, 'Draw', { this.add.text(PILE_X, MK_Y0 - CARD_W / 2 - 14, 'Draw', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud); }).setOrigin(0.5).setDepth(D.hud);
this.makeZone(PILE_X, MK_Y0, CARD_W, CARD_H, () => this.onDeckClick()); this.makeZone(PILE_X, MK_Y0, CARD_H, CARD_W, () => this.onDeckClick());
// Discard pile (display only). // Discard pile (display only, rotated landscape).
const discY = MK_Y0 + MK_STEP * 1.5; const discY = MK_Y0 + MK_STEP * 1.5;
this.discardCard = this.add.graphics().setDepth(D.hud); this.discardCard = this.add.image(PILE_X, discY, 'ttr-cards', TTR_CARD_FRAME.back)
this.drawPileFace(this.discardCard, PILE_X, discY, 'Discard', 0x3a2f22); .setDisplaySize(CARD_W, CARD_H).setAngle(-90).setAlpha(0.3).setDepth(D.hud);
this.discardCount = this.add.text(PILE_X, discY + 4, '', { this.discardCount = this.add.text(PILE_X, discY + 4, '', {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex, fontFamily: 'Righteous', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud + 1); }).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(PILE_X, discY - CARD_H / 2 - 14, 'Discard', { this.add.text(PILE_X, discY - CARD_W / 2 - 14, 'Discard', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud); }).setOrigin(0.5).setDepth(D.hud);
@ -172,13 +180,6 @@ export default class TicketToRideGame extends Phaser.Scene {
this.makeZone(PILE_X, tY, CARD_W, CARD_H, () => this.onTicketDeckClick()); this.makeZone(PILE_X, tY, CARD_W, CARD_H, () => this.onTicketDeckClick());
} }
drawPileFace(g, x, y, _label, fill = 0x243b52) {
g.fillStyle(fill, 1);
g.fillRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
g.lineStyle(3, 0xfdf3d8, 0.85);
g.strokeRoundedRect(x - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8);
}
makeZone(x, y, w, h, fn) { makeZone(x, y, w, h, fn) {
const z = this.add.zone(x, y, w, h).setInteractive({ useHandCursor: true }).setDepth(D.hud + 4); const z = this.add.zone(x, y, w, h).setInteractive({ useHandCursor: true }).setDepth(D.hud + 4);
z.on('pointerdown', fn); z.on('pointerdown', fn);
@ -188,28 +189,61 @@ export default class TicketToRideGame extends Phaser.Scene {
buildOpponentPanels() { buildOpponentPanels() {
this.opponentPortraits.forEach((p) => p.destroy?.()); this.opponentPortraits.forEach((p) => p.destroy?.());
this.opponentPortraits = []; this.opponentPortraits = [];
this.oppPanelText.forEach((t) => t.destroy()); this.oppPanelText.forEach((t) => t?.destroy());
this.oppPanelText = []; this.oppPanelText = [];
this.oppRingGfx?.destroy(); this.oppRingGfx?.destroy();
this.oppRingGfx = this.add.graphics().setDepth(D.hud + 3); this.oppRingGfx = this.add.graphics().setDepth(D.hud + 3);
this.scoreBadgeContainers.forEach((c) => c.destroy());
this.scoreBadgeContainers = [];
this.oppScoreTexts = {};
// Player score badge (above human portrait in the bottom HUD)
const pb = this._makeScoreBadge(80, BOT_Y - 64, this.playerColor(0).hex, 16);
this.playerScoreText = pb.text;
this.scoreBadgeContainers.push(pb.container);
for (let seat = 1; seat < this.gs.playerCount; seat++) { for (let seat = 1; seat < this.gs.playerCount; seat++) {
const y = OPP_Y0 + (seat - 1) * OPP_STEP; const { x, y } = this.oppPos(seat);
const opp = this.opponents[seat - 1]; const opp = this.opponents[seat - 1];
const portrait = createOpponentPortrait(this, opp, OPP_X, y, OPP_R, D.hud, { playIntro: seat === 1 }); const portrait = createOpponentPortrait(this, opp, x, y, OPP_R, D.hud, { playIntro: seat === 1 });
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(OPP_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(OPP_X + OPP_R + 16, y - 20, this.pname(seat), { this.add.text(x + OPP_R + 12, y - 18, this.pname(seat), {
fontFamily: 'Righteous', fontSize: '17px', 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(OPP_X + OPP_R + 16, y + 6, '', { const info = this.add.text(x + OPP_R + 12, y + 6, '', {
fontFamily: '"Julius Sans One"', fontSize: '13px', 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;
// Score badge above opponent portrait
const ob = this._makeScoreBadge(x, y - OPP_R - 14, col.hex, 13);
this.oppScoreTexts[seat] = ob.text;
this.scoreBadgeContainers.push(ob.container);
} }
} }
_makeScoreBadge(x, y, color, radius = 16) {
const c = this.add.container(x, y).setDepth(D.hud + 3);
const g = this.add.graphics();
g.fillStyle(color, 1);
g.fillCircle(0, 0, radius);
g.lineStyle(2, 0xffffff, 0.35);
g.strokeCircle(0, 0, radius);
const t = this.add.text(0, 0, '0', {
fontFamily: 'Righteous', fontSize: radius > 14 ? '13px' : '11px', color: '#ffffff',
}).setOrigin(0.5);
c.add([g, t]);
return { container: c, text: t };
}
oppPos(seat) {
const row = Math.floor((seat - 1) / 2);
const col = (seat - 1) % 2;
return { x: col === 0 ? OPP_COL1_X : OPP_COL2_X, y: OPP_Y0 + row * OPP_STEP };
}
// ── bottom HUD: human portrait, hand, status, leave ─────────────────────────── // ── bottom HUD: human portrait, hand, status, leave ───────────────────────────
buildHUD() { buildHUD() {
this.add.rectangle(GAME_WIDTH / 2, BOT_Y + 30, GAME_WIDTH, 200, COLORS.panel, 0.92).setDepth(D.hud - 1); this.add.rectangle(GAME_WIDTH / 2, BOT_Y + 30, GAME_WIDTH, 200, COLORS.panel, 0.92).setDepth(D.hud - 1);
@ -219,13 +253,6 @@ export default class TicketToRideGame extends Phaser.Scene {
this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', { this.add.text(80, BOT_Y + 60, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud); }).setOrigin(0.5).setDepth(D.hud);
this.playerScoreText = this.add.text(80, BOT_Y - 64, '0', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(80, BOT_Y - 90, 'SCORE', {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud + 1);
this.add.text(180, BOT_Y - 70, 'Your Trains', { this.add.text(180, BOT_Y - 70, 'Your Trains', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.hud); }).setOrigin(0, 0.5).setDepth(D.hud);
@ -241,9 +268,10 @@ export default class TicketToRideGame extends Phaser.Scene {
backgroundColor: '#111923cc', padding: { x: 18, y: 8 }, backgroundColor: '#111923cc', padding: { x: 18, y: 8 },
}).setOrigin(0.5).setDepth(D.banner); }).setOrigin(0.5).setDepth(D.banner);
this.logText = this.add.text(GAME_WIDTH / 2, 1068, '', { this.logText = this.add.text(1760, BOT_Y + 32, '', {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1).setDepth(D.hud); wordWrap: { width: 300 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(D.hud);
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);
@ -442,61 +470,44 @@ export default class TicketToRideGame extends Phaser.Scene {
this.marketObjs.forEach((o) => o.destroy()); this.marketObjs.forEach((o) => o.destroy());
this.marketObjs = []; this.marketObjs = [];
const myTurn = this.canHumanAct(); const myTurn = this.canHumanAct();
const mkStep = CARD_W + 10; // rotated visual height + padding
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const y = MK_Y0 + i * MK_STEP; const y = MK_Y0 + i * mkStep;
const color = this.gs.faceUp[i]; const color = this.gs.faceUp[i];
if (color == null) { if (color == null) {
const g = this.add.graphics().setDepth(D.market); const g = this.add.graphics().setDepth(D.market);
g.lineStyle(2, COLORS.mutedHex, 0.5); g.lineStyle(2, COLORS.mutedHex, 0.5);
g.strokeRoundedRect(MK_X - CARD_W / 2, y - CARD_H / 2, CARD_W, CARD_H, 8); g.strokeRoundedRect(MK_X - CARD_H / 2, y - CARD_W / 2, CARD_H, CARD_W, 8);
this.marketObjs.push(g); this.marketObjs.push(g);
continue; continue;
} }
this.marketObjs.push(...this.makeCardFace(MK_X, y, CARD_W, CARD_H, color, D.market, '')); const objs = this.makeCardFace(MK_X, y, CARD_W, CARD_H, color, D.market, '');
const z = this.add.zone(MK_X, y, CARD_W, CARD_H).setDepth(D.market + 2); objs.forEach((o) => o.setAngle(-90));
this.marketObjs.push(...objs);
const z = this.add.zone(MK_X, y, CARD_H, CARD_W).setDepth(D.market + 2);
if (myTurn) z.setInteractive({ useHandCursor: true }); if (myTurn) z.setInteractive({ useHandCursor: true });
z.on('pointerdown', () => this.onMarketClick(i)); z.on('pointerdown', () => this.onMarketClick(i));
this.marketObjs.push(z); this.marketObjs.push(z);
} }
} }
// Returns the display objects for a card face (graphics + label text). makeCardFace(x, y, w, h, colorKey, depth, _label) {
makeCardFace(x, y, w, h, colorKey, depth, label) { const frame = TTR_CARD_FRAME[colorKey] ?? TTR_CARD_FRAME.back;
const objs = []; return [this.add.image(x, y, 'ttr-cards', frame).setDisplaySize(w, h).setDepth(depth)];
const g = this.add.graphics().setDepth(depth);
const fill = CARD_COLOR_HEX[colorKey] ?? 0x888888;
g.fillStyle(fill, 1);
g.fillRoundedRect(x - w / 2, y - h / 2, w, h, 8);
if (colorKey === 'locomotive') {
// rainbow accent stripe for the wild card
const cols = [0xd23b3b, 0xe0b000, 0x2e7d32, 0x2d6cdf, 0x8e44ad];
cols.forEach((c, i) => {
g.fillStyle(c, 0.9);
g.fillRect(x - w / 2 + 6 + i * ((w - 12) / cols.length), y - 8, (w - 12) / cols.length, 16);
});
}
g.lineStyle(2.5, 0xfdf3d8, 0.9);
g.strokeRoundedRect(x - w / 2, y - h / 2, w, h, 8);
objs.push(g);
const txt = colorKey === 'locomotive' ? 'LOCO' : CARD_LABEL[colorKey];
objs.push(this.add.text(x, y + h / 2 - 12, label || txt, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: this.textColorFor(colorKey),
}).setOrigin(0.5).setDepth(depth + 1));
return objs;
} }
renderHand() { renderHand() {
this.handObjs.forEach((o) => o.destroy()); this.handObjs.forEach((o) => o.destroy());
this.handObjs = []; this.handObjs = [];
const hand = this.gs.players[0].hand; const hand = this.gs.players[0].hand;
const w = 66, h = 84, step = 88, x0 = 400; const h = 140, w = 97, step = 105, x0 = 400, cy = 1000;
HAND_ORDER.forEach((colorKey, i) => { HAND_ORDER.forEach((colorKey, i) => {
const x = x0 + i * step; const x = x0 + i * step;
const count = hand[colorKey]; const count = hand[colorKey];
const objs = this.makeCardFace(x, BOT_Y + 2, w, h, colorKey, D.hud, ''); const objs = this.makeCardFace(x, cy, w, h, colorKey, D.hud, '');
if (count === 0) objs.forEach((o) => o.setAlpha(0.3)); if (count === 0) objs.forEach((o) => o.setAlpha(0.3));
this.handObjs.push(...objs); this.handObjs.push(...objs);
this.handObjs.push(this.add.text(x, BOT_Y - h / 2 - 8, `×${count}`, { this.handObjs.push(this.add.text(x, cy - h / 2 - 8, `×${count}`, {
fontFamily: 'Righteous', fontSize: '18px', color: count > 0 ? COLORS.textHex : COLORS.mutedHex, fontFamily: 'Righteous', fontSize: '18px', color: count > 0 ? COLORS.textHex : COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud + 1)); }).setOrigin(0.5).setDepth(D.hud + 1));
}); });
@ -504,12 +515,15 @@ export default class TicketToRideGame extends Phaser.Scene {
renderPiles() { renderPiles() {
this.deckCount.setText(String(this.gs.trainDeck.length)); this.deckCount.setText(String(this.gs.trainDeck.length));
const topCard = this.gs.discard.at(-1) ?? null;
this.discardCard.setFrame(topCard ? (TTR_CARD_FRAME[topCard] ?? TTR_CARD_FRAME.back) : TTR_CARD_FRAME.back);
this.discardCard.setAlpha(topCard ? 1 : 0.3);
this.discardCount.setText(String(this.gs.discard.length)); this.discardCount.setText(String(this.gs.discard.length));
this.ticketCount.setText(`${this.gs.ticketDeck.length} left`); this.ticketCount.setText(`${this.gs.ticketDeck.length} left`);
} }
renderPanels() { renderPanels() {
this.playerScoreText.setText(String(L.publicScore(this.gs, 0))); this.playerScoreText?.setText(String(L.publicScore(this.gs, 0)));
this.trainsText.setText(String(this.gs.players[0].trainsLeft)); this.trainsText.setText(String(this.gs.players[0].trainsLeft));
this.oppRingGfx.clear(); this.oppRingGfx.clear();
@ -517,12 +531,13 @@ 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} Score ${L.publicScore(this.gs, seat)}`); info.setText(`Trains ${p.trainsLeft} Cards ${L.handCount(p)}\nTickets ${p.tickets.length}`);
} }
this.oppScoreTexts[seat]?.setText(String(L.publicScore(this.gs, seat)));
if (this.gs.currentPlayer === seat) { if (this.gs.currentPlayer === seat) {
const y = OPP_Y0 + (seat - 1) * OPP_STEP; const { x, y } = this.oppPos(seat);
this.oppRingGfx.lineStyle(4, COLORS.goldHex, 1); this.oppRingGfx.lineStyle(4, COLORS.goldHex, 1);
this.oppRingGfx.strokeCircle(OPP_X, y, OPP_R + 8); this.oppRingGfx.strokeCircle(x, y, OPP_R + 8);
} }
} }
} }

View File

@ -73,6 +73,7 @@ export default class PreloadScene extends Phaser.Scene {
// Prosperity expansion art (frame order documented in expansions/prosperity.js). // Prosperity expansion art (frame order documented in expansions/prosperity.js).
// Optional — same procedural fallback applies when the sheet is absent. // Optional — same procedural fallback applies when the sheet is absent.
this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 }); this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('ttr-cards', '/assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
} }
async create() { async create() {