feat(catan): overhaul visuals and opponent UI

- Replace static sea background with a radial gradient for a richer look.
- Redesign settlement and city graphics with drop shadows and white halos.
- Enhance road rendering with a white core for better visibility.
- Add VP badges and card count fans to opponent panels.
- Support card back sprites via `data.cardBack` for accurate card rendering.
- Mark Catan as a card game in the registry.
This commit is contained in:
Brian Fertig 2026-05-23 13:42:51 -06:00
parent 7439945416
commit 29fb4a9b2f
5 changed files with 103 additions and 23 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -22,6 +22,7 @@ export default class CatanGame extends Phaser.Scene {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.cardBack = data.cardBack ?? null;
this.gs = null;
this.busy = false;
this.highlights = [];
@ -70,12 +71,25 @@ export default class CatanGame extends Phaser.Scene {
} else {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x14506b).setDepth(D.board - 2);
}
// Sea backdrop ring under the island.
// Sea backdrop — radial gradient via concentric circles, dark outer to lighter inner.
const SEA_X = 1000, SEA_Y = 470, SEA_R = 470;
const sea = this.add.graphics().setDepth(D.board - 1);
sea.fillStyle(0x0d3a52, 1);
sea.fillCircle(1000, 470, 470);
sea.lineStyle(6, 0x0a2c40, 1);
sea.strokeCircle(1000, 470, 470);
const STEPS = 20;
for (let i = STEPS; i >= 0; i--) {
const t = i / STEPS;
const r = Math.round(SEA_R * (i / STEPS));
// Interpolate from outer dark (0x07243a) to inner lighter (0x1a5e80)
const ro = 0x07, go = 0x24, bo = 0x3a;
const ri = 0x1a, gi = 0x5e, bi = 0x80;
const red = Math.round(ro + (ri - ro) * (1 - t));
const green = Math.round(go + (gi - go) * (1 - t));
const blue = Math.round(bo + (bi - bo) * (1 - t));
const color = (red << 16) | (green << 8) | blue;
sea.fillStyle(color, 1);
sea.fillCircle(SEA_X, SEA_Y, r);
}
sea.lineStyle(4, 0x041820, 0.9);
sea.strokeCircle(SEA_X, SEA_Y, SEA_R);
}
buildBoardStatic() {
@ -585,7 +599,7 @@ export default class CatanGame extends Phaser.Scene {
const info = this.add.text(x, y + 96, '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, align: 'center',
}).setOrigin(0.5, 0).setDepth(D.hud);
this.oppPanels.push({ seat, info, x, y });
this.oppPanels.push({ seat, info, x, y, cardFan: [], vpBadge: null });
});
this.updateOpponentPanels();
}
@ -599,9 +613,59 @@ export default class CatanGame extends Phaser.Scene {
if (this.gs.longestRoad.owner === panel.seat) badges.push('LR');
if (this.gs.largestArmy.owner === panel.seat) badges.push('LA');
panel.info.setText(
`${L.publicVictoryPoints(this.gs, panel.seat)} VP ${cards} cards\n` +
`${dev} dev ${p.knightsPlayed} knights` + (badges.length ? `\n[${badges.join(' ')}]` : '')
);
// VP badge circle above the portrait
if (panel.vpBadge) { panel.vpBadge.destroy(); panel.vpBadge = null; }
const vp = L.publicVictoryPoints(this.gs, panel.seat);
const col = PLAYER_COLORS[p.colorIndex];
const badgeContainer = this.add.container(panel.x, panel.y - 74).setDepth(D.hud + 5);
const bg = this.add.graphics();
bg.fillStyle(col.hexDark, 1); bg.fillCircle(0, 0, 18);
bg.lineStyle(2.5, col.hex, 0.9); bg.strokeCircle(0, 0, 18);
const vpText = this.add.text(0, 0, String(vp), {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: col.key === 'white' ? '#000000' : '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
badgeContainer.add([bg, vpText]);
panel.vpBadge = badgeContainer;
// Rebuild face-down card fan to the right of the portrait
if (!panel.cardFan) panel.cardFan = [];
panel.cardFan.forEach(o => o.destroy());
panel.cardFan = [];
if (cards > 0) {
const frameIdx = this.cardBack?.spriteIndex ?? 0;
const cardW = 32, cardH = 45;
const maxShow = 7;
const show = Math.min(cards, maxShow);
const step = 16;
const fanStartX = panel.x + 74;
for (let i = 0; i < show; i++) {
const img = this.add.image(fanStartX + i * step, panel.y, 'cardbacks', frameIdx)
.setDisplaySize(cardW, cardH)
.setDepth(D.hud + i + 1);
panel.cardFan.push(img);
}
if (cards > maxShow) {
const countX = fanStartX + (maxShow - 1) * step + cardW / 2 + 6;
const badge = this.add.text(countX, panel.y, `×${cards}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.hud + 20);
panel.cardFan.push(badge);
} else {
const countX = fanStartX + (show - 1) * step + cardW / 2 + 6;
const badge = this.add.text(countX, panel.y, `${cards}`, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
stroke: '#000000', strokeThickness: 2,
}).setOrigin(0, 0.5).setDepth(D.hud + 20);
panel.cardFan.push(badge);
}
}
}
}
@ -626,8 +690,10 @@ export default class CatanGame extends Phaser.Scene {
for (const eid of p.roads) {
const [a, b] = EDGES[eid].nodes;
const g = this.add.graphics().setDepth(D.road);
g.lineStyle(12, col.hexDark, 1); g.lineBetween(NODES[a].x, NODES[a].y, NODES[b].x, NODES[b].y);
g.lineStyle(7, col.hex, 1); g.lineBetween(NODES[a].x, NODES[a].y, NODES[b].x, NODES[b].y);
const ax = NODES[a].x, ay = NODES[a].y, bx = NODES[b].x, by = NODES[b].y;
g.lineStyle(16, 0xffffff, 0.9); g.lineBetween(ax, ay, bx, by);
g.lineStyle(12, col.hexDark, 1); g.lineBetween(ax, ay, bx, by);
g.lineStyle(7, col.hex, 1); g.lineBetween(ax, ay, bx, by);
this.pieceObjs.push(g);
}
}
@ -641,24 +707,38 @@ export default class CatanGame extends Phaser.Scene {
makeSettlement(x, y, col) {
const g = this.add.graphics().setDepth(D.building);
g.fillStyle(0x000000, 0.25); g.fillRoundedRect(x - 11, y - 6, 24, 18, 3);
// Drop shadow
g.fillStyle(0x000000, 0.55); g.fillRoundedRect(x - 13, y - 1, 34, 24, 4);
// White halo (3px border around the scaled-up shape)
g.fillStyle(0xffffff, 1);
g.fillRect(x - 16, y - 4, 32, 20);
g.fillTriangle(x - 19, y - 4, x + 19, y - 4, x, y - 21);
// Player color fill (~30% larger than original)
g.fillStyle(col.hex, 1);
g.fillRect(x - 10, y - 3, 20, 13);
g.fillTriangle(x - 12, y - 3, x + 12, y - 3, x, y - 14);
g.lineStyle(2, col.hexDark, 1);
g.strokeRect(x - 10, y - 3, 20, 13);
g.fillRect(x - 13, y - 4, 26, 17);
g.fillTriangle(x - 16, y - 4, x + 16, y - 4, x, y - 18);
// Dark outline
g.lineStyle(2.5, col.hexDark, 1); g.strokeRect(x - 13, y - 4, 26, 17);
return g;
}
makeCity(x, y, col) {
const g = this.add.graphics().setDepth(D.building);
g.fillStyle(0x000000, 0.25); g.fillRoundedRect(x - 17, y - 10, 36, 24, 3);
// Drop shadow
g.fillStyle(0x000000, 0.55); g.fillRoundedRect(x - 19, y - 7, 50, 38, 4);
// White halo (3px border around the scaled-up shape)
g.fillStyle(0xffffff, 1);
g.fillRect(x - 24, y, 27, 21); // lower block halo
g.fillRect(x - 8, y - 10, 32, 32); // tower halo
g.fillTriangle(x - 11, y - 10, x + 26, y - 10, x + 8, y - 26); // roof halo
// Player color fill (~30% larger than original)
g.fillStyle(col.hex, 1);
g.fillRect(x - 16, y, 16, 14); // lower block
g.fillRect(x - 4, y - 8, 20, 22); // tower block
g.fillTriangle(x - 6, y - 8, x + 18, y - 8, x + 6, y - 18);
g.lineStyle(2, col.hexDark, 1);
g.strokeRect(x - 16, y, 16, 14);
g.strokeRect(x - 4, y - 8, 20, 22);
g.fillRect(x - 21, y, 21, 18); // lower block
g.fillRect(x - 5, y - 10, 26, 29); // tower
g.fillTriangle(x - 8, y - 10, x + 23, y - 10, x + 8, y - 23); // roof
// Dark outlines
g.lineStyle(2.5, col.hexDark, 1);
g.strokeRect(x - 21, y, 21, 18);
g.strokeRect(x - 5, y - 10, 26, 29);
return g;
}

View File

@ -38,7 +38,7 @@ export default class PreloadScene extends Phaser.Scene {
frameWidth: 312,
frameHeight: 312,
});
this.load.image('bg-menu', '/assets/images/background-menu.png');
this.load.image('bg-menu', '/assets/images/background-menu.png');
this.load.image('bg-room', '/assets/images/background-room.png');
this.load.image('bg-casino', '/assets/images/background-casino.png');
this.load.image('main-title', '/assets/images/main-title.png');

View File

@ -37,4 +37,4 @@ registerGame({ slug: 'craps', name: 'Craps', category: 'casino', minPlayers: 1,
registerGame({ slug: 'roulette', name: 'Roulette', category: 'casino', minPlayers: 1, maxPlayers: 7, minOpponents: 0, maxOpponents: 6 });
registerGame({ slug: 'mexicantrain', name: 'Mexican Train', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
registerGame({ slug: 'hearts', name: 'Hearts', category: 'cards', cardGame: true, minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });