feat(risk): redesign panel UI and add default opponent configuration

- Redesign Risk panel layout with player portraits, colored rings, and
  inline name/stats display (territories, armies)
- Add active player gold ring highlight on portrait
- Support custom playfield background images with fallback color
- Refactor button/hint positioning for cleaner control rendering
- Add defaultOpponents configuration to game registry
- Default Risk to 3 opponents on seat selection
- Update game-icons asset files
This commit is contained in:
Brian Fertig 2026-06-17 21:01:40 -06:00
parent ae30299838
commit 036947885d
5 changed files with 72 additions and 46 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

View File

@ -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);
}
y += 6;
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);
}
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);
}
}

View File

@ -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));

View File

@ -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 });