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:
parent
4b6593b074
commit
e562ed21b8
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.
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue