feat(catan): enhance visuals with custom tiles, cards, and bank panel

- Add high-resolution `catancards.png` and `catantiles.png` assets
- Replace hexagon background colors with textured tile images from spritesheet
- Redesign resource hand with card-style UI (dark background, artwork, colored borders)
- Introduce a new Bank Panel on the right side showing resource and dev card counts
- Implement resource collection animations (chit pulse, particle effects, card flight)
- Export `HEX_W` constant for use in tile sizing calculations
This commit is contained in:
Brian Fertig 2026-05-23 12:59:27 -06:00
parent 4b6593b074
commit e562ed21b8
7 changed files with 251 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

View File

@ -10,7 +10,7 @@ export const BOARD_CY = 470;
export const HEX_SIZE = 92; // centre-to-corner radius (pointy-top)
const SQRT3 = Math.sqrt(3);
const HEX_W = SQRT3 * HEX_SIZE; // flat-to-flat width / in-row spacing
export const HEX_W = SQRT3 * HEX_SIZE; // flat-to-flat width / in-row spacing
const ROW_V = 1.5 * HEX_SIZE; // vertical spacing between rows
export const HEX_ROWS = [3, 4, 5, 4, 3];

View File

@ -8,7 +8,7 @@ import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
NODES, EDGES, HEXES, PORT_SLOTS, RESOURCE_INFO, RESOURCE_TYPES, DESERT_COLOR,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP,
PLAYER_COLORS, COSTS, DEV_INFO, pipCount, WIN_VP, HEX_SIZE, HEX_W,
} from './CatanBoard.js';
import * as L from './CatanLogic.js';
import * as AI from './CatanAI.js';
@ -40,6 +40,7 @@ export default class CatanGame extends Phaser.Scene {
this.buildBoardStatic();
this.buildDice();
this.buildHUD();
this.buildBankPanel();
this.buildOpponentPanels();
this.startNewMatch();
}
@ -80,27 +81,63 @@ export default class CatanGame extends Phaser.Scene {
buildBoardStatic() {
// Hexes drawn once from the (static) topology; resources/numbers come from state at startNewMatch.
this.hexGfx = this.add.graphics().setDepth(D.board);
this.hexBorderGfx = this.add.graphics().setDepth(D.board + 2);
this.hexImgs = [];
this.hexLabels = [];
this.portObjs = [];
}
// Frame pairs per resource: pick one at random each draw.
static TILE_FRAMES = {
lumber: [0, 1],
wool: [2, 3],
brick: [4, 5],
ore: [6, 7],
grain: [8, 9],
desert: [10, 11],
};
drawHexes() {
const g = this.hexGfx;
g.clear();
this.hexBorderGfx.clear();
this.hexImgs.forEach(({ img, maskG }) => { img.destroy(); maskG.destroy(); });
this.hexImgs = [];
this.hexLabels.forEach((t) => t.destroy());
this.hexLabels = [];
for (const hex of this.gs.hexes) {
const pts = HEXES[hex.id].corners.map((c) => ({ x: NODES[c].x, y: NODES[c].y }));
const { x, y } = this.hexPos(hex.id);
// Color fill as fallback base layer
const color = hex.resource === 'desert' ? DESERT_COLOR : RESOURCE_INFO[hex.resource].color;
g.fillStyle(color, 1);
g.fillPoints(pts, true);
g.lineStyle(4, 0x6b4a1a, 0.85);
g.strokePoints(pts, true);
const { x, y } = this.hexPos(hex.id);
// Tile image clipped to hex polygon
if (this.textures.exists('catan-tiles')) {
const frames = CatanGame.TILE_FRAMES[hex.resource] ?? [10, 11];
const frame = frames[Math.floor(Math.random() * 2)];
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillPoints(pts, true);
const img = this.add.image(x, y, 'catan-tiles', frame)
.setDisplaySize(HEX_W, HEX_SIZE * 2)
.setMask(maskG.createGeometryMask())
.setDepth(D.board + 1);
this.hexImgs.push({ img, maskG });
}
// Border on top of images
this.hexBorderGfx.lineStyle(4, 0x6b4a1a, 0.85);
this.hexBorderGfx.strokePoints(pts, true);
// Resource label
const label = hex.resource === 'desert' ? 'Desert' : RESOURCE_INFO[hex.resource].tile;
this.hexLabels.push(this.add.text(x, y - 56, label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: '#2a2118',
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 1));
}).setOrigin(0.5).setAlpha(0.65).setDepth(D.board + 3));
}
}
@ -132,6 +169,7 @@ export default class CatanGame extends Phaser.Scene {
drawChits() {
this.chitObjs.forEach((o) => o.destroy());
this.chitObjs = [];
this.chitByHexId = {};
for (const hex of this.gs.hexes) {
if (hex.number == null) continue;
const { x, y } = this.hexPos(hex.id);
@ -156,6 +194,7 @@ export default class CatanGame extends Phaser.Scene {
for (let i = 0; i < n; i++) pg.fillCircle(startX + i * spacing, 13, 2);
c.add(pg);
this.chitObjs.push(c);
this.chitByHexId[hex.id] = c;
// pop-in
c.setScale(0);
this.tweens.add({ targets: c, scale: 1, duration: 260, delay: hex.id * 18, ease: 'Back.easeOut' });
@ -216,7 +255,7 @@ export default class CatanGame extends Phaser.Scene {
this.add.rectangle(GAME_WIDTH / 2, 893, GAME_WIDTH, 4, COLORS.accent, 0.6).setDepth(D.hud - 1);
// human portrait
createPlayerPortrait(this, 90, 980, 64, D.hud, 'Catan');
createPlayerPortrait(this, 90, 980, 64, D.hud, 'CatanGame');
this.add.text(90, 1056, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.hud);
@ -224,16 +263,28 @@ export default class CatanGame extends Phaser.Scene {
// resource hand
this.resText = {};
const startX = 230, gap = 86;
const cardW = 60, cardH = 84, cardR = 6, borderW = 3;
RESOURCE_TYPES.forEach((r, i) => {
const x = startX + i * gap, y = 950;
const g = this.add.graphics().setDepth(D.hud);
g.fillStyle(RESOURCE_INFO[r].swatch, 1); g.fillRoundedRect(x - 32, y - 30, 64, 60, 8);
g.lineStyle(2, 0x000000, 0.35); g.strokeRoundedRect(x - 32, y - 30, 64, 60, 8);
this.add.text(x, y - 14, RESOURCE_INFO[r].label, {
fontFamily: '"Julius Sans One"', fontSize: '12px', color: '#1a1208',
}).setOrigin(0.5).setDepth(D.hud);
this.resText[r] = this.add.text(x, y + 8, '0', {
fontFamily: 'Righteous', fontSize: '24px', color: '#1a1208',
// 1. Dark background fill
const gFill = this.add.graphics().setDepth(D.hud);
gFill.fillStyle(0x111111, 0.85);
gFill.fillRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
// 2. Card artwork (270×390 source → 54×78 display)
this.add.image(x, y, 'catan-cards', i).setDisplaySize(54, 78).setDepth(D.hud);
// 3. Colored border on top of artwork
const gBorder = this.add.graphics().setDepth(D.hud);
gBorder.lineStyle(borderW, RESOURCE_INFO[r].swatch, 1);
gBorder.strokeRoundedRect(x - cardW / 2, y - cardH / 2, cardW, cardH, cardR);
// 4. Resource label below card
this.add.text(x, y + cardH / 2 + 9, RESOURCE_INFO[r].label, {
fontFamily: '"Julius Sans One"', fontSize: '11px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0).setDepth(D.hud);
// 5. Quantity number over artwork
this.resText[r] = this.add.text(x, y + 6, '0', {
fontFamily: 'Righteous', fontSize: '26px', color: '#ffffff',
stroke: '#000000', strokeThickness: 4,
shadow: { color: '#000000', fill: true, offsetX: 2, offsetY: 2, blur: 3 },
}).setOrigin(0.5).setDepth(D.hud);
});
@ -315,6 +366,178 @@ export default class CatanGame extends Phaser.Scene {
});
}
// ── bank panel ───────────────────────────────────────────────────────────────
buildBankPanel() {
this.bankText = {};
// Panel flush against sea-circle right edge; wider to fit card + count text side by side
const panelX = 1489, panelW = 200, panelH = 632;
// Centre vertically in the playfield zone above the bottom bar (y=10..882)
const panelY = 10 + Math.round((872 - panelH) / 2); // 130
const cardCx = panelX + 10 + 63; // 10px left pad + half of 126
const textX = panelX + 10 + 126 + 12 + 15; // card right + 12 gap + half text ≈ 1637
const panelCx = panelX + panelW / 2; // for BANK title
const cardW = 126, cardH = 90, cardR = 6, borderW = 3, shadow = 4;
const imgW = 81, imgH = 117; // portrait in code; -90° rotation → landscape on screen
// Stacks nearly touching: 6 × (90 + 6px gap), starting 46px below panel top
const step = 96;
const stackTops = Array.from({ length: 6 }, (_, i) => panelY + 46 + i * step);
const dividerY = stackTops[4] + cardH + 3; // 3px below resource-5 bottom
this.bankCardPos = {};
RESOURCE_TYPES.forEach((r, i) => {
this.bankCardPos[r] = { x: cardCx, y: stackTops[i] + cardH / 2 };
});
// Layer 1: panel background + card shadow fills
const gBg = this.add.graphics().setDepth(D.hud - 1);
gBg.fillStyle(COLORS.panel, 0.92);
gBg.fillRect(panelX, panelY, panelW, panelH);
gBg.lineStyle(2, COLORS.accent, 0.7);
gBg.strokeRect(panelX, panelY, panelW, panelH);
this.add.text(panelCx, panelY + 14, 'BANK', {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0.5, 0).setDepth(D.hud);
[...RESOURCE_TYPES, 'dev'].forEach((_, i) => {
const top = stackTops[i];
gBg.fillStyle(0x000000, 0.4);
gBg.fillRoundedRect(cardCx - cardW / 2 + shadow, top + shadow, cardW, cardH, cardR);
gBg.fillStyle(0x111111, 0.9);
gBg.fillRoundedRect(cardCx - cardW / 2, top, cardW, cardH, cardR);
});
gBg.lineStyle(1, COLORS.accent, 0.6);
gBg.lineBetween(panelX + 8, dividerY, panelX + panelW - 8, dividerY);
// Layer 2: card artwork images, rotated 90° CCW
RESOURCE_TYPES.forEach((r, i) => {
this.add.image(cardCx, stackTops[i] + cardH / 2, 'catan-cards', i)
.setDisplaySize(imgW, imgH).setAngle(-90).setDepth(D.hud);
});
this.add.image(cardCx, stackTops[5] + cardH / 2, 'catan-cards', 8)
.setDisplaySize(imgW, imgH).setAngle(-90).setDepth(D.hud);
// Layer 3: colored borders
const gBorders = this.add.graphics().setDepth(D.hud);
RESOURCE_TYPES.forEach((r, i) => {
gBorders.lineStyle(borderW, RESOURCE_INFO[r].swatch, 1);
gBorders.strokeRoundedRect(cardCx - cardW / 2, stackTops[i], cardW, cardH, cardR);
});
gBorders.lineStyle(borderW, COLORS.accent, 1);
gBorders.strokeRoundedRect(cardCx - cardW / 2, stackTops[5], cardW, cardH, cardR);
// Layer 4: count text to the right of each card
const countStyle = {
fontFamily: 'Righteous', fontSize: '30px', color: '#ffffff',
stroke: '#000000', strokeThickness: 3,
};
RESOURCE_TYPES.forEach((r, i) => {
this.bankText[r] = this.add.text(textX, stackTops[i] + cardH / 2, '19', countStyle)
.setOrigin(0.5).setDepth(D.hud);
});
this.bankText.dev = this.add.text(textX, stackTops[5] + cardH / 2, '25', countStyle)
.setOrigin(0.5).setDepth(D.hud);
}
updateBank() {
if (!this.bankText) return;
const b = this.gs.bank;
for (const r of RESOURCE_TYPES) this.bankText[r]?.setText(String(b[r]));
this.bankText.dev?.setText(String(this.gs.devDeck.length));
}
// ── resource collection animations ──────────────────────────────────────────
portraitPos(seat) {
if (seat === 0) return { x: 90, y: 980 };
const panel = this.oppPanels.find(p => p.seat === seat);
return panel ? { x: panel.x, y: panel.y } : { x: 130, y: 300 };
}
async animateResourceCollection(oldGs, newGs) {
if (newGs.diceTotal === 7) return;
const cards = [];
for (let seat = 0; seat < newGs.players.length; seat++) {
for (const r of RESOURCE_TYPES) {
const delta = newGs.players[seat].resources[r] - oldGs.players[seat].resources[r];
for (let n = 0; n < delta; n++) cards.push({ seat, resource: r });
}
}
if (cards.length === 0) return;
const matchingHexes = newGs.hexes.filter(h => h.number === newGs.diceTotal && !h.hasRobber);
await Promise.all(matchingHexes.map(h => this.animateChitPulse(h)));
for (const { seat, resource } of cards) await this.animateCardFlight(seat, resource);
}
animateChitPulse(hex) {
return new Promise(resolve => {
const chit = this.chitByHexId?.[hex.id];
const { x, y } = this.hexPos(hex.id);
const color = RESOURCE_INFO[hex.resource]?.swatch ?? COLORS.accent;
const doParticles = () => {
const emitter = this.add.particles(x, y + 6, 'catanParticle', {
speed: { min: 80, max: 220 }, lifespan: 700,
scale: { start: 1.4, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 3, frequency: 25,
tint: [color, 0xffffff, 0xffd700], angle: { min: 0, max: 360 },
}).setDepth(D.chit + 2);
this.time.delayedCall(650, () => emitter.destroy());
};
if (!chit) { doParticles(); this.time.delayedCall(800, resolve); return; }
this.tweens.add({
targets: chit, scale: 1.6, duration: 200, ease: 'Back.easeOut',
onComplete: () => {
doParticles();
this.time.delayedCall(350, () => this.tweens.add({
targets: chit, scale: 1, duration: 200, ease: 'Back.easeIn',
onComplete: () => this.time.delayedCall(80, resolve),
}));
},
});
});
}
animateCardFlight(seat, resource) {
return new Promise(resolve => {
const frameIdx = RESOURCE_TYPES.indexOf(resource);
const src = this.bankCardPos?.[resource] ?? { x: 1562, y: 400 };
const dst = this.portraitPos(seat);
const img = this.add.image(src.x, src.y, 'catan-cards', frameIdx)
.setDisplaySize(81, 117).setDepth(D.banner - 1);
let flipped = false;
this.tweens.add({
targets: img, x: dst.x, y: dst.y, duration: 500, ease: 'Quad.InOut',
onUpdate: (tween) => {
if (!flipped && tween.progress > 0.4) {
flipped = true;
this.tweens.add({
targets: img, scaleX: 0, duration: 100, ease: 'Linear',
onComplete: () => this.tweens.add({ targets: img, scaleX: 1, duration: 100, ease: 'Linear' }),
});
}
},
onComplete: () => {
playSound(this, SFX.CASINO_WIN);
img.destroy();
const radius = seat === 0 ? 64 : 56;
const label = this.add.text(dst.x + radius + 10, dst.y,
RESOURCE_INFO[resource].label.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '26px', color: '#ffd700',
stroke: '#000000', strokeThickness: 3,
}).setOrigin(0, 0.5).setDepth(D.banner);
this.tweens.add({
targets: label, alpha: 0, y: dst.y - 24,
duration: 700, delay: 300,
onComplete: () => label.destroy(),
});
this.time.delayedCall(80, resolve);
},
});
});
}
// ── opponents (left column) ─────────────────────────────────────────────────
buildOpponentPanels() {
this.oppPanels = [];
@ -368,6 +591,7 @@ export default class CatanGame extends Phaser.Scene {
this.renderRobber();
this.updateHand();
this.updateDevHand();
this.updateBank();
this.renderOpponentPanels();
this.updateButtons();
this.updateStatus();
@ -606,9 +830,11 @@ export default class CatanGame extends Phaser.Scene {
this.renderAll(); await this.delay(400);
}
if (this.gs.phase === 'rollPhase') {
const preGs = this.gs;
const ns = L.rollDice(this.gs);
await this.animateDice(ns.dice);
this.gs = ns;
await this.animateResourceCollection(preGs, ns);
this.renderAll();
await this.delay(500);
}
@ -675,9 +901,11 @@ export default class CatanGame extends Phaser.Scene {
if (this.busy || this.gs.phase !== 'rollPhase' || this.gs.currentPlayer !== 0) return;
this.busy = true;
this.buttons.roll.setEnabled(false);
const preGs = this.gs;
const ns = L.rollDice(this.gs);
await this.animateDice(ns.dice);
this.gs = ns;
await this.animateResourceCollection(preGs, ns);
this.busy = false;
this.advance();
}

View File

@ -30,6 +30,14 @@ export default class PreloadScene extends Phaser.Scene {
frameWidth: 320,
frameHeight: 420,
});
this.load.spritesheet('catan-cards', '/assets/images/catancards.png', {
frameWidth: 270,
frameHeight: 390,
});
this.load.spritesheet('catan-tiles', '/assets/images/catantiles.png', {
frameWidth: 312,
frameHeight: 312,
});
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');