feat: add Splendor board game support
- Register Splendor in server game registry with metadata - Import and register SplendorGame in frontend main.js - Map 'splendor' slug to SplendorGame in GameRoomScene - Preload splendor-cards spritesheet in PreloadScene - Update game-icons assets for the new game
This commit is contained in:
parent
7858263c22
commit
6a33bf500b
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 154 KiB |
Binary file not shown.
|
|
@ -0,0 +1,99 @@
|
|||
// Splendor AI — synchronous, in-browser heuristic. 1-ply greedy over a board
|
||||
// evaluation, with a skill model (1–5) that adds blunders / score noise and
|
||||
// picks from a widening top-N, exactly the shape used by Connect4AI / IslandAI.
|
||||
|
||||
import { GEMS, GOLD, WIN_POINTS } from './SplendorData.js';
|
||||
import { legalActions, applyAction, purchaseCost } from './SplendorLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { topN: 5, blunder: 0.45, noise: 40, delay: [700, 1200] },
|
||||
2: { topN: 4, blunder: 0.25, noise: 22, delay: [650, 1100] },
|
||||
3: { topN: 3, blunder: 0.12, noise: 12, delay: [600, 1000] },
|
||||
4: { topN: 2, blunder: 0.04, noise: 5, delay: [520, 900] },
|
||||
5: { topN: 1, blunder: 0.00, noise: 0, delay: [440, 820] },
|
||||
};
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
// Coloured tokens (excluding gold) still required for `card` after bonuses and
|
||||
// the player's current tokens — i.e. how far off buying it they are.
|
||||
function shortfall(p, card) {
|
||||
let s = 0;
|
||||
for (const color of GEMS) {
|
||||
s += Math.max(0, (card.cost[color] ?? 0) - (p.bonuses[color] ?? 0) - p.tokens[color]);
|
||||
}
|
||||
return Math.max(0, s - p.tokens[GOLD]);
|
||||
}
|
||||
|
||||
// Evaluate the whole position from `seat`'s perspective (higher = better).
|
||||
function evalPlayer(state, seat) {
|
||||
const p = state.players[seat];
|
||||
let v = p.points * 100;
|
||||
|
||||
// Permanent engine: each card bonus is lasting buying power; reward breadth.
|
||||
let bonusSum = 0, distinct = 0;
|
||||
for (const g of GEMS) { bonusSum += p.bonuses[g]; if (p.bonuses[g] > 0) distinct++; }
|
||||
v += bonusSum * 9 + distinct * 4;
|
||||
|
||||
// Tokens are minor liquid value; gold (wild) is worth a touch more.
|
||||
for (const g of GEMS) v += p.tokens[g] * 1.0;
|
||||
v += p.tokens[GOLD] * 1.6;
|
||||
|
||||
// Progress toward the nobles still on the table.
|
||||
for (const n of state.nobles) {
|
||||
let prog = 0;
|
||||
for (const [color, req] of Object.entries(n.requires)) {
|
||||
prog += Math.min(p.bonuses[color] ?? 0, req);
|
||||
}
|
||||
v += prog * 2;
|
||||
}
|
||||
|
||||
// Reach: how close is the best high-value card we could aim for?
|
||||
let best = 0;
|
||||
const targets = [];
|
||||
for (const t of [1, 2, 3]) for (const c of state.board[t]) if (c) targets.push(c);
|
||||
for (const c of p.reserved) targets.push(c);
|
||||
for (const c of targets) {
|
||||
const reach = c.points * 6 - shortfall(p, c) * 1.5;
|
||||
if (reach > best) best = reach;
|
||||
}
|
||||
v += best;
|
||||
|
||||
// Mild nudge to close out the game when in front.
|
||||
if (p.points >= WIN_POINTS) v += 500;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Returns a single action object for the current player, or { type: 'pass' }.
|
||||
export function chooseAction(state, skill) {
|
||||
const prof = profileFor(skill);
|
||||
const seat = state.current;
|
||||
const legal = legalActions(state);
|
||||
if (legal.length === 0) return { type: 'pass' };
|
||||
if (legal.length === 1) return legal[0];
|
||||
|
||||
const ranked = legal
|
||||
.map((action) => {
|
||||
let score = evalPlayer(applyAction(state, action), seat);
|
||||
score += (Math.random() * 2 - 1) * prof.noise;
|
||||
return { action, score };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (Math.random() < prof.blunder) {
|
||||
return legal[Math.floor(Math.random() * legal.length)];
|
||||
}
|
||||
const pool = ranked.slice(0, Math.max(1, prof.topN));
|
||||
return pool[Math.floor(Math.random() * pool.length)].action;
|
||||
}
|
||||
|
||||
// Re-export so the scene can resolve AI / quick discards without importing Logic
|
||||
// for that one helper.
|
||||
export { defaultDiscards } from './SplendorLogic.js';
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
// Splendor — static catalog. No Phaser, no state, no mutations.
|
||||
// The full development-card deck (90 cards across 3 tiers), the nobles, the gem
|
||||
// palette, and the spritesheet frame mapping all live here. The Logic and Game
|
||||
// modules import from this file; nothing here imports back.
|
||||
|
||||
// Gem colours. Order is fixed and used everywhere (token bank, costs, frames).
|
||||
export const GEMS = ['white', 'blue', 'green', 'red', 'black'];
|
||||
export const GOLD = 'gold';
|
||||
|
||||
// Display palette for vector-drawn tokens / pips / bonus badges. Chosen to read
|
||||
// clearly on the dark felt; `black` is lightened so it stays visible.
|
||||
export const GEM_HEX = {
|
||||
white: 0xe9e4d6,
|
||||
blue: 0x2f6fb0,
|
||||
green: 0x3f9b54,
|
||||
red: 0xc0392b,
|
||||
black: 0x3c3a36,
|
||||
gold: 0xd4a017,
|
||||
};
|
||||
// A contrasting edge colour so same-on-same (e.g. white token on light) reads.
|
||||
export const GEM_EDGE = {
|
||||
white: 0x9c9684,
|
||||
blue: 0x1b4c80,
|
||||
green: 0x2a6e3a,
|
||||
red: 0x8a2419,
|
||||
black: 0x16140f,
|
||||
gold: 0x8a6a0c,
|
||||
};
|
||||
|
||||
export const GEM_NAME = {
|
||||
white: 'Diamond', blue: 'Sapphire', green: 'Emerald', red: 'Ruby', black: 'Onyx', gold: 'Gold',
|
||||
};
|
||||
|
||||
// Rules constants.
|
||||
export const WIN_POINTS = 15;
|
||||
export const HAND_LIMIT = 10; // max tokens held at end of a turn
|
||||
export const MAX_RESERVED = 3;
|
||||
export const FACE_UP_PER_TIER = 4;
|
||||
export const TAKE_SAME_MIN = 4; // pile must have ≥4 to take 2 of one colour
|
||||
export const NOBLE_POINTS = 3;
|
||||
|
||||
// Per-colour token supply scales with player count; gold is always 5.
|
||||
export function tokenSupplyFor(playerCount) {
|
||||
const per = playerCount <= 2 ? 4 : playerCount === 3 ? 5 : 7;
|
||||
const bank = {};
|
||||
for (const g of GEMS) bank[g] = per;
|
||||
bank[GOLD] = 5;
|
||||
return bank;
|
||||
}
|
||||
|
||||
// ── Development-card deck ────────────────────────────────────────────────────
|
||||
// Built from the real Splendor structure: each bonus colour gets a fixed set of
|
||||
// cards per tier (8 / 6 / 4), with costs assigned to the *other* colours by a
|
||||
// cyclic offset, then rotated across all five colours. This reproduces the
|
||||
// official counts (T1=40, T2=30, T3=20 = 90) and a balanced cost/point spread.
|
||||
//
|
||||
// A template's `c` maps an offset (1..4 = the next colours after the bonus, in
|
||||
// GEMS order, wrapping) to a token cost. `p` is the prestige points.
|
||||
const TIER_TEMPLATES = {
|
||||
1: [
|
||||
{ p: 0, c: { 1: 1, 2: 1, 3: 1, 4: 1 } },
|
||||
{ p: 0, c: { 1: 2, 2: 1, 3: 1, 4: 1 } },
|
||||
{ p: 0, c: { 1: 2, 2: 2, 4: 1 } },
|
||||
{ p: 0, c: { 2: 1, 3: 3, 4: 1 } },
|
||||
{ p: 0, c: { 1: 2, 4: 2 } },
|
||||
{ p: 0, c: { 2: 3 } },
|
||||
{ p: 0, c: { 1: 1, 2: 1, 3: 2, 4: 1 } },
|
||||
{ p: 1, c: { 3: 4 } },
|
||||
],
|
||||
2: [
|
||||
{ p: 1, c: { 1: 3, 2: 2, 3: 2 } },
|
||||
{ p: 1, c: { 2: 3, 3: 3, 4: 2 } },
|
||||
{ p: 2, c: { 1: 5 } },
|
||||
{ p: 2, c: { 3: 5, 4: 3 } },
|
||||
{ p: 2, c: { 1: 2, 2: 3, 4: 3 } },
|
||||
{ p: 3, c: { 2: 6 } },
|
||||
],
|
||||
3: [
|
||||
{ p: 3, c: { 1: 3, 2: 5, 3: 3, 4: 3 } },
|
||||
{ p: 4, c: { 2: 6, 3: 3, 4: 3 } },
|
||||
{ p: 4, c: { 1: 7 } },
|
||||
{ p: 5, c: { 1: 7, 4: 3 } },
|
||||
],
|
||||
};
|
||||
|
||||
function buildCards() {
|
||||
const cards = [];
|
||||
let frame = 0;
|
||||
for (const tier of [1, 2, 3]) {
|
||||
for (let ci = 0; ci < GEMS.length; ci++) {
|
||||
const bonus = GEMS[ci];
|
||||
for (const tpl of TIER_TEMPLATES[tier]) {
|
||||
const cost = {};
|
||||
for (const [offsetStr, amount] of Object.entries(tpl.c)) {
|
||||
const color = GEMS[(ci + Number(offsetStr)) % GEMS.length];
|
||||
cost[color] = (cost[color] ?? 0) + amount;
|
||||
}
|
||||
cards.push({ id: `c${frame}`, frame, tier, bonus, points: tpl.p, cost });
|
||||
frame++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
// Flat catalog, ordered tier1(all colours) → tier2 → tier3. `frame` === array index.
|
||||
export const CARDS = buildCards();
|
||||
export const CARDS_BY_TIER = {
|
||||
1: CARDS.filter((c) => c.tier === 1),
|
||||
2: CARDS.filter((c) => c.tier === 2),
|
||||
3: CARDS.filter((c) => c.tier === 3),
|
||||
};
|
||||
|
||||
// ── Nobles ───────────────────────────────────────────────────────────────────
|
||||
// 10 nobles; each needs a set of card bonuses and awards 3 prestige.
|
||||
const NOBLE_REQS = [
|
||||
{ white: 4, blue: 4 },
|
||||
{ blue: 4, green: 4 },
|
||||
{ green: 4, red: 4 },
|
||||
{ red: 4, black: 4 },
|
||||
{ black: 4, white: 4 },
|
||||
{ white: 3, blue: 3, green: 3 },
|
||||
{ blue: 3, green: 3, red: 3 },
|
||||
{ green: 3, red: 3, black: 3 },
|
||||
{ red: 3, black: 3, white: 3 },
|
||||
{ black: 3, white: 3, blue: 3 },
|
||||
];
|
||||
export const NOBLES = NOBLE_REQS.map((requires, i) => ({
|
||||
id: `n${i}`, frame: i, points: NOBLE_POINTS, requires,
|
||||
}));
|
||||
|
||||
// ── 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); }
|
||||
|
|
@ -0,0 +1,610 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { auth } from '../../services/auth.js';
|
||||
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,
|
||||
WIN_POINTS, MAX_RESERVED, TAKE_SAME_MIN,
|
||||
} from './SplendorData.js';
|
||||
import {
|
||||
createInitialState, legalActions, applyAction, applyDiscard, defaultDiscards,
|
||||
canAfford, tokenTotal, isGameOver, finalRanking, currentPlayer,
|
||||
} from './SplendorLogic.js';
|
||||
import { HAND_LIMIT } from './SplendorData.js';
|
||||
import { chooseAction, nextThinkDelay } from './SplendorAI.js';
|
||||
|
||||
// ── Layout ───────────────────────────────────────────────────────────────────
|
||||
const CW = 128, CH = 176, CARD_GAP = 16; // development card size
|
||||
const DECK_W = 110;
|
||||
const MARKET_X = 80; // left edge of decks
|
||||
const CARDS_X = MARKET_X + DECK_W + 24; // first face-up card
|
||||
const TIER_Y = { 3: 168, 2: 168 + CH + 26, 1: 168 + (CH + 26) * 2 };
|
||||
const NOBLE = 96, NOBLE_Y = 56;
|
||||
|
||||
const BANK_X = 830, BANK_Y0 = 176, BANK_STEP = 88, TOKEN_R = 36;
|
||||
|
||||
const PANEL_X = 1036, PANEL_W = GAME_WIDTH - PANEL_X - 28;
|
||||
const CONTROL_Y = 794; // bottom context bar
|
||||
|
||||
const DEPTH = { bg: 0, board: 10, card: 14, ui: 40, popup: 60, banner: 90 };
|
||||
|
||||
const hexStr = (n) => '#' + (n >>> 0).toString(16).padStart(6, '0').slice(-6);
|
||||
|
||||
export default class SplendorGame extends Phaser.Scene {
|
||||
constructor() { super('SplendorGame'); }
|
||||
|
||||
init(data) {
|
||||
this._initData = { ...data };
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.humanSeat = 0;
|
||||
this.gs = null;
|
||||
this.busy = false;
|
||||
this.selection = []; // colours chosen for a take action
|
||||
this.selectedCard = null; // { card, source } chosen to buy/reserve
|
||||
this.dyn = []; // dynamic objects rebuilt every render
|
||||
}
|
||||
|
||||
create() {
|
||||
try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch { /* optional */ }
|
||||
this.hasArt = this.textures.exists('splendor-cards');
|
||||
this.buildBackground();
|
||||
|
||||
const playerCount = Math.max(2, Math.min(4, 1 + this.opponents.length));
|
||||
this.skillBySeat = {};
|
||||
const names = [];
|
||||
for (let seat = 0; seat < playerCount; seat++) {
|
||||
if (seat === this.humanSeat) {
|
||||
names.push(auth.user?.username ?? 'You');
|
||||
this.skillBySeat[seat] = 5;
|
||||
} else {
|
||||
const opp = this.opponents[seat - 1];
|
||||
names.push(opp?.name ?? `Player ${seat}`);
|
||||
this.skillBySeat[seat] = Math.max(1, Math.min(5, opp?.skill ?? 3));
|
||||
}
|
||||
}
|
||||
|
||||
this.gs = createInitialState({ playerCount, names });
|
||||
this.render();
|
||||
this.advance();
|
||||
}
|
||||
|
||||
// ── static background / chrome ──────────────────────────────────────────────
|
||||
buildBackground() {
|
||||
const pf = this.playfield;
|
||||
if (pf?.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
} else {
|
||||
const g = this.add.graphics().setDepth(DEPTH.bg);
|
||||
g.fillGradientStyle(0x14110b, 0x14110b, 0x070503, 0x070503, 1);
|
||||
g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
}
|
||||
this.add.text(GAME_WIDTH / 2, 26, 'Splendor', {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
|
||||
|
||||
new Button(this, GAME_WIDTH - 96, 40, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 140, height: 42, fontSize: 18 }).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
// ── render: rebuild all dynamic visuals from this.gs ─────────────────────────
|
||||
reg(obj) { this.dyn.push(obj); return obj; }
|
||||
|
||||
clearDyn() {
|
||||
for (const o of this.dyn) { try { o.destroy(); } catch { /* noop */ } }
|
||||
this.dyn = [];
|
||||
}
|
||||
|
||||
render() {
|
||||
this.clearDyn();
|
||||
this.drawNobles();
|
||||
this.drawMarket();
|
||||
this.drawBank();
|
||||
this.drawPanels();
|
||||
this.drawControlBar();
|
||||
this.drawTurnLabel();
|
||||
}
|
||||
|
||||
// ── nobles ──────────────────────────────────────────────────────────────────
|
||||
drawNobles() {
|
||||
this.gs.nobles.forEach((n, i) => {
|
||||
const x = MARKET_X + i * (NOBLE + 14);
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.card));
|
||||
g.fillStyle(0x2b2620, 1).fillRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
|
||||
g.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(x, NOBLE_Y, NOBLE, NOBLE, 10);
|
||||
if (this.hasArt) {
|
||||
this.reg(this.add.image(x + NOBLE / 2, NOBLE_Y + NOBLE / 2, 'splendor-cards', nobleFrame(n))
|
||||
.setDisplaySize(NOBLE, NOBLE).setDepth(DEPTH.card + 1));
|
||||
}
|
||||
// prestige
|
||||
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
|
||||
const reqs = Object.entries(n.requires);
|
||||
reqs.forEach(([color, req], j) => {
|
||||
const cx = x + 16 + j * 30, cy = NOBLE_Y + NOBLE - 18;
|
||||
const c = this.reg(this.add.graphics().setDepth(DEPTH.card + 2));
|
||||
c.fillStyle(GEM_HEX[color], 1).fillCircle(cx, cy, 11);
|
||||
c.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(cx, cy, 11);
|
||||
this.reg(this.add.text(cx, cy, String(req), {
|
||||
fontFamily: 'Righteous', fontSize: '14px',
|
||||
color: color === 'white' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.card + 3));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── market ──────────────────────────────────────────────────────────────────
|
||||
drawMarket() {
|
||||
for (const tier of [3, 2, 1]) {
|
||||
const y = TIER_Y[tier];
|
||||
// deck pile
|
||||
const deckN = this.gs.decks[tier].length;
|
||||
const dg = this.reg(this.add.graphics().setDepth(DEPTH.card));
|
||||
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);
|
||||
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));
|
||||
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));
|
||||
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));
|
||||
zone.on('pointerdown', () => this.onDeckClick(tier));
|
||||
}
|
||||
|
||||
// face-up cards
|
||||
this.gs.board[tier].forEach((card, i) => {
|
||||
if (!card) return;
|
||||
const x = CARDS_X + i * (CW + CARD_GAP);
|
||||
this.drawCard(card, x, y, 'board');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawCard(card, x, y, source) {
|
||||
const human = this.gs.players[this.humanSeat];
|
||||
const affordable = this.isHumanTurn() && canAfford(human, card);
|
||||
const selected = this.selectedCard && this.selectedCard.card.id === card.id;
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
// affordability / selection ring
|
||||
if (selected || affordable) {
|
||||
const ring = this.reg(this.add.graphics().setDepth(DEPTH.card + 4));
|
||||
ring.lineStyle(4, selected ? COLORS.gold : 0x57c46a, selected ? 1 : 0.85)
|
||||
.strokeRoundedRect(x - 3, y - 3, CW + 6, CH + 6, 12);
|
||||
}
|
||||
|
||||
if (this.isHumanTurn()) {
|
||||
const zone = this.reg(this.add.zone(x + CW / 2, y + CH / 2, CW, CH)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 5));
|
||||
zone.on('pointerdown', () => this.onCardClick(card, source));
|
||||
}
|
||||
}
|
||||
|
||||
// ── token bank ──────────────────────────────────────────────────────────────
|
||||
drawBank() {
|
||||
const order = [...GEMS, GOLD];
|
||||
order.forEach((color, i) => {
|
||||
const cx = BANK_X, cy = BANK_Y0 + i * BANK_STEP;
|
||||
const n = this.gs.bank[color];
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.card));
|
||||
g.fillStyle(GEM_HEX[color], n > 0 ? 1 : 0.25).fillCircle(cx, cy, TOKEN_R);
|
||||
g.lineStyle(3, GEM_EDGE[color], 1).strokeCircle(cx, cy, TOKEN_R);
|
||||
this.reg(this.add.text(cx, cy, String(n), {
|
||||
fontFamily: 'Righteous', fontSize: '26px',
|
||||
color: color === 'white' || color === 'gold' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.card + 1));
|
||||
this.reg(this.add.text(cx + TOKEN_R + 14, cy, GEM_NAME[color], {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.card + 1));
|
||||
// selection count overlay
|
||||
const sel = this.selection.filter((c) => c === color).length;
|
||||
if (sel > 0) {
|
||||
this.reg(this.add.text(cx - TOKEN_R - 6, cy - TOKEN_R, `+${sel}`, {
|
||||
fontFamily: 'Righteous', fontSize: '20px', color: COLORS.goldHex,
|
||||
}).setOrigin(1, 0).setDepth(DEPTH.card + 2));
|
||||
}
|
||||
if (this.isHumanTurn() && color !== GOLD && n > 0) {
|
||||
const zone = this.reg(this.add.zone(cx, cy, TOKEN_R * 2, TOKEN_R * 2)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.card + 2));
|
||||
zone.on('pointerdown', () => this.onTokenClick(color));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── player panels ───────────────────────────────────────────────────────────
|
||||
drawPanels() {
|
||||
const n = this.gs.players.length;
|
||||
const gap = 16;
|
||||
const h = Math.min(220, Math.floor((GAME_HEIGHT - 90 - gap * (n - 1)) / n));
|
||||
this.gs.players.forEach((p, idx) => {
|
||||
const x = PANEL_X, y = 76 + idx * (h + gap), w = PANEL_W;
|
||||
const isCurrent = idx === this.gs.current && !isGameOver(this.gs);
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
|
||||
g.fillStyle(0x000000, 0.34).fillRoundedRect(x, y, w, h, 12);
|
||||
g.lineStyle(isCurrent ? 3 : 1, isCurrent ? COLORS.gold : COLORS.accent, isCurrent ? 1 : 0.4)
|
||||
.strokeRoundedRect(x, y, w, h, 12);
|
||||
|
||||
const youTag = idx === this.humanSeat ? ' (you)' : '';
|
||||
this.reg(this.add.text(x + 16, y + 10, `${p.name}${youTag}`, {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
this.reg(this.add.text(x + w - 16, y + 8, `${p.points}`, {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
|
||||
}).setOrigin(1, 0).setDepth(DEPTH.ui + 1));
|
||||
this.reg(this.add.text(x + w - 70, y + 18, 'pts', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(1, 0).setDepth(DEPTH.ui + 1));
|
||||
|
||||
// gem cells: cards (bonus) over tokens
|
||||
const order = [...GEMS, GOLD];
|
||||
const cellW = (w - 32) / order.length;
|
||||
order.forEach((color, ci) => {
|
||||
const ccx = x + 16 + cellW * ci + cellW / 2;
|
||||
const ccy = y + 70;
|
||||
const cards = color === GOLD ? 0 : (p.bonuses[color] ?? 0);
|
||||
const toks = p.tokens[color] ?? 0;
|
||||
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
cg.fillStyle(GEM_HEX[color], 1).fillCircle(ccx, ccy, 16);
|
||||
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(ccx, ccy, 16);
|
||||
if (color !== GOLD) {
|
||||
this.reg(this.add.text(ccx, ccy, String(cards), {
|
||||
fontFamily: 'Righteous', fontSize: '16px',
|
||||
color: color === 'white' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
|
||||
}
|
||||
this.reg(this.add.text(ccx, ccy + 24, `${toks}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
|
||||
// discard interactivity (human, over limit)
|
||||
const discardable = this.gs.phase === 'discard' && this.gs.current === this.humanSeat
|
||||
&& idx === this.humanSeat && toks > 0;
|
||||
if (discardable) {
|
||||
const z = this.reg(this.add.zone(ccx, ccy + 8, cellW, 56)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.ui + 3));
|
||||
z.on('pointerdown', () => this.onDiscardClick(color));
|
||||
}
|
||||
});
|
||||
|
||||
// reserved cards
|
||||
const rN = p.reserved.length;
|
||||
this.reg(this.add.text(x + 16, y + h - 30, `Reserved: ${rN}/${MAX_RESERVED}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
if (idx === this.humanSeat && rN > 0) {
|
||||
p.reserved.forEach((card, ri) => {
|
||||
const rx = x + 150 + ri * 116, ry = y + h - 40;
|
||||
const canBuy = this.isHumanTurn() && canAfford(p, card);
|
||||
const mg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
mg.fillStyle(GEM_HEX[card.bonus], 0.85).fillRoundedRect(rx, ry, 104, 30, 6);
|
||||
mg.lineStyle(2, canBuy ? 0x57c46a : GEM_EDGE[card.bonus], canBuy ? 1 : 0.8)
|
||||
.strokeRoundedRect(rx, ry, 104, 30, 6);
|
||||
this.reg(this.add.text(rx + 52, ry + 15,
|
||||
`${card.points || ''} ${'★'.repeat(card.tier)}`.trim(), {
|
||||
fontFamily: 'Righteous', fontSize: '14px',
|
||||
color: card.bonus === 'white' ? '#222' : '#fff',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui + 2));
|
||||
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'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── bottom control bar ──────────────────────────────────────────────────────
|
||||
drawControlBar() {
|
||||
const x = MARKET_X, y = CONTROL_Y, w = 920, h = GAME_HEIGHT - y - 24;
|
||||
const g = this.reg(this.add.graphics().setDepth(DEPTH.ui));
|
||||
g.fillStyle(0x000000, 0.3).fillRoundedRect(x, y, w, h, 12);
|
||||
g.lineStyle(1, COLORS.accent, 0.4).strokeRoundedRect(x, y, w, h, 12);
|
||||
|
||||
if (isGameOver(this.gs)) return;
|
||||
|
||||
// Discard prompt
|
||||
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
|
||||
const over = tokenTotal(currentPlayer(this.gs)) - HAND_LIMIT;
|
||||
this.reg(this.add.text(x + 20, y + 18,
|
||||
`Over the ${HAND_LIMIT}-token limit — return ${over} token${over === 1 ? '' : 's'}.`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
this.reg(this.add.text(x + 20, y + 52, 'Click your tokens in the panel at right to return them.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isHumanTurn()) {
|
||||
this.reg(this.add.text(x + 20, y + 22, `${this.pname(this.gs.current)} is thinking…`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Human turn — card selected → buy/reserve options
|
||||
if (this.selectedCard) {
|
||||
const { card, source } = this.selectedCard;
|
||||
const human = this.gs.players[this.humanSeat];
|
||||
this.reg(this.add.text(x + 20, y + 16,
|
||||
`Tier ${card.tier} • ${GEM_NAME[card.bonus]}${card.points ? ` • ${card.points} pts` : ''}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
let bx = x + 24;
|
||||
if (canAfford(human, card)) {
|
||||
this.addButton(bx + 80, y + 70, 'Buy', () => this.applyHuman({ type: 'buy', cardId: card.id, source }),
|
||||
{ width: 150, height: 46 });
|
||||
bx += 170;
|
||||
}
|
||||
if (source === 'board' && human.reserved.length < MAX_RESERVED) {
|
||||
this.addButton(bx + 80, y + 70, 'Reserve (+1 gold)',
|
||||
() => this.applyHuman({ type: 'reserve', cardId: card.id, tier: card.tier }),
|
||||
{ width: 230, height: 46, variant: 'ghost' });
|
||||
bx += 250;
|
||||
}
|
||||
this.addButton(bx + 70, y + 70, 'Cancel', () => { this.selectedCard = null; this.render(); },
|
||||
{ width: 130, height: 46, variant: 'ghost' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Token selection
|
||||
if (this.selection.length > 0) {
|
||||
this.reg(this.add.text(x + 20, y + 16, 'Taking gems:', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
this.selection.forEach((color, i) => {
|
||||
const cx = x + 160 + i * 44;
|
||||
const cg = this.reg(this.add.graphics().setDepth(DEPTH.ui + 1));
|
||||
cg.fillStyle(GEM_HEX[color], 1).fillCircle(cx, y + 26, 16);
|
||||
cg.lineStyle(2, GEM_EDGE[color], 1).strokeCircle(cx, y + 26, 16);
|
||||
});
|
||||
this.addButton(x + 110, y + 78, 'Take', () => this.confirmTake(), { width: 160, height: 44 });
|
||||
this.addButton(x + 290, y + 78, 'Clear', () => { this.selection = []; this.render(); },
|
||||
{ width: 140, height: 44, variant: 'ghost' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.reg(this.add.text(x + 20, y + 26,
|
||||
'Your turn — take gems (3 different, or 2 of one), or click a card to buy or reserve.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.textHex,
|
||||
wordWrap: { width: w - 40 },
|
||||
}).setDepth(DEPTH.ui + 1));
|
||||
}
|
||||
|
||||
addButton(x, y, label, fn, opts = {}) {
|
||||
const b = new Button(this, x, y, label, fn, { fontSize: 20, ...opts }).setDepth(DEPTH.ui + 2);
|
||||
this.dyn.push(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
drawTurnLabel() {
|
||||
const txt = isGameOver(this.gs) ? 'Game over' : `${this.pname(this.gs.current)}'s turn`;
|
||||
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 70, txt, {
|
||||
fontFamily: 'Righteous', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
|
||||
this.reg(this.add.text(BANK_X, GAME_HEIGHT - 40, `First to ${WIN_POINTS} prestige wins`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0, 0.5).setDepth(DEPTH.ui + 1));
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
isHumanTurn() {
|
||||
return !this.busy && this.gs.phase === 'turn' && this.gs.current === this.humanSeat;
|
||||
}
|
||||
pname(seat) { return this.gs.players[seat]?.name ?? `Player ${seat}`; }
|
||||
|
||||
// ── human input ─────────────────────────────────────────────────────────────
|
||||
onTokenClick(color) {
|
||||
if (!this.isHumanTurn()) return;
|
||||
this.selectedCard = null;
|
||||
const sel = this.selection;
|
||||
const sameCount = sel.filter((c) => c === color).length;
|
||||
|
||||
if (sel.length === 0) { sel.push(color); }
|
||||
else if (sel.length === 1 && sel[0] === color) {
|
||||
// second of the same → take2 (needs a pile of ≥4)
|
||||
if (this.gs.bank[color] >= TAKE_SAME_MIN) sel.push(color);
|
||||
} else if (sel.length < 3 && sameCount === 0) {
|
||||
// a different colour, only if we aren't already on a take2
|
||||
const isTake2 = sel.length === 2 && sel[0] === sel[1];
|
||||
if (!isTake2) sel.push(color);
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
confirmTake() {
|
||||
const sel = this.selection;
|
||||
if (sel.length === 0) return;
|
||||
const action = (sel.length === 2 && sel[0] === sel[1])
|
||||
? { type: 'take2', color: sel[0] }
|
||||
: { type: 'take3', colors: [...new Set(sel)] };
|
||||
// guard against an illegal combo
|
||||
const ok = legalActions(this.gs).some((a) => this.sameAction(a, action));
|
||||
if (!ok) { this.selection = []; this.render(); return; }
|
||||
this.applyHuman(action);
|
||||
}
|
||||
|
||||
sameAction(a, b) {
|
||||
if (a.type !== b.type) return false;
|
||||
if (a.type === 'take2') return a.color === b.color;
|
||||
if (a.type === 'take3') {
|
||||
const as = [...a.colors].sort().join(), bs = [...b.colors].sort().join();
|
||||
return as === bs;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onCardClick(card, source) {
|
||||
if (!this.isHumanTurn()) return;
|
||||
this.selection = [];
|
||||
this.selectedCard = { card, source };
|
||||
this.render();
|
||||
}
|
||||
|
||||
onDeckClick(tier) {
|
||||
if (!this.isHumanTurn()) return;
|
||||
if (this.gs.players[this.humanSeat].reserved.length >= MAX_RESERVED) return;
|
||||
this.applyHuman({ type: 'reserveDeck', tier });
|
||||
}
|
||||
|
||||
onDiscardClick(color) {
|
||||
if (this.gs.phase !== 'discard' || this.gs.current !== this.humanSeat) return;
|
||||
this.gs = applyDiscard(this.gs, { [color]: 1 });
|
||||
if (this.gs.phase === 'turn' || isGameOver(this.gs)) {
|
||||
// discard completed → the turn finalized
|
||||
this.render();
|
||||
this.advance();
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
applyHuman(action) {
|
||||
this.selectedCard = null;
|
||||
this.selection = [];
|
||||
this.gs = applyAction(this.gs, action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard' && this.gs.current === this.humanSeat) {
|
||||
this.render(); // hand over to the discard UI
|
||||
return;
|
||||
}
|
||||
this.render();
|
||||
this.advance();
|
||||
}
|
||||
|
||||
// ── turn loop ───────────────────────────────────────────────────────────────
|
||||
advance() {
|
||||
if (isGameOver(this.gs)) { this.render(); this.onGameOver(); return; }
|
||||
if (this.gs.phase === 'discard') {
|
||||
// Only the human ever parks here interactively; AI auto-discards inline.
|
||||
this.busy = false;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (this.gs.current === this.humanSeat) {
|
||||
this.busy = false;
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
this.aiTurn();
|
||||
}
|
||||
|
||||
aiTurn() {
|
||||
this.busy = true;
|
||||
this.render();
|
||||
const seat = this.gs.current;
|
||||
const skill = this.skillBySeat[seat];
|
||||
this.showTurnBanner(`${this.pname(seat)}'s turn`);
|
||||
this.time.delayedCall(nextThinkDelay(skill), () => {
|
||||
const action = chooseAction(this.gs, skill);
|
||||
this.gs = applyAction(this.gs, action);
|
||||
this.playActionSound(action);
|
||||
if (this.gs.phase === 'discard') {
|
||||
this.gs = applyDiscard(this.gs, defaultDiscards(this.gs));
|
||||
}
|
||||
this.busy = false;
|
||||
this.render();
|
||||
this.advance();
|
||||
});
|
||||
}
|
||||
|
||||
playActionSound(action) {
|
||||
if (!action) return;
|
||||
if (action.type === 'buy') playSound(this, SFX.PURCHASE);
|
||||
else if (action.type === 'reserve' || action.type === 'reserveDeck') playSound(this, SFX.CARD_DEAL);
|
||||
else if (action.type === 'take2' || action.type === 'take3') playSound(this, SFX.COINS);
|
||||
}
|
||||
|
||||
showTurnBanner(text) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const banner = this.add.text(cx, 130, text, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||
backgroundColor: '#111923ee', padding: { x: 24, y: 10 },
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner);
|
||||
this.tweens.add({
|
||||
targets: banner, y: 160, duration: 280, ease: 'Back.easeOut',
|
||||
onComplete: () => this.time.delayedCall(800, () =>
|
||||
this.tweens.add({ targets: banner, alpha: 0, y: 130, duration: 220, onComplete: () => banner.destroy() })),
|
||||
});
|
||||
}
|
||||
|
||||
// ── game over ───────────────────────────────────────────────────────────────
|
||||
onGameOver() {
|
||||
this.busy = true;
|
||||
const ranking = finalRanking(this.gs);
|
||||
const humanRank = ranking.findIndex((r) => r.seat === this.humanSeat);
|
||||
const won = humanRank === 0;
|
||||
const humanPoints = this.gs.players[this.humanSeat].points;
|
||||
const oppScores = ranking.filter((r) => r.seat !== this.humanSeat).map((r) => r.points);
|
||||
this.recordResult(won ? 'win' : 'loss', humanPoints, oppScores);
|
||||
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const w = 640, h = 120 + ranking.length * 46;
|
||||
this.add.rectangle(cx, cy, w, h, 0x0a0e14, 0.94).setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
|
||||
this.add.text(cx, cy - h / 2 + 30, won ? '🎉 You win!' : 'Game over', {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: won ? COLORS.goldHex : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||
ranking.forEach((r, i) => {
|
||||
this.add.text(cx, cy - h / 2 + 80 + i * 40,
|
||||
`${i + 1}. ${this.pname(r.seat)}${r.seat === this.humanSeat ? ' (you)' : ''} — ${r.points} pts (${r.cards} cards)`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px',
|
||||
color: i === 0 ? COLORS.goldHex : COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||
});
|
||||
new Button(this, cx - 110, cy + h / 2 - 36, 'Play Again', () => this.scene.restart(this._initData),
|
||||
{ width: 190, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
new Button(this, cx + 110, cy + h / 2 - 36, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ width: 190, fontSize: 22, variant: 'ghost' }).setDepth(DEPTH.banner + 1);
|
||||
}
|
||||
|
||||
async recordResult(result, score, opponentScores) {
|
||||
try {
|
||||
await api.post('/history/single-player', { slug: 'splendor', score, opponentScores, result });
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
// Splendor — pure state engine. No Phaser, no network, no rendering.
|
||||
//
|
||||
// State is immutable from the outside: every mutator deep-clones and returns a
|
||||
// fresh state (the IslandLogic/BlokusLogic idiom). Randomness is seeded so games
|
||||
// are reproducible and testable. A turn is exactly one action (take3 / take2 /
|
||||
// reserve / buy); if it leaves the player over the 10-token limit the state
|
||||
// parks in a 'discard' phase until tokens are returned, then the turn finalizes
|
||||
// (noble visit + end-of-game check + advance).
|
||||
|
||||
import {
|
||||
GEMS, GOLD, CARDS_BY_TIER, NOBLES, tokenSupplyFor,
|
||||
WIN_POINTS, HAND_LIMIT, MAX_RESERVED, FACE_UP_PER_TIER, TAKE_SAME_MIN, NOBLE_POINTS,
|
||||
} from './SplendorData.js';
|
||||
|
||||
const TIERS = [1, 2, 3];
|
||||
|
||||
// ── seeded RNG (mulberry32), matching IslandLogic ────────────────────────────
|
||||
function rngFrom(seedState) {
|
||||
let a = seedState >>> 0;
|
||||
return () => {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function shuffle(arr, rng) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const clone = (s) => JSON.parse(JSON.stringify(s));
|
||||
const emptyTokens = () => ({ white: 0, blue: 0, green: 0, red: 0, black: 0, gold: 0 });
|
||||
const emptyBonuses = () => ({ white: 0, blue: 0, green: 0, red: 0, black: 0 });
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────────────────
|
||||
export function currentPlayer(state) { return state.players[state.current]; }
|
||||
|
||||
export function tokenTotal(p) {
|
||||
return GEMS.reduce((n, g) => n + p.tokens[g], 0) + p.tokens[GOLD];
|
||||
}
|
||||
|
||||
// How a player would pay for `card`: coloured tokens used per colour + gold to
|
||||
// cover the shortfall after permanent bonuses.
|
||||
export function purchaseCost(p, card) {
|
||||
const pay = {};
|
||||
let gold = 0;
|
||||
for (const color of GEMS) {
|
||||
const need = Math.max(0, (card.cost[color] ?? 0) - (p.bonuses[color] ?? 0));
|
||||
const use = Math.min(need, p.tokens[color]);
|
||||
pay[color] = use;
|
||||
gold += need - use;
|
||||
}
|
||||
return { pay, gold };
|
||||
}
|
||||
|
||||
export function canAfford(p, card) {
|
||||
return purchaseCost(p, card).gold <= p.tokens[GOLD];
|
||||
}
|
||||
|
||||
// Nobles the player currently qualifies for.
|
||||
export function qualifyingNobles(state, p) {
|
||||
return state.nobles.filter((n) =>
|
||||
Object.entries(n.requires).every(([color, req]) => (p.bonuses[color] ?? 0) >= req));
|
||||
}
|
||||
|
||||
export function cardById(state, id) {
|
||||
for (const t of TIERS) {
|
||||
const found = state.board[t].find((c) => c && c.id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
for (const p of state.players) {
|
||||
const r = p.reserved.find((c) => c.id === id);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── construction ─────────────────────────────────────────────────────────────
|
||||
export function createInitialState({ playerCount = 4, names = [], seed } = {}) {
|
||||
const pc = Math.max(2, Math.min(4, playerCount));
|
||||
const s = {
|
||||
seed: (seed ?? Math.floor(Math.random() * 1e9)) >>> 0,
|
||||
rngCursor: 0,
|
||||
playerCount: pc,
|
||||
bank: tokenSupplyFor(pc),
|
||||
decks: {},
|
||||
board: {},
|
||||
nobles: [],
|
||||
players: [],
|
||||
current: 0,
|
||||
phase: 'turn', // turn | discard | gameOver
|
||||
pendingDiscardSeat: null,
|
||||
triggeredEnd: false,
|
||||
lastVisit: null, // { seat, nobleId } for the scene to animate
|
||||
log: [],
|
||||
};
|
||||
|
||||
const rng = rngFrom((s.seed + 0x9e3779b9) >>> 0);
|
||||
for (const t of TIERS) {
|
||||
const deck = shuffle(CARDS_BY_TIER[t], rng);
|
||||
s.board[t] = deck.splice(0, FACE_UP_PER_TIER);
|
||||
while (s.board[t].length < FACE_UP_PER_TIER) s.board[t].push(null);
|
||||
s.decks[t] = deck;
|
||||
}
|
||||
s.nobles = shuffle(NOBLES, rng).slice(0, pc + 1);
|
||||
|
||||
for (let seat = 0; seat < pc; seat++) {
|
||||
s.players.push({
|
||||
seat,
|
||||
name: names[seat] ?? (seat === 0 ? 'You' : `Player ${seat}`),
|
||||
tokens: emptyTokens(),
|
||||
bonuses: emptyBonuses(),
|
||||
reserved: [],
|
||||
nobles: [],
|
||||
points: 0,
|
||||
cardsCount: 0,
|
||||
});
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── legal actions ────────────────────────────────────────────────────────────
|
||||
function combinations(arr, k) {
|
||||
if (k === 0) return [[]];
|
||||
if (arr.length < k) return [];
|
||||
const [head, ...rest] = arr;
|
||||
return [
|
||||
...combinations(rest, k - 1).map((c) => [head, ...c]),
|
||||
...combinations(rest, k),
|
||||
];
|
||||
}
|
||||
|
||||
export function legalActions(state) {
|
||||
if (state.phase !== 'turn') return [];
|
||||
const p = currentPlayer(state);
|
||||
const actions = [];
|
||||
|
||||
// take 3 different (or as many distinct colours as the bank can offer, up to 3)
|
||||
const avail = GEMS.filter((g) => state.bank[g] > 0);
|
||||
const k = Math.min(3, avail.length);
|
||||
if (k > 0) {
|
||||
for (const combo of combinations(avail, k)) {
|
||||
actions.push({ type: 'take3', colors: combo });
|
||||
}
|
||||
}
|
||||
|
||||
// take 2 of one colour (pile must have ≥4)
|
||||
for (const g of GEMS) {
|
||||
if (state.bank[g] >= TAKE_SAME_MIN) actions.push({ type: 'take2', color: g });
|
||||
}
|
||||
|
||||
// reserve (face-up card or top of a deck) — gains 1 gold if any left
|
||||
if (p.reserved.length < MAX_RESERVED) {
|
||||
for (const t of TIERS) {
|
||||
for (const c of state.board[t]) {
|
||||
if (c) actions.push({ type: 'reserve', cardId: c.id, tier: t });
|
||||
}
|
||||
if (state.decks[t].length > 0) actions.push({ type: 'reserveDeck', tier: t });
|
||||
}
|
||||
}
|
||||
|
||||
// buy a face-up card or one of your reserved cards
|
||||
for (const t of TIERS) {
|
||||
for (const c of state.board[t]) {
|
||||
if (c && canAfford(p, c)) actions.push({ type: 'buy', cardId: c.id, source: 'board' });
|
||||
}
|
||||
}
|
||||
for (const c of p.reserved) {
|
||||
if (canAfford(p, c)) actions.push({ type: 'buy', cardId: c.id, source: 'reserve' });
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
// ── helpers used by applyAction ──────────────────────────────────────────────
|
||||
function drawTop(s, tier) {
|
||||
return s.decks[tier].length ? s.decks[tier].shift() : null;
|
||||
}
|
||||
|
||||
function refillSlot(s, tier, cardId) {
|
||||
const row = s.board[tier];
|
||||
const idx = row.findIndex((c) => c && c.id === cardId);
|
||||
if (idx >= 0) row[idx] = drawTop(s, tier);
|
||||
}
|
||||
|
||||
// After tokens change, either park for discard or finalize the turn.
|
||||
function afterAction(s) {
|
||||
if (tokenTotal(currentPlayer(s)) > HAND_LIMIT) {
|
||||
s.phase = 'discard';
|
||||
s.pendingDiscardSeat = s.current;
|
||||
return s;
|
||||
}
|
||||
return finalizeTurn(s);
|
||||
}
|
||||
|
||||
// Noble visit, win trigger, advance to next seat.
|
||||
function finalizeTurn(s) {
|
||||
const p = currentPlayer(s);
|
||||
const eligible = qualifyingNobles(s, p);
|
||||
if (eligible.length) {
|
||||
// Award the highest-requirement noble (ties: first). One visit per turn.
|
||||
const noble = eligible[0];
|
||||
s.nobles = s.nobles.filter((n) => n.id !== noble.id);
|
||||
p.nobles.push(noble);
|
||||
p.points += NOBLE_POINTS;
|
||||
s.lastVisit = { seat: p.seat, nobleId: noble.id };
|
||||
} else {
|
||||
s.lastVisit = null;
|
||||
}
|
||||
|
||||
if (p.points >= WIN_POINTS) s.triggeredEnd = true;
|
||||
|
||||
s.current = (s.current + 1) % s.playerCount;
|
||||
s.phase = (s.triggeredEnd && s.current === 0) ? 'gameOver' : 'turn';
|
||||
s.pendingDiscardSeat = null;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ── apply a turn action ──────────────────────────────────────────────────────
|
||||
export function applyAction(state, action) {
|
||||
const s = clone(state);
|
||||
if (s.phase !== 'turn') return s;
|
||||
const p = currentPlayer(s);
|
||||
|
||||
switch (action.type) {
|
||||
case 'take3': {
|
||||
for (const g of action.colors) { s.bank[g]--; p.tokens[g]++; }
|
||||
s.log.push(`${p.name} takes ${action.colors.join(', ')}.`);
|
||||
return afterAction(s);
|
||||
}
|
||||
case 'take2': {
|
||||
s.bank[action.color] -= 2; p.tokens[action.color] += 2;
|
||||
s.log.push(`${p.name} takes 2 ${action.color}.`);
|
||||
return afterAction(s);
|
||||
}
|
||||
case 'reserve': {
|
||||
const card = cardById(s, action.cardId);
|
||||
if (!card) return s;
|
||||
refillSlot(s, action.tier, action.cardId);
|
||||
p.reserved.push(card);
|
||||
if (s.bank[GOLD] > 0) { s.bank[GOLD]--; p.tokens[GOLD]++; }
|
||||
s.log.push(`${p.name} reserves a tier-${action.tier} card.`);
|
||||
return afterAction(s);
|
||||
}
|
||||
case 'reserveDeck': {
|
||||
const card = drawTop(s, action.tier);
|
||||
if (!card) return s;
|
||||
p.reserved.push(card);
|
||||
if (s.bank[GOLD] > 0) { s.bank[GOLD]--; p.tokens[GOLD]++; }
|
||||
s.log.push(`${p.name} reserves the top tier-${action.tier} card.`);
|
||||
return afterAction(s);
|
||||
}
|
||||
case 'buy': {
|
||||
const card = cardById(s, action.cardId);
|
||||
if (!card || !canAfford(p, card)) return s;
|
||||
const { pay, gold } = purchaseCost(p, card);
|
||||
for (const color of GEMS) { p.tokens[color] -= pay[color]; s.bank[color] += pay[color]; }
|
||||
p.tokens[GOLD] -= gold; s.bank[GOLD] += gold;
|
||||
p.bonuses[card.bonus]++;
|
||||
p.points += card.points;
|
||||
p.cardsCount++;
|
||||
if (action.source === 'reserve') {
|
||||
p.reserved = p.reserved.filter((c) => c.id !== card.id);
|
||||
} else {
|
||||
refillSlot(s, card.tier, card.id);
|
||||
}
|
||||
s.log.push(`${p.name} buys a ${card.bonus} card${card.points ? ` (+${card.points})` : ''}.`);
|
||||
return afterAction(s);
|
||||
}
|
||||
case 'pass':
|
||||
// No legal action available (bank empty, nothing affordable, reserve full).
|
||||
s.log.push(`${p.name} cannot act and passes.`);
|
||||
return finalizeTurn(s);
|
||||
default:
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
// ── discard phase ────────────────────────────────────────────────────────────
|
||||
// Default choice: return the most-abundant coloured tokens first, never gold.
|
||||
export function defaultDiscards(state) {
|
||||
const p = currentPlayer(state);
|
||||
const excess = tokenTotal(p) - HAND_LIMIT;
|
||||
const map = {};
|
||||
let left = Math.max(0, excess);
|
||||
// Greedily shave from the largest non-gold piles.
|
||||
const pools = GEMS.map((g) => ({ g, n: p.tokens[g] }));
|
||||
while (left > 0) {
|
||||
pools.sort((a, b) => (b.n - (map[b.g] ?? 0)) - (a.n - (map[a.g] ?? 0)));
|
||||
const top = pools[0];
|
||||
if ((top.n - (map[top.g] ?? 0)) <= 0) break;
|
||||
map[top.g] = (map[top.g] ?? 0) + 1;
|
||||
left--;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function applyDiscard(state, discardMap) {
|
||||
const s = clone(state);
|
||||
if (s.phase !== 'discard') return s;
|
||||
const p = currentPlayer(s);
|
||||
for (const [color, n] of Object.entries(discardMap)) {
|
||||
const take = Math.min(n, p.tokens[color] ?? 0);
|
||||
p.tokens[color] -= take;
|
||||
s.bank[color] += take;
|
||||
}
|
||||
if (tokenTotal(p) > HAND_LIMIT) {
|
||||
// Still over (incomplete discard) — stay parked.
|
||||
return s;
|
||||
}
|
||||
s.phase = 'turn';
|
||||
return finalizeTurn(s);
|
||||
}
|
||||
|
||||
// ── end of game ──────────────────────────────────────────────────────────────
|
||||
export function isGameOver(state) { return state.phase === 'gameOver'; }
|
||||
|
||||
// Ranking: most prestige, then fewest purchased cards (official tiebreak).
|
||||
export function finalRanking(state) {
|
||||
return state.players
|
||||
.map((p) => ({ seat: p.seat, name: p.name, points: p.points, cards: p.cardsCount }))
|
||||
.sort((a, b) => (b.points - a.points) || (a.cards - b.cards));
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ import SpellingBeeGame from './games/spellingbee/SpellingBeeGame.js';
|
|||
import MiniCrosswordGame from './games/minicrossword/MiniCrosswordGame.js';
|
||||
import ForbiddenIslandGame from './games/forbiddenisland/ForbiddenIslandGame.js';
|
||||
import SolitaireTourGame from './games/solitairetour/SolitaireTourGame.js';
|
||||
import SplendorGame from './games/splendor/SplendorGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -115,6 +116,7 @@ const config = {
|
|||
MiniCrosswordGame,
|
||||
ForbiddenIslandGame,
|
||||
SolitaireTourGame,
|
||||
SplendorGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ 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.
|
||||
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 });
|
||||
this.load.spritesheet('oldmaid-cards', '/assets/images/oldmaid-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||
|
|
|
|||
|
|
@ -66,3 +66,4 @@ registerGame({ slug: 'spellingbee', name: 'Spelling Bee', category: 'w
|
|||
registerGame({ slug: 'minicrossword', name: 'Mini Crossword', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 38 });
|
||||
registerGame({ slug: 'forbiddenisland', name: 'Forbidden Island', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, hasTutorial: false, iconFrame: 39 });
|
||||
registerGame({ slug: 'solitairetour', name: 'Solitaire Tour', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 40 });
|
||||
registerGame({ slug: 'splendor', name: 'Splendor', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 41 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue