feat(blokus/splendor): refine tray UI and upgrade splendor spritesheet rendering

- Blokus: Adjust tray coordinates and add a background panel for improved layout separation.
- Splendor: Integrate a 28-frame spritesheet for development cards, nobles, and deck backs.
- Refactor frame mapping to calculate indices dynamically based on tier and bonus color.
- Unify card rendering pipeline: draw tinted background, overlay spritesheet art (when available), then render vector overlays.
- Add hover preview panel for reserved cards.
- Document spritesheet layout and update preload scene asset comments.
This commit is contained in:
Brian Fertig 2026-06-02 21:35:14 -06:00
parent 04a32cebcd
commit e60387ec7c
7 changed files with 202 additions and 49 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

View File

@ -17,16 +17,16 @@ import {
} from './BlokusLogic.js';
import { chooseMove, nextThinkDelay } from './BlokusAI.js';
const DEPTH = { felt: -1, grid: 0, corner: 1, piece: 10, ghost: 20, hud: 30, button: 35, banner: 50, modal: 80 };
const DEPTH = { felt: -1, grid: 0, corner: 1, trayBg: 2, piece: 10, ghost: 20, hud: 30, button: 35, banner: 50, modal: 80 };
// Right-hand panel layout.
const TRAY_X = 1035;
const TRAY_X = 1020;
const TRAY_Y = 96;
const TRAY_COLS = 3;
const TRAY_SLOT_W = 160;
const TRAY_SLOT_H = 92;
const TRAY_CELL = 15;
const SIDE_X = 1710; // centre of the button / score column
const SIDE_X = 1730; // centre of the button / score column
export default class BlokusGame extends Phaser.Scene {
constructor() { super('BlokusGame'); }
@ -61,6 +61,7 @@ export default class BlokusGame extends Phaser.Scene {
}
this.buildBoard();
this.buildTrayBackground();
this.pieceGfx = this.add.graphics().setDepth(DEPTH.piece);
this.ghostGfx = this.add.graphics().setDepth(DEPTH.ghost);
@ -105,6 +106,18 @@ export default class BlokusGame extends Phaser.Scene {
});
}
buildTrayBackground() {
const pad = 12;
const trayW = TRAY_COLS * TRAY_SLOT_W;
// Bottom edge of the Pass button (center y=898, height=48)
const panelBottom = 922;
const g = this.add.graphics().setDepth(DEPTH.trayBg);
g.fillStyle(COLORS.panel, 0.88);
g.fillRoundedRect(TRAY_X - pad, TRAY_Y - pad, trayW + pad * 2, panelBottom - TRAY_Y + pad * 2, 14);
g.lineStyle(2, COLORS.accent, 0.9);
g.strokeRoundedRect(TRAY_X - pad, TRAY_Y - pad, trayW + pad * 2, panelBottom - TRAY_Y + pad * 2, 14);
}
buildBoardInput() {
const zone = this.add.zone(GRID_X, GRID_Y, BOARD_PX, BOARD_PX).setOrigin(0)
.setInteractive({ useHandCursor: false });

View File

@ -130,10 +130,14 @@ export const NOBLES = NOBLE_REQS.map((requires, i) => ({
}));
// ── Spritesheet frame mapping (270×390 cells, row-major) ─────────────────────
// Layout authored later: 089 = development cards (catalog order),
// 9099 = nobles, 100102 = tier deck backs. Code falls back to vector drawing
// when the 'splendor-cards' texture is absent, so these are advisory until art
// exists.
export function cardFrame(card) { return card.frame; }
export function nobleFrame(noble) { return 90 + noble.frame; }
export function deckBackFrame(tier) { return 100 + (tier - 1); }
// 28 frames total:
// 014 = card backgrounds: (tier-1)*5 + GEMS.indexOf(bonus)
// tier1: white(0) blue(1) green(2) red(3) black(4)
// tier2: white(5) blue(6) green(7) red(8) black(9)
// tier3: white(10) blue(11) green(12) red(13) black(14)
// 1524 = nobles 09 (unique art each)
// 2527 = deck backs for tier 1, 2, 3
// Vectors (cost pips, points, bonus badge) are always drawn on top of the sprite.
export function cardFrame(card) { return (card.tier - 1) * 5 + GEMS.indexOf(card.bonus); }
export function nobleFrame(noble) { return 15 + noble.frame; }
export function deckBackFrame(tier) { return 25 + (tier - 1); }

View File

@ -6,7 +6,7 @@ import { api } from '../../services/api.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
GEMS, GOLD, GEM_HEX, GEM_EDGE, GEM_NAME, cardFrame, nobleFrame,
GEMS, GOLD, GEM_HEX, GEM_EDGE, GEM_NAME, cardFrame, nobleFrame, deckBackFrame,
WIN_POINTS, MAX_RESERVED, TAKE_SAME_MIN,
} from './SplendorData.js';
import {
@ -47,6 +47,7 @@ export default class SplendorGame extends Phaser.Scene {
this.selection = []; // colours chosen for a take action
this.selectedCard = null; // { card, source } chosen to buy/reserve
this.dyn = []; // dynamic objects rebuilt every render
this.preview = []; // hover card preview — cleared separately
}
create() {
@ -96,10 +97,71 @@ export default class SplendorGame extends Phaser.Scene {
reg(obj) { this.dyn.push(obj); return obj; }
clearDyn() {
this.clearPreview();
for (const o of this.dyn) { try { o.destroy(); } catch { /* noop */ } }
this.dyn = [];
}
clearPreview() {
for (const o of this.preview) { try { o.destroy(); } catch { /* noop */ } }
this.preview = [];
}
showCardPreview(card, barCenterX, barBottomY) {
this.clearPreview();
const pw = 270, ph = 390;
const rp = (obj) => { this.preview.push(obj); return obj; };
const px = Math.min(Math.max(barCenterX - pw / 2, 8), GAME_WIDTH - pw - 8);
const pyBelow = barBottomY + 4;
const py = pyBelow + ph > GAME_HEIGHT - 8 ? barBottomY - ph - 4 : pyBelow;
// Drop shadow panel
rp(this.add.graphics().setDepth(DEPTH.popup))
.fillStyle(0x000000, 0.65).fillRoundedRect(px - 7, py - 7, pw + 14, ph + 14, 14);
// Card background (same tinted-rect approach as drawCard)
const g = rp(this.add.graphics().setDepth(DEPTH.popup));
const tint = GEM_HEX[card.bonus];
g.fillStyle(0x14110b, 1).fillRoundedRect(px, py, pw, ph, 10);
g.fillStyle(tint, 0.20).fillRoundedRect(px, py, pw, ph, 10);
if (!this.hasArt) {
g.fillStyle(tint, 0.9).fillRoundedRect(px, py, pw, 65, { tl: 10, tr: 10, bl: 0, br: 0 });
}
g.lineStyle(3, GEM_EDGE[card.bonus], 1).strokeRoundedRect(px, py, pw, ph, 10);
if (this.hasArt) {
rp(this.add.image(px + pw / 2, py + ph / 2, 'splendor-cards', cardFrame(card))
.setDisplaySize(pw, ph).setDepth(DEPTH.popup));
}
// Points
if (card.points > 0) {
rp(this.add.text(px + 18, py + 8, String(card.points), {
fontFamily: 'Righteous', fontSize: '54px',
color: card.bonus === 'white' ? '#222' : '#fff',
}).setDepth(DEPTH.popup + 1));
}
// Bonus gem badge top-right
const bg = rp(this.add.graphics().setDepth(DEPTH.popup + 1));
bg.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(px + pw - 40, py + 36, 26);
bg.lineStyle(3, GEM_EDGE[card.bonus], 1).strokeCircle(px + pw - 40, py + 36, 26);
// Cost pips bottom-left stacked
const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0);
costs.forEach((c, i) => {
const cy = py + ph - 44 - i * 62;
const pg = rp(this.add.graphics().setDepth(DEPTH.popup + 1));
pg.fillStyle(GEM_HEX[c], 1).fillCircle(px + 40, cy, 26);
pg.lineStyle(3, GEM_EDGE[c], 1).strokeCircle(px + 40, cy, 26);
rp(this.add.text(px + 40, cy, String(card.cost[c]), {
fontFamily: 'Righteous', fontSize: '28px',
color: c === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.popup + 2));
});
}
render() {
this.clearDyn();
this.drawNobles();
@ -121,11 +183,11 @@ export default class SplendorGame extends Phaser.Scene {
this.reg(this.add.image(x + NOBLE / 2, NOBLE_Y + NOBLE / 2, 'splendor-cards', nobleFrame(n))
.setDisplaySize(NOBLE, NOBLE).setDepth(DEPTH.card + 1));
}
// prestige
// prestige (always drawn over art)
this.reg(this.add.text(x + 8, NOBLE_Y + 4, '3', {
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.goldHex,
}).setDepth(DEPTH.card + 2));
// requirement chips along the bottom
// requirement chips along the bottom (always drawn over art)
const reqs = Object.entries(n.requires);
reqs.forEach(([color, req], j) => {
const cx = x + 16 + j * 30, cy = NOBLE_Y + NOBLE - 18;
@ -150,12 +212,16 @@ export default class SplendorGame extends Phaser.Scene {
dg.fillStyle(tier === 3 ? 0x2a3550 : tier === 2 ? 0x3a4a2a : 0x4a3a2a, 1)
.fillRoundedRect(MARKET_X, y, DECK_W, CH, 10);
dg.lineStyle(2, 0x000000, 0.6).strokeRoundedRect(MARKET_X, y, DECK_W, CH, 10);
if (deckN > 0 && this.hasArt) {
this.reg(this.add.image(MARKET_X + DECK_W / 2, y + CH / 2, 'splendor-cards', deckBackFrame(tier))
.setDisplaySize(DECK_W, CH).setDepth(DEPTH.card + 1));
}
this.reg(this.add.text(MARKET_X + DECK_W / 2, y + 30, '★'.repeat(tier), {
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
}).setOrigin(0.5).setDepth(DEPTH.card + 2));
this.reg(this.add.text(MARKET_X + DECK_W / 2, y + CH - 26, `${deckN}`, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
}).setOrigin(0.5).setDepth(DEPTH.card + 2));
if (deckN > 0 && this.isHumanTurn()) {
const zone = this.reg(this.add.zone(MARKET_X + DECK_W / 2, y + CH / 2, DECK_W, CH)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 2));
@ -176,41 +242,46 @@ export default class SplendorGame extends Phaser.Scene {
const affordable = this.isHumanTurn() && canAfford(human, card);
const selected = this.selectedCard && this.selectedCard.card.id === card.id;
// Card background — always draw the tinted rect (provides border shape and colour identity)
const g = this.reg(this.add.graphics().setDepth(DEPTH.card + 1));
const tint = GEM_HEX[card.bonus];
g.fillStyle(0x14110b, 1).fillRoundedRect(x, y, CW, CH, 10);
g.fillStyle(tint, 0.20).fillRoundedRect(x, y, CW, CH, 10);
if (!this.hasArt) {
g.fillStyle(tint, 0.9).fillRoundedRect(x, y, CW, 30, { tl: 10, tr: 10, bl: 0, br: 0 });
}
g.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
// Spritesheet background art drawn over the tinted rect when available
if (this.hasArt) {
this.reg(this.add.image(x + CW / 2, y + CH / 2, 'splendor-cards', cardFrame(card))
.setDisplaySize(CW, CH).setDepth(DEPTH.card + 1));
} else {
const g = this.reg(this.add.graphics().setDepth(DEPTH.card + 1));
const tint = GEM_HEX[card.bonus];
g.fillStyle(0x14110b, 1).fillRoundedRect(x, y, CW, CH, 10);
g.fillStyle(tint, 0.20).fillRoundedRect(x, y, CW, CH, 10);
g.fillStyle(tint, 0.9).fillRoundedRect(x, y, CW, 30, { tl: 10, tr: 10, bl: 0, br: 0 });
g.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeRoundedRect(x, y, CW, CH, 10);
// points
if (card.points > 0) {
this.reg(this.add.text(x + 10, y + 2, String(card.points), {
fontFamily: 'Righteous', fontSize: '26px',
color: card.bonus === 'white' ? '#222' : '#fff',
}).setDepth(DEPTH.card + 2));
}
// bonus gem badge (top-right)
const bg = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
bg.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(x + CW - 22, y + 15, 12);
bg.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeCircle(x + CW - 22, y + 15, 12);
// cost pips, bottom-left stacked
const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0);
costs.forEach((c, i) => {
const cy = y + CH - 22 - i * 30;
const pg = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
pg.fillStyle(GEM_HEX[c], 1).fillCircle(x + 20, cy, 12);
pg.lineStyle(2, GEM_EDGE[c], 1).strokeCircle(x + 20, cy, 12);
this.reg(this.add.text(x + 20, cy, String(card.cost[c]), {
fontFamily: 'Righteous', fontSize: '15px',
color: c === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.card + 3));
});
}
// Vector overlays — always drawn on top of whatever background is present
if (card.points > 0) {
this.reg(this.add.text(x + 10, y + 2, String(card.points), {
fontFamily: 'Righteous', fontSize: '26px',
color: card.bonus === 'white' ? '#222' : '#fff',
}).setDepth(DEPTH.card + 2));
}
// bonus gem badge (top-right)
const bg = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
bg.fillStyle(GEM_HEX[card.bonus], 1).fillCircle(x + CW - 22, y + 15, 12);
bg.lineStyle(2, GEM_EDGE[card.bonus], 1).strokeCircle(x + CW - 22, y + 15, 12);
// cost pips, bottom-left stacked
const costs = GEMS.filter((c) => (card.cost[c] ?? 0) > 0);
costs.forEach((c, i) => {
const cy = y + CH - 22 - i * 30;
const pg = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
pg.fillStyle(GEM_HEX[c], 1).fillCircle(x + 20, cy, 12);
pg.lineStyle(2, GEM_EDGE[c], 1).strokeCircle(x + 20, cy, 12);
this.reg(this.add.text(x + 20, cy, String(card.cost[c]), {
fontFamily: 'Righteous', fontSize: '15px',
color: c === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.card + 3));
});
// affordability / selection ring
if (selected || affordable) {
const ring = this.reg(this.add.graphics().setDepth(DEPTH.card + 4));
@ -328,9 +399,12 @@ export default class SplendorGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '14px',
color: card.bonus === 'white' ? '#222' : '#fff',
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
const z = this.reg(this.add.zone(rx + 52, ry + 15, 104, 30)
.setInteractive(this.isHumanTurn() ? { useHandCursor: true } : {})
.setDepth(DEPTH.ui + 3));
z.on('pointerover', () => this.showCardPreview(card, rx + 52, ry + 30));
z.on('pointerout', () => this.clearPreview());
if (this.isHumanTurn()) {
const z = this.reg(this.add.zone(rx + 52, ry + 15, 104, 30)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3));
z.on('pointerdown', () => this.onCardClick(card, 'reserve'));
}
});

View File

@ -0,0 +1,62 @@
# Splendor Spritesheet Spec
**File:** `public/assets/images/splendor-cards.png`
**Cell size:** 270 × 390 px
**Total frames:** 28, row-major order
Vectors (cost pips, point value, bonus badge, requirement chips) are always drawn on top of the sprite by the game engine — art only needs to provide the background illustration.
## Development Card Backgrounds (frames 014)
One background per tier × bonus color. All cards sharing the same tier and bonus color use the same frame.
| Frame | Tier | Bonus Color |
|-------|------|-------------|
| 0 | 1 | White (Diamond) |
| 1 | 1 | Blue (Sapphire) |
| 2 | 1 | Green (Emerald) |
| 3 | 1 | Red (Ruby) |
| 4 | 1 | Black (Onyx) |
| 5 | 2 | White (Diamond) |
| 6 | 2 | Blue (Sapphire) |
| 7 | 2 | Green (Emerald) |
| 8 | 2 | Red (Ruby) |
| 9 | 2 | Black (Onyx) |
| 10 | 3 | White (Diamond) |
| 11 | 3 | Blue (Sapphire) |
| 12 | 3 | Green (Emerald) |
| 13 | 3 | Red (Ruby) |
| 14 | 3 | Black (Onyx) |
## Nobles (frames 1524)
Each noble has unique art. Nobles are displayed at 96 × 96 px (square), so design the main subject centered within the 270 × 390 cell.
| Frame | Requirements |
|-------|-------------|
| 15 | White ×4, Blue ×4 |
| 16 | Blue ×4, Green ×4 |
| 17 | Green ×4, Red ×4 |
| 18 | Red ×4, Black ×4 |
| 19 | Black ×4, White ×4 |
| 20 | White ×3, Blue ×3, Green ×3 |
| 21 | Blue ×3, Green ×3, Red ×3 |
| 22 | Green ×3, Red ×3, Black ×3 |
| 23 | Red ×3, Black ×3, White ×3 |
| 24 | Black ×3, White ×3, Blue ×3 |
## Deck Backs (frames 2527)
| Frame | Tier |
|-------|------|
| 25 | Tier 1 |
| 26 | Tier 2 |
| 27 | Tier 3 |
## Display Sizes (for reference)
| Element | Rendered size |
|---------|--------------|
| Development cards | 128 × 176 px |
| Nobles | 96 × 96 px |
| Deck backs | 110 × 176 px |

View File

@ -98,8 +98,8 @@ export default class PreloadScene extends Phaser.Scene {
this.load.spritesheet('dominion-prosperity', '/assets/images/dominion-prosperity.png', { frameWidth: 270, frameHeight: 390 });
// Prosperity token sprites (1 VP, 5 VP, Gold) — 150×150 each.
this.load.spritesheet('dominion-tokens', '/assets/images/dominion-tokens.png', { frameWidth: 150, frameHeight: 150 });
// Splendor development cards + nobles. 270×390 cells; frame order documented
// in SplendorData.js. Optional — the scene draws vector cards when absent.
// Splendor: 28 frames at 270×390. 0-14 = tier×bonus backgrounds, 15-24 = nobles, 25-27 = deck backs.
// Frame order documented in SplendorData.js. Optional — vectors are drawn when absent.
this.load.spritesheet('splendor-cards', '/assets/images/splendor-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('ttr-cards', '/assets/images/tickettoride-cards.png', { frameWidth: 270, frameHeight: 390 });
this.load.spritesheet('gofish-cards', '/assets/images/gofish-cards.png', { frameWidth: 270, frameHeight: 390 });