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:
parent
ae30299838
commit
036947885d
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 260 KiB |
Binary file not shown.
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue