feat: polish Ticket to Ride visuals and add animated game menu tabs

- Introduce muted palette and stroke outlines for unclaimed route segments so claimed routes pop
- Refactor busy state handling to toggle dim overlay and suppress hand render flicker during animations
- Improve modal layout using right-band centering, responsive font sizing, and word wrapping
- Block input briefly after modal close to prevent accidental clicks
- Add animated tab icons to the game menu with scale transitions and pulsing glow effects
- Load new tab-icons assets in the preload scene
This commit is contained in:
Brian Fertig 2026-05-30 15:09:03 -06:00
parent 2521146579
commit eca8013fd4
5 changed files with 147 additions and 39 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

View File

@ -26,6 +26,19 @@ const TTR_CARD_FRAME = {
purple: 5, black: 6, white: 7, locomotive: 8, back: 9, purple: 5, black: 6, white: 7, locomotive: 8, back: 9,
}; };
// Muted palette for unclaimed route segments — desaturated so claimed (player-coloured) routes pop.
const TTR_ROUTE_HEX = {
red: 0x8c5050, // dusty brick
orange: 0x8c6030, // muted amber
yellow: 0x7a6820, // olive
green: 0x4a6840, // sage
blue: 0x3a5470, // slate
purple: 0x5a3a6c, // dusty plum
black: 0x383838, // dark charcoal
white: 0xd4cfc8, // bright warm gray
gray: 0x706860, // neutral warm gray
};
// 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 = 150, OPP_R = 32; const OPP_COL1_X = 1450, OPP_COL2_X = 1706, OPP_Y0 = 150, OPP_STEP = 150, OPP_R = 32;
@ -42,7 +55,8 @@ export default class TicketToRideGame extends Phaser.Scene {
this.opponents = data.opponents ?? []; this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null; this.playfield = data.playfield ?? null;
this.gs = null; this.gs = null;
this.busy = false; this._setBusy(false);
this._suppressHandRender = false;
this.opponentPortraits = []; this.opponentPortraits = [];
this.modalObjs = []; this.modalObjs = [];
this.marketObjs = []; this.marketObjs = [];
@ -79,14 +93,14 @@ export default class TicketToRideGame extends Phaser.Scene {
buildTrainCarTexture() { buildTrainCarTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false }); 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(0xffffff, 1); g.fillRoundedRect(1, 3, 118, 34, 4); // body
g.fillStyle(0xdddddd, 1); g.fillRect(5, 3, 110, 9); // roof g.fillStyle(0xffffff, 1); g.fillRect(5, 3, 110, 9); // roof (white = full tint brightness)
g.fillStyle(0x181818, 1); g.fillRect(14, 14, 92, 12); // window glass g.fillStyle(0x181818, 1); g.fillRect(14, 14, 92, 12); // window glass (dark contrast detail)
g.fillStyle(0xdddddd, 1); g.fillRect(42, 14, 4, 12); // mullion 1 g.fillStyle(0xffffff, 1); g.fillRect(42, 14, 4, 12); // mullion 1
g.fillRect(74, 14, 4, 12); // mullion 2 g.fillRect(74, 14, 4, 12); // mullion 2
g.fillStyle(0xaaaaaa, 1); g.fillRoundedRect(10, 30, 22, 9, 2); // left truck g.fillStyle(0xeeeeee, 1); g.fillRoundedRect(10, 30, 22, 9, 2); // left truck
g.fillRoundedRect(88, 30, 22, 9, 2); // right 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(30, 29, 60, 4); // underframe
g.fillStyle(0xeeeeee, 1); g.fillRect(1, 13, 3, 14); // left end cap g.fillStyle(0xffffff, 1); g.fillRect(1, 13, 3, 14); // left end cap
g.fillRect(116, 13, 3, 14); // right end cap g.fillRect(116, 13, 3, 14); // right end cap
g.generateTexture('ttr-train-car', 120, 40); g.generateTexture('ttr-train-car', 120, 40);
g.destroy(); g.destroy();
@ -122,6 +136,7 @@ export default class TicketToRideGame extends Phaser.Scene {
this.midCache = ROUTES.map((r) => routeMidpoint(r)); this.midCache = ROUTES.map((r) => routeMidpoint(r));
this.routeGfx = this.add.graphics().setDepth(D.route); this.routeGfx = this.add.graphics().setDepth(D.route);
this.trainStrokeGfx = this.add.graphics().setDepth(D.train + 1);
this.hoverGfx = this.add.graphics().setDepth(D.hover); this.hoverGfx = this.add.graphics().setDepth(D.hover);
// Invisible rotated hit-zone per route. // Invisible rotated hit-zone per route.
@ -200,6 +215,17 @@ export default class TicketToRideGame extends Phaser.Scene {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud); }).setOrigin(0.5).setDepth(D.hud);
this.makeZone(PILE_X, tY, CARD_W, CARD_H, () => this.onTicketDeckClick()); this.makeZone(PILE_X, tY, CARD_W, CARD_H, () => this.onTicketDeckClick());
const dimTop = MK_Y0 - 70;
const dimBot = MK_Y0 + MK_STEP * 3 + CARD_H / 2 + 30;
this.drawAreaDimOverlay = this.add.rectangle(
RB.cx, (dimTop + dimBot) / 2, RB.w, dimBot - dimTop, 0x000000, 0.55
).setDepth(D.hud + 5).setVisible(false);
}
_setBusy(val) {
this.busy = val;
this.drawAreaDimOverlay?.setVisible(val);
} }
makeZone(x, y, w, h, fn) { makeZone(x, y, w, h, fn) {
@ -325,7 +351,7 @@ export default class TicketToRideGame extends Phaser.Scene {
// ── new match / turn driver ─────────────────────────────────────────────────── // ── new match / turn driver ───────────────────────────────────────────────────
startNewMatch() { startNewMatch() {
this.busy = false; this._setBusy(false);
this.closeModal(); this.closeModal();
const playerCount = Math.min(5, Math.max(2, 1 + this.opponents.length)); const playerCount = Math.min(5, Math.max(2, 1 + this.opponents.length));
this.gs = L.createInitialState(playerCount); this.gs = L.createInitialState(playerCount);
@ -382,7 +408,8 @@ export default class TicketToRideGame extends Phaser.Scene {
const refillHappened = newGs.faceUp.length === origFaceUpLength; const refillHappened = newGs.faceUp.length === origFaceUpLength;
const refillSlot = origFaceUpLength - 1; const refillSlot = origFaceUpLength - 1;
const refillColor = refillHappened ? newGs.faceUp[refillSlot] : undefined; const refillColor = refillHappened ? newGs.faceUp[refillSlot] : undefined;
this.busy = true; this._suppressHandRender = true;
this._setBusy(true);
this.gs = newGs; this.gs = newGs;
this.marketSlotObjs[i]?.forEach((o) => o.setVisible(false)); this.marketSlotObjs[i]?.forEach((o) => o.setVisible(false));
this.renderAllExceptMarket(); this.renderAllExceptMarket();
@ -394,8 +421,9 @@ export default class TicketToRideGame extends Phaser.Scene {
}); });
await this.animateMarketSlideUp(i, origFaceUpLength - 1); await this.animateMarketSlideUp(i, origFaceUpLength - 1);
if (refillColor !== undefined) await this.animateRefillMarketSlot(refillSlot, refillColor); if (refillColor !== undefined) await this.animateRefillMarketSlot(refillSlot, refillColor);
this._suppressHandRender = false;
this.renderAll(); this.renderAll();
this.busy = false; this._setBusy(false);
this.advance(); this.advance();
} }
@ -404,7 +432,8 @@ export default class TicketToRideGame extends Phaser.Scene {
if (!L.canDrawTrains(this.gs)) { this.flash('No cards left to draw'); return; } if (!L.canDrawTrains(this.gs)) { this.flash('No cards left to draw'); return; }
const newGs = L.drawTrainCard(this.gs, 0, 'deck'); const newGs = L.drawTrainCard(this.gs, 0, 'deck');
const drawnColor = HAND_ORDER.find((k) => newGs.players[0].hand[k] > this.gs.players[0].hand[k]); const drawnColor = HAND_ORDER.find((k) => newGs.players[0].hand[k] > this.gs.players[0].hand[k]);
this.busy = true; this._suppressHandRender = true;
this._setBusy(true);
this.gs = newGs; this.gs = newGs;
this.renderAll(); this.renderAll();
playSound(this, SFX.CARD_DEAL); playSound(this, SFX.CARD_DEAL);
@ -413,7 +442,8 @@ export default class TicketToRideGame extends Phaser.Scene {
srcX: PILE_X, srcY: MK_Y0, srcAngle: -90, cardFrame: TTR_CARD_FRAME[drawnColor], srcX: PILE_X, srcY: MK_Y0, srcAngle: -90, cardFrame: TTR_CARD_FRAME[drawnColor],
shouldFlip: true, destX: dest.x, destY: dest.y, destW: 97, destH: 140, shouldFlip: true, destX: dest.x, destY: dest.y, destW: 97, destH: 140,
}); });
this.busy = false; this._suppressHandRender = false;
this._setBusy(false);
this.advance(); this.advance();
} }
@ -442,11 +472,11 @@ export default class TicketToRideGame extends Phaser.Scene {
if (opts.length === 1) { if (opts.length === 1) {
const payment = opts[0]; const payment = opts[0];
const newGs = L.claimRoute(this.gs, 0, id, payment); const newGs = L.claimRoute(this.gs, 0, id, payment);
this.busy = true; this._setBusy(true);
await this.animateClaimRoute(0, id, payment); await this.animateClaimRoute(0, id, payment);
this.gs = newGs; this.gs = newGs;
playSound(this, SFX.CARD_PLACE); playSound(this, SFX.CARD_PLACE);
this.busy = false; this._setBusy(false);
this.advance(); this.advance();
return; return;
} }
@ -455,7 +485,7 @@ export default class TicketToRideGame extends Phaser.Scene {
// ── AI driver ───────────────────────────────────────────────────────────────── // ── AI driver ─────────────────────────────────────────────────────────────────
async aiTurn() { async aiTurn() {
this.busy = true; this._setBusy(true);
const seat = this.gs.currentPlayer; const seat = this.gs.currentPlayer;
this.showTurnBanner(`${this.pname(seat)}'s Turn`); this.showTurnBanner(`${this.pname(seat)}'s Turn`);
await this.delay(650); await this.delay(650);
@ -486,6 +516,7 @@ export default class TicketToRideGame extends Phaser.Scene {
const refillHappened1 = firstFaceUp && this.gs.faceUp.length === origFaceUpLen1; const refillHappened1 = firstFaceUp && this.gs.faceUp.length === origFaceUpLen1;
const refillColor1 = refillHappened1 ? this.gs.faceUp[origFaceUpLen1 - 1] : undefined; const refillColor1 = refillHappened1 ? this.gs.faceUp[origFaceUpLen1 - 1] : undefined;
playSound(this, SFX.CARD_DEAL); playSound(this, SFX.CARD_DEAL);
this._suppressHandRender = true;
if (firstFaceUp) this.renderAllExceptMarket(); else this.renderAll(); if (firstFaceUp) this.renderAllExceptMarket(); else this.renderAll();
await this.animateCardDraw({ await this.animateCardDraw({
srcX: srcX1, srcY: srcY1, srcAngle: -90, srcX: srcX1, srcY: srcY1, srcAngle: -90,
@ -494,6 +525,7 @@ export default class TicketToRideGame extends Phaser.Scene {
destX: fanX + slot1 * MINI_STEP + MINI_W / 2, destY: fanY, destX: fanX + slot1 * MINI_STEP + MINI_W / 2, destY: fanY,
destW: MINI_W, destH: MINI_H, destW: MINI_W, destH: MINI_H,
}); });
this._suppressHandRender = false;
if (firstFaceUp) { if (firstFaceUp) {
await this.animateMarketSlideUp(a.index, origFaceUpLen1 - 1); await this.animateMarketSlideUp(a.index, origFaceUpLen1 - 1);
if (refillColor1 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen1 - 1, refillColor1); if (refillColor1 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen1 - 1, refillColor1);
@ -515,6 +547,7 @@ export default class TicketToRideGame extends Phaser.Scene {
const refillHappened2 = secondFaceUp && this.gs.faceUp.length === origFaceUpLen2; const refillHappened2 = secondFaceUp && this.gs.faceUp.length === origFaceUpLen2;
const refillColor2 = refillHappened2 ? this.gs.faceUp[origFaceUpLen2 - 1] : undefined; const refillColor2 = refillHappened2 ? this.gs.faceUp[origFaceUpLen2 - 1] : undefined;
playSound(this, SFX.CARD_DEAL); playSound(this, SFX.CARD_DEAL);
this._suppressHandRender = true;
if (secondFaceUp) this.renderAllExceptMarket(); else this.renderAll(); if (secondFaceUp) this.renderAllExceptMarket(); else this.renderAll();
await this.animateCardDraw({ await this.animateCardDraw({
srcX: srcX2, srcY: srcY2, srcAngle: -90, srcX: srcX2, srcY: srcY2, srcAngle: -90,
@ -523,6 +556,7 @@ export default class TicketToRideGame extends Phaser.Scene {
destX: fanX + slot2 * MINI_STEP + MINI_W / 2, destY: fanY, destX: fanX + slot2 * MINI_STEP + MINI_W / 2, destY: fanY,
destW: MINI_W, destH: MINI_H, destW: MINI_W, destH: MINI_H,
}); });
this._suppressHandRender = false;
if (secondFaceUp) { if (secondFaceUp) {
await this.animateMarketSlideUp(b.index, origFaceUpLen2 - 1); await this.animateMarketSlideUp(b.index, origFaceUpLen2 - 1);
if (refillColor2 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen2 - 1, refillColor2); if (refillColor2 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen2 - 1, refillColor2);
@ -533,17 +567,17 @@ export default class TicketToRideGame extends Phaser.Scene {
this.renderAll(); this.renderAll();
await this.delay(200); await this.delay(200);
this.busy = false; this._setBusy(false);
this.advance(); this.advance();
} }
async aiResolveTickets() { async aiResolveTickets() {
this.busy = true; this._setBusy(true);
await this.delay(550); await this.delay(550);
const pend = this.gs.pendingTickets; const pend = this.gs.pendingTickets;
const keep = AI.chooseTicketsToKeep(this.gs, pend.seat, pend.drawn, pend.minKeep); const keep = AI.chooseTicketsToKeep(this.gs, pend.seat, pend.drawn, pend.minKeep);
this.gs = L.resolveTicketKeep(this.gs, pend.seat, keep); this.gs = L.resolveTicketKeep(this.gs, pend.seat, keep);
this.busy = false; this._setBusy(false);
this.advance(); this.advance();
} }
@ -563,7 +597,9 @@ export default class TicketToRideGame extends Phaser.Scene {
this.trainCarObjs.forEach((o) => o.destroy()); this.trainCarObjs.forEach((o) => o.destroy());
this.trainCarObjs = []; this.trainCarObjs = [];
const g = this.routeGfx; const g = this.routeGfx;
const sg = this.trainStrokeGfx;
g.clear(); g.clear();
sg.clear();
ROUTES.forEach((r, id) => { ROUTES.forEach((r, id) => {
const owner = this.gs.claimed[id]; const owner = this.gs.claimed[id];
const segs = this.segCache[id]; const segs = this.segCache[id];
@ -575,10 +611,16 @@ export default class TicketToRideGame extends Phaser.Scene {
.setRotation(seg.angle) .setRotation(seg.angle)
.setTint(col.hex) .setTint(col.hex)
.setDepth(D.train); .setDepth(D.train);
sg.save();
sg.translateCanvas(seg.cx, seg.cy);
sg.rotateCanvas(seg.angle);
sg.lineStyle(2, 0xffffff, 1);
sg.strokeRoundedRect(-seg.w / 2, -seg.h / 2, seg.w, seg.h, 3);
sg.restore();
this.trainCarObjs.push(img); this.trainCarObjs.push(img);
} }
} else { } else {
const fill = CARD_COLOR_HEX[r.color] ?? CARD_COLOR_HEX.gray; const fill = TTR_ROUTE_HEX[r.color] ?? TTR_ROUTE_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);
} }
}); });
@ -639,6 +681,7 @@ export default class TicketToRideGame extends Phaser.Scene {
} }
renderHand() { renderHand() {
if (this._suppressHandRender) return;
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;
@ -683,7 +726,7 @@ export default class TicketToRideGame extends Phaser.Scene {
} }
// Mini hand fan // Mini hand fan
const gfx = this.oppMiniHandGfx[seat]; const gfx = this.oppMiniHandGfx[seat];
if (gfx) { if (gfx && !this._suppressHandRender) {
const { x, y } = this.oppPos(seat); const { x, y } = this.oppPos(seat);
const fanX = x - OPP_R; const fanX = x - OPP_R;
const fanY = y + OPP_R + 20; const fanY = y + OPP_R + 20;
@ -785,6 +828,10 @@ export default class TicketToRideGame extends Phaser.Scene {
closeModal() { closeModal() {
this.modalObjs.forEach((o) => o.destroy()); this.modalObjs.forEach((o) => o.destroy());
this.modalObjs = []; this.modalObjs = [];
// Block input for one tick so the click that closed the modal doesn't
// immediately fire on whatever is now exposed underneath it.
this.input.enabled = false;
this.time.delayedCall(80, () => { this.input.enabled = true; });
} }
promptTicketKeep() { promptTicketKeep() {
@ -792,7 +839,7 @@ export default class TicketToRideGame extends Phaser.Scene {
const pend = this.gs.pendingTickets; const pend = this.gs.pendingTickets;
const setup = pend.context === 'setup'; const setup = pend.context === 'setup';
const selected = new Set(pend.drawn); // pre-keep everything const selected = new Set(pend.drawn); // pre-keep everything
const PX = 700, PW = 720; const PX = RB.cx, PW = RB.w - 52;
const rowH = 70; const rowH = 70;
const PH = 200 + pend.drawn.length * rowH; const PH = 200 + pend.drawn.length * rowH;
const PY = 540; const PY = 540;
@ -802,7 +849,8 @@ export default class TicketToRideGame extends Phaser.Scene {
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1); const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = this.add.text(PX, PY - PH / 2 + 36, const title = this.add.text(PX, PY - PH / 2 + 36,
setup ? 'Keep at least 2 destination tickets' : 'Keep at least 1 destination ticket', { setup ? 'Keep at least 2 destination tickets' : 'Keep at least 1 destination ticket', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
wordWrap: { width: PW - 32 }, align: 'center',
}).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);
@ -819,10 +867,10 @@ export default class TicketToRideGame extends Phaser.Scene {
const box = this.add.rectangle(PX, ry, PW - 80, rowH - 12, 0x223344, 1) const box = this.add.rectangle(PX, ry, PW - 80, rowH - 12, 0x223344, 1)
.setStrokeStyle(3, COLORS.gold).setDepth(D.modal + 2).setInteractive({ useHandCursor: true }); .setStrokeStyle(3, COLORS.gold).setDepth(D.modal + 2).setInteractive({ useHandCursor: true });
const label = this.add.text(PX - PW / 2 + 60, ry, `${CITIES[t.a].name}${CITIES[t.b].name}`, { const label = this.add.text(PX - PW / 2 + 60, ry, `${CITIES[t.a].name}${CITIES[t.b].name}`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.modal + 3); }).setOrigin(0, 0.5).setDepth(D.modal + 3);
const pts = this.add.text(PX + PW / 2 - 60, ry, `${t.points} pts`, { const pts = this.add.text(PX + PW / 2 - 46, ry, `${t.points} pts`, {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '17px', color: COLORS.goldHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 3); }).setOrigin(1, 0.5).setDepth(D.modal + 3);
const mark = this.add.text(PX - PW / 2 + 32, ry, '✓', { const mark = this.add.text(PX - PW / 2 + 32, ry, '✓', {
fontFamily: 'Righteous', fontSize: '22px', color: '#5fd97a', fontFamily: 'Righteous', fontSize: '22px', color: '#5fd97a',
@ -854,9 +902,9 @@ export default class TicketToRideGame extends Phaser.Scene {
promptClaimPayment(routeId, options) { promptClaimPayment(routeId, options) {
this.closeModal(); this.closeModal();
this.busy = true; // block other input while choosing this._setBusy(true); // block other input while choosing
const route = ROUTES[routeId]; const route = ROUTES[routeId];
const PX = 700, PW = 640; const PX = RB.cx, PW = RB.w - 52;
const PH = 180 + options.length * 70; const PH = 180 + options.length * 70;
const PY = 540; const PY = 540;
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
@ -864,7 +912,8 @@ export default class TicketToRideGame extends Phaser.Scene {
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1); const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = this.add.text(PX, PY - PH / 2 + 34, const title = this.add.text(PX, PY - PH / 2 + 34,
`Claim ${CITIES[route.a].name} ${CITIES[route.b].name}\nPay with:`, { `Claim ${CITIES[route.a].name} ${CITIES[route.b].name}\nPay with:`, {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex, align: 'center', fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex, align: 'center',
wordWrap: { width: PW - 32 },
}).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);
@ -875,10 +924,10 @@ export default class TicketToRideGame extends Phaser.Scene {
await this.animateClaimRoute(0, routeId, payment); await this.animateClaimRoute(0, routeId, payment);
this.gs = newGs; this.gs = newGs;
playSound(this, SFX.CARD_PLACE); playSound(this, SFX.CARD_PLACE);
this.busy = false; this._setBusy(false);
this.advance(); this.advance();
} else { } else {
this.busy = false; this._setBusy(false);
this.advance(); this.advance();
} }
}; };
@ -904,14 +953,14 @@ export default class TicketToRideGame extends Phaser.Scene {
for (const rid of this.gs.players[0].routes) { const r = ROUTES[rid]; parent[f(r.a)] = f(r.b); } for (const rid of this.gs.players[0].routes) { const r = ROUTES[rid]; parent[f(r.a)] = f(r.b); }
return f; return f;
})(); })();
const PX = 700, PW = 700, rowH = 64; const PX = RB.cx, PW = RB.w - 52, rowH = 64;
const PH = 170 + Math.max(1, tickets.length) * rowH; const PH = 170 + Math.max(1, tickets.length) * rowH;
const PY = 540; const PY = 540;
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(D.modal).setInteractive(); .setDepth(D.modal).setInteractive();
const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1); const panel = this.add.rectangle(PX, PY, PW, PH, 0x14202c, 0.98).setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
const title = this.add.text(PX, PY - PH / 2 + 34, 'Your Destination Tickets', { const title = this.add.text(PX, PY - PH / 2 + 34, 'Your Destination Tickets', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
}).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);
@ -923,13 +972,13 @@ export default class TicketToRideGame extends Phaser.Scene {
tickets.forEach((t, i) => { tickets.forEach((t, i) => {
const ry = PY - PH / 2 + 84 + i * rowH; const ry = PY - PH / 2 + 84 + i * rowH;
const done = this.gs.players[0].routes.length > 0 && find(t.a) === find(t.b); const done = this.gs.players[0].routes.length > 0 && find(t.a) === find(t.b);
this.modalObjs.push(this.add.text(PX - PW / 2 + 40, ry, `${CITIES[t.a].name}${CITIES[t.b].name}`, { this.modalObjs.push(this.add.text(PX - PW / 2 + 28, ry, `${CITIES[t.a].name}${CITIES[t.b].name}`, {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.modal + 2)); }).setOrigin(0, 0.5).setDepth(D.modal + 2));
this.modalObjs.push(this.add.text(PX + PW / 2 - 150, ry, `${t.points} pts`, { this.modalObjs.push(this.add.text(PX + PW / 2 - 90, ry, `${t.points} pts`, {
fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex, fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 2)); }).setOrigin(1, 0.5).setDepth(D.modal + 2));
this.modalObjs.push(this.add.text(PX + PW / 2 - 40, ry, done ? '✓ done' : 'open', { this.modalObjs.push(this.add.text(PX + PW / 2 - 18, ry, done ? '✓' : '…', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: done ? '#5fd97a' : COLORS.mutedHex, fontFamily: '"Julius Sans One"', fontSize: '16px', color: done ? '#5fd97a' : COLORS.mutedHex,
}).setOrigin(1, 0.5).setDepth(D.modal + 2)); }).setOrigin(1, 0.5).setDepth(D.modal + 2));
}); });

View File

@ -12,6 +12,12 @@ const CATEGORIES = [
{ key: 'word', label: 'Word' }, { key: 'word', label: 'Word' },
]; ];
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3 };
const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -125;
export default class GameMenuScene extends Phaser.Scene { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } constructor() { super('GameMenu'); }
@ -63,6 +69,15 @@ export default class GameMenuScene extends Phaser.Scene {
this._tabs[key] = btn; this._tabs[key] = btn;
}); });
this._tabIcons = {};
activeCats.forEach(({ key }) => {
const btn = this._tabs[key];
const icon = this.add.image(btn.x + ICON_X_OFFSET, btn.y, 'tab-icons', TAB_ICON_FRAMES[key])
.setDisplaySize(ICON_INACTIVE, ICON_INACTIVE);
icon._glow = null;
this._tabIcons[key] = icon;
});
this.showCategory(activeCats[0].key); this.showCategory(activeCats[0].key);
new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); new Button(this, cx, GAME_HEIGHT - 100, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
@ -73,6 +88,49 @@ export default class GameMenuScene extends Phaser.Scene {
btn.setActive(k === key); btn.setActive(k === key);
} }
if (this._tabIcons) {
for (const [k, icon] of Object.entries(this._tabIcons)) {
this.tweens.killTweensOf(icon._glow);
icon.postFX.clear();
icon._glow = null;
if (k === key) {
this.tweens.add({
targets: icon,
displayWidth: ICON_OVERSHOOT, displayHeight: ICON_OVERSHOOT,
duration: 160,
ease: 'Quad.easeOut',
onComplete: () => {
this.tweens.add({
targets: icon,
displayWidth: ICON_ACTIVE, displayHeight: ICON_ACTIVE,
duration: 220,
ease: 'Back.easeOut',
onComplete: () => {
icon._glow = icon.postFX.addGlow(0xd4a017, 0, 0, false, 0.1, 16);
this.tweens.add({
targets: icon._glow,
outerStrength: 6,
duration: 900,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
},
});
},
});
} else {
this.tweens.add({
targets: icon,
displayWidth: ICON_INACTIVE, displayHeight: ICON_INACTIVE,
duration: 180,
ease: 'Quad.easeIn',
});
}
}
}
for (const obj of this._gameObjects) obj.destroy(); for (const obj of this._gameObjects) obj.destroy();
this._gameObjects = []; this._gameObjects = [];

View File

@ -74,6 +74,7 @@ export default class PreloadScene extends Phaser.Scene {
// 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 }); this.load.spritesheet('ttr-cards', '/assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('tab-icons', '/assets/images/tab-icons.png', { frameWidth: 128, frameHeight: 128 });
} }
async create() { async create() {