diff --git a/public/assets/images/tab-icons.png b/public/assets/images/tab-icons.png new file mode 100644 index 0000000..823b361 Binary files /dev/null and b/public/assets/images/tab-icons.png differ diff --git a/public/assets/images/tab-icons.psd b/public/assets/images/tab-icons.psd new file mode 100644 index 0000000..8ca1e7c Binary files /dev/null and b/public/assets/images/tab-icons.psd differ diff --git a/public/src/games/tickettoride/TicketToRideGame.js b/public/src/games/tickettoride/TicketToRideGame.js index ec1594b..e91faca 100644 --- a/public/src/games/tickettoride/TicketToRideGame.js +++ b/public/src/games/tickettoride/TicketToRideGame.js @@ -26,6 +26,19 @@ const TTR_CARD_FRAME = { 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. 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; @@ -42,7 +55,8 @@ export default class TicketToRideGame extends Phaser.Scene { this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.gs = null; - this.busy = false; + this._setBusy(false); + this._suppressHandRender = false; this.opponentPortraits = []; this.modalObjs = []; this.marketObjs = []; @@ -79,14 +93,14 @@ export default class TicketToRideGame extends Phaser.Scene { 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.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 (dark contrast detail) + g.fillStyle(0xffffff, 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.fillStyle(0xeeeeee, 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.fillStyle(0xeeeeee, 1); g.fillRect(30, 29, 60, 4); // underframe + g.fillStyle(0xffffff, 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(); @@ -121,8 +135,9 @@ export default class TicketToRideGame extends Phaser.Scene { this.segCache = ROUTES.map((r) => routeSegments(r)); this.midCache = ROUTES.map((r) => routeMidpoint(r)); - this.routeGfx = this.add.graphics().setDepth(D.route); - this.hoverGfx = this.add.graphics().setDepth(D.hover); + 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); // Invisible rotated hit-zone per route. this.routeZones = ROUTES.map((r, id) => { @@ -200,6 +215,17 @@ export default class TicketToRideGame extends Phaser.Scene { fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(D.hud); 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) { @@ -325,7 +351,7 @@ export default class TicketToRideGame extends Phaser.Scene { // ── new match / turn driver ─────────────────────────────────────────────────── startNewMatch() { - this.busy = false; + this._setBusy(false); this.closeModal(); const playerCount = Math.min(5, Math.max(2, 1 + this.opponents.length)); this.gs = L.createInitialState(playerCount); @@ -382,7 +408,8 @@ export default class TicketToRideGame extends Phaser.Scene { const refillHappened = newGs.faceUp.length === origFaceUpLength; const refillSlot = origFaceUpLength - 1; const refillColor = refillHappened ? newGs.faceUp[refillSlot] : undefined; - this.busy = true; + this._suppressHandRender = true; + this._setBusy(true); this.gs = newGs; this.marketSlotObjs[i]?.forEach((o) => o.setVisible(false)); this.renderAllExceptMarket(); @@ -394,8 +421,9 @@ export default class TicketToRideGame extends Phaser.Scene { }); await this.animateMarketSlideUp(i, origFaceUpLength - 1); if (refillColor !== undefined) await this.animateRefillMarketSlot(refillSlot, refillColor); + this._suppressHandRender = false; this.renderAll(); - this.busy = false; + this._setBusy(false); 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; } 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]); - this.busy = true; + this._suppressHandRender = true; + this._setBusy(true); this.gs = newGs; this.renderAll(); 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], shouldFlip: true, destX: dest.x, destY: dest.y, destW: 97, destH: 140, }); - this.busy = false; + this._suppressHandRender = false; + this._setBusy(false); this.advance(); } @@ -442,11 +472,11 @@ export default class TicketToRideGame extends Phaser.Scene { if (opts.length === 1) { const payment = opts[0]; const newGs = L.claimRoute(this.gs, 0, id, payment); - this.busy = true; + this._setBusy(true); await this.animateClaimRoute(0, id, payment); this.gs = newGs; playSound(this, SFX.CARD_PLACE); - this.busy = false; + this._setBusy(false); this.advance(); return; } @@ -455,7 +485,7 @@ export default class TicketToRideGame extends Phaser.Scene { // ── AI driver ───────────────────────────────────────────────────────────────── async aiTurn() { - this.busy = true; + this._setBusy(true); const seat = this.gs.currentPlayer; this.showTurnBanner(`${this.pname(seat)}'s Turn`); await this.delay(650); @@ -486,6 +516,7 @@ export default class TicketToRideGame extends Phaser.Scene { const refillHappened1 = firstFaceUp && this.gs.faceUp.length === origFaceUpLen1; const refillColor1 = refillHappened1 ? this.gs.faceUp[origFaceUpLen1 - 1] : undefined; playSound(this, SFX.CARD_DEAL); + this._suppressHandRender = true; if (firstFaceUp) this.renderAllExceptMarket(); else this.renderAll(); await this.animateCardDraw({ 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, destW: MINI_W, destH: MINI_H, }); + this._suppressHandRender = false; if (firstFaceUp) { await this.animateMarketSlideUp(a.index, origFaceUpLen1 - 1); 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 refillColor2 = refillHappened2 ? this.gs.faceUp[origFaceUpLen2 - 1] : undefined; playSound(this, SFX.CARD_DEAL); + this._suppressHandRender = true; if (secondFaceUp) this.renderAllExceptMarket(); else this.renderAll(); await this.animateCardDraw({ 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, destW: MINI_W, destH: MINI_H, }); + this._suppressHandRender = false; if (secondFaceUp) { await this.animateMarketSlideUp(b.index, origFaceUpLen2 - 1); if (refillColor2 !== undefined) await this.animateRefillMarketSlot(origFaceUpLen2 - 1, refillColor2); @@ -533,17 +567,17 @@ export default class TicketToRideGame extends Phaser.Scene { this.renderAll(); await this.delay(200); - this.busy = false; + this._setBusy(false); this.advance(); } async aiResolveTickets() { - this.busy = true; + this._setBusy(true); await this.delay(550); const pend = this.gs.pendingTickets; const keep = AI.chooseTicketsToKeep(this.gs, pend.seat, pend.drawn, pend.minKeep); this.gs = L.resolveTicketKeep(this.gs, pend.seat, keep); - this.busy = false; + this._setBusy(false); this.advance(); } @@ -563,7 +597,9 @@ export default class TicketToRideGame extends Phaser.Scene { this.trainCarObjs.forEach((o) => o.destroy()); this.trainCarObjs = []; const g = this.routeGfx; + const sg = this.trainStrokeGfx; g.clear(); + sg.clear(); ROUTES.forEach((r, id) => { const owner = this.gs.claimed[id]; const segs = this.segCache[id]; @@ -575,10 +611,16 @@ export default class TicketToRideGame extends Phaser.Scene { .setRotation(seg.angle) .setTint(col.hex) .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); } } 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); } }); @@ -639,6 +681,7 @@ export default class TicketToRideGame extends Phaser.Scene { } renderHand() { + if (this._suppressHandRender) return; this.handObjs.forEach((o) => o.destroy()); this.handObjs = []; const hand = this.gs.players[0].hand; @@ -683,7 +726,7 @@ export default class TicketToRideGame extends Phaser.Scene { } // Mini hand fan const gfx = this.oppMiniHandGfx[seat]; - if (gfx) { + if (gfx && !this._suppressHandRender) { const { x, y } = this.oppPos(seat); const fanX = x - OPP_R; const fanY = y + OPP_R + 20; @@ -785,6 +828,10 @@ export default class TicketToRideGame extends Phaser.Scene { closeModal() { this.modalObjs.forEach((o) => o.destroy()); 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() { @@ -792,7 +839,7 @@ export default class TicketToRideGame extends Phaser.Scene { const pend = this.gs.pendingTickets; const setup = pend.context === 'setup'; 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 PH = 200 + pend.drawn.length * rowH; 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 title = this.add.text(PX, PY - PH / 2 + 36, 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); 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) .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}`, { - 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); - const pts = this.add.text(PX + PW / 2 - 60, ry, `${t.points} pts`, { - fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex, + const pts = this.add.text(PX + PW / 2 - 46, ry, `${t.points} pts`, { + fontFamily: 'Righteous', fontSize: '17px', color: COLORS.goldHex, }).setOrigin(1, 0.5).setDepth(D.modal + 3); const mark = this.add.text(PX - PW / 2 + 32, ry, '✓', { fontFamily: 'Righteous', fontSize: '22px', color: '#5fd97a', @@ -854,9 +902,9 @@ export default class TicketToRideGame extends Phaser.Scene { promptClaimPayment(routeId, options) { this.closeModal(); - this.busy = true; // block other input while choosing + this._setBusy(true); // block other input while choosing const route = ROUTES[routeId]; - const PX = 700, PW = 640; + const PX = RB.cx, PW = RB.w - 52; const PH = 180 + options.length * 70; const PY = 540; 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 title = this.add.text(PX, PY - PH / 2 + 34, `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); this.modalObjs.push(overlay, panel, title); @@ -875,10 +924,10 @@ export default class TicketToRideGame extends Phaser.Scene { await this.animateClaimRoute(0, routeId, payment); this.gs = newGs; playSound(this, SFX.CARD_PLACE); - this.busy = false; + this._setBusy(false); this.advance(); } else { - this.busy = false; + this._setBusy(false); 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); } 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 PY = 540; const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) .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 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); this.modalObjs.push(overlay, panel, title); @@ -923,13 +972,13 @@ export default class TicketToRideGame extends Phaser.Scene { tickets.forEach((t, i) => { const ry = PY - PH / 2 + 84 + i * rowH; 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}`, { - fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex, + this.modalObjs.push(this.add.text(PX - PW / 2 + 28, ry, `${CITIES[t.a].name} → ${CITIES[t.b].name}`, { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex, }).setOrigin(0, 0.5).setDepth(D.modal + 2)); - this.modalObjs.push(this.add.text(PX + PW / 2 - 150, ry, `${t.points} pts`, { - fontFamily: 'Righteous', fontSize: '18px', color: COLORS.goldHex, + this.modalObjs.push(this.add.text(PX + PW / 2 - 90, ry, `${t.points} pts`, { + fontFamily: 'Righteous', fontSize: '16px', color: COLORS.goldHex, }).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, }).setOrigin(1, 0.5).setDepth(D.modal + 2)); }); diff --git a/public/src/scenes/GameMenuScene.js b/public/src/scenes/GameMenuScene.js index 9d03f9f..6adce7e 100644 --- a/public/src/scenes/GameMenuScene.js +++ b/public/src/scenes/GameMenuScene.js @@ -12,6 +12,12 @@ const CATEGORIES = [ { 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 { constructor() { super('GameMenu'); } @@ -63,6 +69,15 @@ export default class GameMenuScene extends Phaser.Scene { 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); 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); } + 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(); this._gameObjects = []; diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index baabca0..21fbc82 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -74,6 +74,7 @@ export default class PreloadScene extends Phaser.Scene { // 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('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() {