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:
parent
04a32cebcd
commit
e60387ec7c
Binary file not shown.
|
After Width: | Height: | Size: 1022 KiB |
Binary file not shown.
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -130,10 +130,14 @@ export const NOBLES = NOBLE_REQS.map((requires, i) => ({
|
|||
}));
|
||||
|
||||
// ── Spritesheet frame mapping (270×390 cells, row-major) ─────────────────────
|
||||
// Layout authored later: 0–89 = development cards (catalog order),
|
||||
// 90–99 = nobles, 100–102 = 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:
|
||||
// 0–14 = 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)
|
||||
// 15–24 = nobles 0–9 (unique art each)
|
||||
// 25–27 = 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); }
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 0–14)
|
||||
|
||||
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 15–24)
|
||||
|
||||
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 25–27)
|
||||
|
||||
| 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 |
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue