diff --git a/public/assets/images/game-icons.png b/public/assets/images/game-icons.png index fc1f620..269ce15 100644 Binary files a/public/assets/images/game-icons.png and b/public/assets/images/game-icons.png differ diff --git a/public/assets/images/game-icons.psd b/public/assets/images/game-icons.psd index e9dc9e5..9c5c4bd 100644 Binary files a/public/assets/images/game-icons.psd and b/public/assets/images/game-icons.psd differ diff --git a/public/src/games/risk/RiskGame.js b/public/src/games/risk/RiskGame.js index c8ca23e..18f0cb2 100644 --- a/public/src/games/risk/RiskGame.js +++ b/public/src/games/risk/RiskGame.js @@ -25,6 +25,11 @@ import { // ── Layout ────────────────────────────────────────────────────────────────── const PANEL_X = 1560; const PANEL_W = GAME_WIDTH - PANEL_X - 16; // ~344 +const PANEL_Y = 112; // top of panel (12 + 100) +const PANEL_H = GAME_HEIGHT - 224; // height (GAME_HEIGHT - 24 - 200) +const PORTRAIT_R = 37; // portrait radius (22 × 1.7) +const PORTRAIT_CX = PANEL_X + 58; // portrait centre x +const PORTRAIT_ROW = 102; // row spacing (60 × 1.7) const MAP_LEFT = 16, MAP_TOP = 16; const MAP_AREA_W = PANEL_X - MAP_LEFT - 16; // ~1528 const MAP_AREA_H = GAME_HEIGHT - MAP_TOP - 16; // ~1048 @@ -52,6 +57,7 @@ export default class RiskGame extends Phaser.Scene { init(data) { this.gameDef = data.game ?? { slug: 'risk', name: 'Risk' }; this.opponents = data.opponents ?? []; + this.playfield = data.playfield ?? null; this.humanSeat = 0; this.gs = null; this.busy = false; @@ -101,9 +107,19 @@ export default class RiskGame extends Phaser.Scene { // ── static build ──────────────────────────────────────────────────────────── buildBackground() { - const g = this.add.graphics().setDepth(DEPTH.bg); - g.fillStyle(0x0a1822, 1); g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); - // subtle ocean panel behind the map + const pf = this.playfield; + if (pf?.key && this.textures.exists(pf.key)) { + this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key) + .setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg); + } else { + const fallback = pf?.fallbackColor + ? parseInt(pf.fallbackColor.replace('#', ''), 16) + : 0x0a1822; + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, fallback) + .setDepth(DEPTH.bg); + } + // ocean panel behind the map + const g = this.add.graphics().setDepth(DEPTH.bg + 1); g.fillStyle(0x0e2230, 1); g.fillRoundedRect(MAP_X - 8, MAP_Y - 8, DISP_W + 16, DISP_H + 16, 10); } @@ -129,25 +145,27 @@ export default class RiskGame extends Phaser.Scene { buildPanel() { const g = this.add.graphics().setDepth(DEPTH.panel); g.fillStyle(COLORS.panel, 0.96); - g.fillRoundedRect(PANEL_X, 12, PANEL_W, GAME_HEIGHT - 24, 12); + g.fillRoundedRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 12); g.lineStyle(2, COLORS.accent, 0.8); - g.strokeRoundedRect(PANEL_X, 12, PANEL_W, GAME_HEIGHT - 24, 12); - this.add.text(PANEL_X + PANEL_W / 2, 40, 'RISK', { + g.strokeRoundedRect(PANEL_X, PANEL_Y, PANEL_W, PANEL_H, 12); + this.add.text(PANEL_X + PANEL_W / 2, PANEL_Y + 28, 'RISK', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, }).setOrigin(0.5).setDepth(DEPTH.panel); } buildPortraits() { // small portraits stacked under the title for each player - const x = PANEL_X + 34; - let y = 96; + let y = PANEL_Y + 90; for (let seat = 0; seat < this.gs.playerCount; seat++) { - const p = this.gs.players[seat]; let portrait = null; - if (seat === this.humanSeat) portrait = createPlayerPortrait(this, x, y, 22, DEPTH.panel + 1, 'RiskGame'); - else portrait = createOpponentPortrait(this, this.opponents[seat - 1], x, y, 22, DEPTH.panel + 1, { playIntro: false }); + if (seat === this.humanSeat) portrait = createPlayerPortrait(this, PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, 'RiskGame'); + else portrait = createOpponentPortrait(this, this.opponents[seat - 1], PORTRAIT_CX, y, PORTRAIT_R, DEPTH.panel + 1, { playIntro: false }); + // colored ring matching the player's piece color + const ring = this.add.graphics().setDepth(DEPTH.panel + 2); + ring.lineStyle(7, this.colorOf(seat), 1); + ring.strokeCircle(PORTRAIT_CX, y, PORTRAIT_R + 4); this.portraits.push({ seat, portrait, y }); - y += 60; + y += PORTRAIT_ROW; } this.panelRowY = y; // first free y under portraits } @@ -227,29 +245,34 @@ export default class RiskGame extends Phaser.Scene { renderPanel() { const s = this.gs; - let y = this.panelRowY + 6; const lx = PANEL_X + 16, rx = PANEL_X + PANEL_W - 16; + const textX = PORTRAIT_CX + PORTRAIT_R + 17; // left edge of text column - // per-player rows + // per-player rows — name + stats inline with portrait for (let seat = 0; seat < s.playerCount; seat++) { const p = s.players[seat]; const isCur = seat === s.current && !isGameOver(s); const terr = countTerritories(s, seat); let army = 0; for (let t = 0; t < NUM_TERRITORIES; t++) if (s.owner[t] === seat) army += s.armies[t]; + const py = this.portraits[seat].y; const name = p.alive ? p.name : `${p.name} (out)`; const col = p.alive ? this.colorHexOf(seat) : COLORS.mutedHex; - const sw = this.add.graphics().setDepth(DEPTH.panel + 1); this.dyn.push(sw); - sw.fillStyle(this.colorOf(seat), p.alive ? 1 : 0.4); sw.fillRoundedRect(lx, y, 14, 14, 3); - if (isCur) { sw.lineStyle(2, COLORS.gold, 1); sw.strokeRoundedRect(lx - 2, y - 2, 18, 18, 4); } - const t1 = this.add.text(lx + 22, y - 1, name, { - fontFamily: '"Julius Sans One"', fontSize: '16px', color: isCur ? COLORS.goldHex : col, - }).setOrigin(0, 0).setDepth(DEPTH.panel + 1); this.dyn.push(t1); - const t2 = this.add.text(rx, y - 1, `${terr}⬡ ${army}⚔`, { - fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, - }).setOrigin(1, 0).setDepth(DEPTH.panel + 1); this.dyn.push(t2); - y += 26; + + // gold outer ring on portrait for the active player + if (isCur) { + const glow = this.add.graphics().setDepth(DEPTH.panel + 3); this.dyn.push(glow); + glow.lineStyle(5, COLORS.gold, 1); glow.strokeCircle(PORTRAIT_CX, py, PORTRAIT_R + 9); + } + + const t1 = this.add.text(textX, py - 15, name, { + fontFamily: '"Julius Sans One"', fontSize: '27px', color: isCur ? COLORS.goldHex : col, + }).setOrigin(0, 0.5).setDepth(DEPTH.panel + 1); this.dyn.push(t1); + const t2 = this.add.text(textX, py + 17, `${terr}⬡ ${army}⚔`, { + fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(DEPTH.panel + 1); this.dyn.push(t2); } - y += 6; + + let y = this.panelRowY + 10; const sep = this.add.graphics().setDepth(DEPTH.panel + 1); this.dyn.push(sep); sep.lineStyle(1, COLORS.accent, 0.4); sep.lineBetween(lx, y, rx, y); y += 12; @@ -285,38 +308,39 @@ export default class RiskGame extends Phaser.Scene { if (!this.isHumanTurn()) return; const s = this.gs; const cx = PANEL_X + PANEL_W / 2; - let y = this.panelControlsY + 4; - const mk = (label, fn, opts = {}) => { + const panelBottom = PANEL_Y + PANEL_H; + const hintY = panelBottom - 52; + const btnY = hintY - 52; + const btn2Y = btnY - 56; + + const mk = (label, fn, y, opts = {}) => { const b = new Button(this, cx, y, label, fn, { width: PANEL_W - 40, height: 46, fontSize: 20, ...opts }).setDepth(DEPTH.ui); - this.dyn.push(b); y += 56; + this.dyn.push(b); return b; }; + const mkHint = (text, y) => { + const h = this.add.text(cx, y, text, { + fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, + align: 'center', wordWrap: { width: PANEL_W - 36 }, + }).setOrigin(0.5, 0.5).setDepth(DEPTH.ui); this.dyn.push(h); + }; if (s.phase === 'reinforce') { const human = s.players[this.humanSeat]; const set = hasValidSet(human.cards); - if (set) mk(mustTradeCards(s, this.humanSeat) ? 'Trade Cards (required)' : 'Trade Cards', () => this.openTradeModal()); - const hint = this.add.text(cx, y, 'Click your territories to place armies', { - fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, - align: 'center', wordWrap: { width: PANEL_W - 36 }, - }).setOrigin(0.5, 0).setDepth(DEPTH.ui); this.dyn.push(hint); + if (set) mk(mustTradeCards(s, this.humanSeat) ? 'Trade Cards (required)' : 'Trade Cards', () => this.openTradeModal(), btnY); + mkHint('Click your territories to place armies', hintY); } else if (s.phase === 'attack') { - mk('End Attack ▸', () => { this.selFrom = null; this.gs = endAttack(this.gs); this.render(); this.advance(); }); - const hint = this.add.text(cx, y, this.selFrom == null + mk('End Attack ▸', () => { this.selFrom = null; this.gs = endAttack(this.gs); this.render(); this.advance(); }, btnY); + mkHint(this.selFrom == null ? 'Click one of your territories (2+ armies) to attack from' - : 'Click a red enemy territory to attack', { - fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, - align: 'center', wordWrap: { width: PANEL_W - 36 }, - }).setOrigin(0.5, 0).setDepth(DEPTH.ui); this.dyn.push(hint); + : 'Click a red enemy territory to attack', hintY); } else if (s.phase === 'fortify') { - mk('Skip / End Turn ▸', () => { this.selFrom = null; this.gs = endTurn(this.gs); this.render(); this.advance(); }); - const hint = this.add.text(cx, y, this.selFrom == null + mk('Skip / End Turn ▸', () => { this.selFrom = null; this.gs = endTurn(this.gs); this.render(); this.advance(); }, btnY); + mkHint(this.selFrom == null ? 'Optionally fortify: click a source territory' - : 'Click a green connected territory to move armies', { - fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, - align: 'center', wordWrap: { width: PANEL_W - 36 }, - }).setOrigin(0.5, 0).setDepth(DEPTH.ui); this.dyn.push(hint); + : 'Click a green connected territory to move armies', hintY); } } diff --git a/public/src/scenes/OpponentSelectScene.js b/public/src/scenes/OpponentSelectScene.js index edd1cb1..0fcdf1d 100644 --- a/public/src/scenes/OpponentSelectScene.js +++ b/public/src/scenes/OpponentSelectScene.js @@ -114,6 +114,7 @@ export default class OpponentSelectScene extends Phaser.Scene { const max = this.gameDef.maxOpponents ?? 1; const defaultCount = this.gameDef.slug === 'nerts' ? 1 : this.gameDef.slug === 'tickettoride' ? Math.min(3, max) + : this.gameDef.defaultOpponents != null ? Math.min(this.gameDef.defaultOpponents, max) : max; this._initializing = true; this.cards.slice(0, defaultCount).forEach(({ opp, el }) => this.toggleOpponent(opp, el)); diff --git a/server/games/registry.js b/server/games/registry.js index 3719046..d9cde8c 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -11,6 +11,7 @@ export function registerGame(definition) { maxPlayers: definition.maxPlayers ?? 2, minOpponents: definition.minOpponents ?? 1, maxOpponents: definition.maxOpponents ?? 1, + defaultOpponents: definition.defaultOpponents ?? null, hasTutorial: definition.hasTutorial ?? false, iconFrame: definition.iconFrame ?? null, }); @@ -94,4 +95,4 @@ registerGame({ slug: 'dotlink', name: 'Dot Link', category: 'logic', minPlayers: registerGame({ slug: '2048', name: '2048', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 67 }); registerGame({ slug: 'rummikub', name: 'Rummikub', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: true, iconFrame: 68 }); registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 69 }); -registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, hasTutorial: true, iconFrame: 54 }); +registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54 });