feat: add Dominion card game with AI and full UI
- Implement pure state engine (`DominionLogic.js`) with turn lifecycle, card effects queue, and pending decision resolution for complex interactions. - Add card definitions and kingdom pool (`DominionCards.js`) supporting Standard and Random deck modes. - Create AI logic (`DominionAI.js`) using Big Money/early engine strategies scaled by opponent skill. - Build Phaser scene (`DominionGame.js`) with supply/hand rendering, turn drivers, modal prompts for pending decisions, and end-game scoring. - Wire up game registry, routing, preload assets, and opponent selection UI to support the new mode.
This commit is contained in:
parent
0f347e3502
commit
5a4f8a1204
|
|
@ -0,0 +1,253 @@
|
|||
// Dominion — AI decisions (pure). Big Money with light engine sense, scaled by
|
||||
// per-opponent skill (1–5). The scene drives an AI turn step by step:
|
||||
// • repeatedly call chooseAction() until it returns null → play that action
|
||||
// • play all treasures, then repeatedly call chooseBuy() → buy that card
|
||||
// • whenever state.pending targets this AI, call resolvePending()
|
||||
// Every function is read-only on state and returns a plain choice.
|
||||
|
||||
import {
|
||||
getCard, isType,
|
||||
} from './DominionCards.js';
|
||||
import {
|
||||
legalActionIids, allCards, handCoinValue,
|
||||
affordableSupply, canGain,
|
||||
} from './DominionLogic.js';
|
||||
|
||||
function countOwned(state, seat, id) {
|
||||
return allCards(state, seat).filter((c) => c.id === id).length;
|
||||
}
|
||||
|
||||
// ── Action phase ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Higher = play first among non-cantrip terminals.
|
||||
const TERMINAL_PRIORITY = {
|
||||
witch: 95, councilroom: 90, smithy: 85, militia: 80, library: 75,
|
||||
bandit: 70, bureaucrat: 65, mine: 60, remodel: 55, moneylender: 50,
|
||||
workshop: 45, throneroom: 40, chapel: 35, cellar: 30, artisan: 25, vassal: 20,
|
||||
};
|
||||
|
||||
export function chooseAction(state, seat) {
|
||||
const legal = legalActionIids(state);
|
||||
if (legal.length === 0) return null;
|
||||
const p = state.players[seat];
|
||||
const byIid = new Map(p.hand.map((c) => [c.iid, c]));
|
||||
|
||||
// Play cantrips / villages first (they replace the action they cost).
|
||||
const cantrips = legal.filter((iid) => getCard(byIid.get(iid).id).plus.actions >= 1);
|
||||
if (cantrips.length > 0) {
|
||||
// Prefer the one drawing the most cards, then the cheapest, to keep the chain alive.
|
||||
cantrips.sort((a, b) => {
|
||||
const ca = getCard(byIid.get(a).id), cb = getCard(byIid.get(b).id);
|
||||
return (cb.plus.cards - ca.plus.cards) || (ca.cost - cb.cost);
|
||||
});
|
||||
return cantrips[0];
|
||||
}
|
||||
|
||||
// Only terminals remain (one action left). Throne Room needs another action to be worth it.
|
||||
const terminals = legal.slice();
|
||||
terminals.sort((a, b) => {
|
||||
const ida = byIid.get(a).id, idb = byIid.get(b).id;
|
||||
return (TERMINAL_PRIORITY[idb] ?? 0) - (TERMINAL_PRIORITY[ida] ?? 0);
|
||||
});
|
||||
const best = terminals[0];
|
||||
if (byIid.get(best).id === 'throneroom') {
|
||||
const hasOther = p.hand.some((c) => c.iid !== best && isType(c.id, 'action'));
|
||||
if (!hasOther) {
|
||||
// Skip Throne Room; play the next-best terminal instead, if any.
|
||||
const alt = terminals.find((iid) => byIid.get(iid).id !== 'throneroom');
|
||||
return alt ?? best;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ── Buy phase ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Engine/attack cards worth buying, best first, with an ownership cap each.
|
||||
const ENGINE_BUYS = [
|
||||
{ id: 'witch', cap: 1 }, { id: 'laboratory', cap: 4 }, { id: 'market', cap: 4 },
|
||||
{ id: 'festival', cap: 3 }, { id: 'village', cap: 4 }, { id: 'smithy', cap: 2 },
|
||||
{ id: 'councilroom', cap: 2 }, { id: 'mine', cap: 1 }, { id: 'militia', cap: 1 },
|
||||
{ id: 'moneylender', cap: 1 }, { id: 'poacher', cap: 2 }, { id: 'merchant', cap: 2 },
|
||||
{ id: 'sentry', cap: 2 }, { id: 'harbinger', cap: 1 }, { id: 'remodel', cap: 1 },
|
||||
{ id: 'workshop', cap: 1 },
|
||||
];
|
||||
|
||||
export function chooseBuy(state, seat, skill = 3) {
|
||||
const p = state.players[seat];
|
||||
const coins = p.coins;
|
||||
if (p.buys <= 0) return null;
|
||||
const provincesLeft = state.supply.province ?? 0;
|
||||
|
||||
if (coins >= 8 && provincesLeft > 0) return 'province';
|
||||
|
||||
// Late-game greening — thresholds widen with skill (better players green sooner).
|
||||
const greenAt = 3 + Math.round(skill / 2);
|
||||
if (provincesLeft <= greenAt) {
|
||||
if (coins >= 5 && (state.supply.duchy ?? 0) > 0) return 'duchy';
|
||||
if (coins >= 2 && provincesLeft <= 2 && (state.supply.estate ?? 0) > 0) return 'estate';
|
||||
}
|
||||
|
||||
// Engine building (mid coins; never skip Gold/Province).
|
||||
if (skill >= 3 && coins <= 5) {
|
||||
for (const { id, cap } of ENGINE_BUYS) {
|
||||
if (!state.kingdom.includes(id)) continue;
|
||||
if (getCard(id).cost > coins) continue;
|
||||
if ((state.supply[id] ?? 0) <= 0) continue;
|
||||
if (countOwned(state, seat, id) >= cap) continue;
|
||||
// Keep terminals roughly balanced against villages at higher skill.
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
if (coins >= 6 && (state.supply.gold ?? 0) > 0) return 'gold';
|
||||
if (coins >= 3 && (state.supply.silver ?? 0) > 0) return 'silver';
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Pending decisions ──────────────────────────────────────────────────────────
|
||||
|
||||
// Disposability: higher = discard / get rid of first.
|
||||
function discardRank(id) {
|
||||
if (id === 'curse') return 100;
|
||||
if (isType(id, 'victory')) return 90;
|
||||
if (id === 'copper') return 50;
|
||||
if (isType(id, 'action')) return 30;
|
||||
if (id === 'silver') return 10;
|
||||
if (id === 'gold') return 5;
|
||||
return 20;
|
||||
}
|
||||
|
||||
function trashRank(id, copperKeep) {
|
||||
if (id === 'curse') return 100;
|
||||
if (id === 'estate') return 80;
|
||||
if (id === 'copper') return copperKeep ? 0 : 60;
|
||||
return 0; // never auto-trash anything else
|
||||
}
|
||||
|
||||
function bestGain(state, seat, maxCost, filterTreasure) {
|
||||
const options = affordableSupply(state, maxCost, filterTreasure);
|
||||
if (options.length === 0) return null;
|
||||
// Gain value ordering.
|
||||
const rank = (id) => {
|
||||
if (id === 'province') return 1000;
|
||||
if (id === 'gold') return 900;
|
||||
if (id === 'duchy' && (state.supply.province ?? 0) <= 4) return 850;
|
||||
const def = getCard(id);
|
||||
if (isType(id, 'action')) return 400 + def.cost * 10;
|
||||
if (id === 'silver') return 300;
|
||||
if (isType(id, 'treasure')) return 200 + def.cost;
|
||||
return def.cost; // victory/curse low unless flagged above
|
||||
};
|
||||
options.sort((a, b) => rank(b) - rank(a));
|
||||
return options[0];
|
||||
}
|
||||
|
||||
export function resolvePending(state, skill = 3) {
|
||||
const pend = state.pending;
|
||||
if (!pend) return {};
|
||||
const seat = pend.seat;
|
||||
const p = state.players[seat];
|
||||
|
||||
switch (pend.kind) {
|
||||
case 'cellarDiscard': {
|
||||
// Dump dead cards (victory/curse) to dig for fresh ones.
|
||||
const iids = p.hand.filter((c) => isType(c.id, 'victory') || c.id === 'curse').map((c) => c.iid);
|
||||
return { iids };
|
||||
}
|
||||
case 'chapelTrash': {
|
||||
const copperCount = countOwned(state, seat, 'copper');
|
||||
const keepCoppers = copperCount <= 3;
|
||||
const ranked = p.hand
|
||||
.map((c) => ({ c, r: trashRank(c.id, keepCoppers) }))
|
||||
.filter((x) => x.r > 0)
|
||||
.sort((a, b) => b.r - a.r)
|
||||
.slice(0, pend.max ?? 4);
|
||||
return { iids: ranked.map((x) => x.c.iid) };
|
||||
}
|
||||
case 'harbingerTopdeck': {
|
||||
// Topdeck the strongest non-dead card from the discard.
|
||||
const cand = p.discard
|
||||
.filter((c) => !isType(c.id, 'victory') && c.id !== 'curse' && c.id !== 'copper')
|
||||
.sort((a, b) => getCard(b.id).cost - getCard(a.id).cost);
|
||||
return { iid: cand[0]?.iid ?? null };
|
||||
}
|
||||
case 'vassalPlay':
|
||||
return { play: true };
|
||||
case 'gainFromSupply': {
|
||||
const id = bestGain(state, seat, pend.maxCost, pend.filterTreasure);
|
||||
return { id };
|
||||
}
|
||||
case 'artisanTopdeck': {
|
||||
// Prefer topdecking an action we'll replay; else the most disposable card.
|
||||
const action = p.hand.filter((c) => isType(c.id, 'action'))
|
||||
.sort((a, b) => getCard(b.id).cost - getCard(a.id).cost)[0];
|
||||
if (action) return { iid: action.iid };
|
||||
const worst = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id))[0];
|
||||
return { iid: worst?.iid ?? null };
|
||||
}
|
||||
case 'remodelTrash': {
|
||||
const greening = (state.supply.province ?? 0) <= 4;
|
||||
if (greening) {
|
||||
const gold = p.hand.find((c) => c.id === 'gold');
|
||||
if (gold && canGain(state, 'province', getCard('gold').cost + 2)) return { iid: gold.iid };
|
||||
}
|
||||
const estate = p.hand.find((c) => c.id === 'estate');
|
||||
if (estate) return { iid: estate.iid };
|
||||
const copper = p.hand.find((c) => c.id === 'copper');
|
||||
if (copper) return { iid: copper.iid };
|
||||
// Fall back to the cheapest card.
|
||||
const cheapest = p.hand.slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost)[0];
|
||||
return { iid: cheapest?.iid ?? null };
|
||||
}
|
||||
case 'mineTrash': {
|
||||
const silver = p.hand.find((c) => c.id === 'silver');
|
||||
if (silver) return { iid: silver.iid };
|
||||
const copper = p.hand.find((c) => c.id === 'copper');
|
||||
if (copper) return { iid: copper.iid };
|
||||
return { iid: null };
|
||||
}
|
||||
case 'moneylenderTrash':
|
||||
return { confirm: p.hand.some((c) => c.id === 'copper') };
|
||||
case 'throneChoose': {
|
||||
const actions = p.hand.filter((c) => isType(c.id, 'action') && c.id !== 'throneroom')
|
||||
.sort((a, b) => (TERMINAL_PRIORITY[b.id] ?? getCard(b.id).cost) - (TERMINAL_PRIORITY[a.id] ?? getCard(a.id).cost));
|
||||
return { iid: actions[0]?.iid ?? null };
|
||||
}
|
||||
case 'libraryKeep':
|
||||
// Keep the action if we still have actions to play it; otherwise set aside.
|
||||
return { keep: p.actions > 0 };
|
||||
case 'sentry': {
|
||||
const look = pend.cards ?? [];
|
||||
const trash = [], discard = [], top = [];
|
||||
for (const c of look) {
|
||||
if (c.id === 'curse' || c.id === 'estate' || (c.id === 'copper' && countOwned(state, seat, 'copper') > 3)) trash.push(c.iid);
|
||||
else if (isType(c.id, 'victory')) discard.push(c.iid);
|
||||
else top.push(c.iid);
|
||||
}
|
||||
return { trash, discard, top };
|
||||
}
|
||||
case 'poacherDiscard': {
|
||||
const ranked = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id));
|
||||
return { iids: ranked.slice(0, pend.count).map((c) => c.iid) };
|
||||
}
|
||||
case 'discardDownTo': {
|
||||
const ranked = p.hand.slice().sort((a, b) => discardRank(b.id) - discardRank(a.id));
|
||||
return { iids: ranked.slice(0, pend.count).map((c) => c.iid) };
|
||||
}
|
||||
case 'moatReveal':
|
||||
return { reveal: true };
|
||||
case 'banditTrash': {
|
||||
// Keep the more valuable treasure; trash the cheaper of the options.
|
||||
const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost);
|
||||
return { iid: opts[0]?.iid ?? null };
|
||||
}
|
||||
case 'bureaucratTopdeck': {
|
||||
// Topdeck the cheapest victory card to lose the least.
|
||||
const opts = (pend.options ?? []).slice().sort((a, b) => getCard(a.id).cost - getCard(b.id).cost);
|
||||
return { iid: opts[0]?.iid ?? null };
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
// Dominion — card definitions (pure data, no Phaser).
|
||||
//
|
||||
// Each card's `frame` is its index in the `dominion-cards` spritesheet
|
||||
// (270×390 per cell, art in the top ~60%; the title/icon band is drawn at
|
||||
// runtime). The art sheet must be laid out in this exact frame order:
|
||||
//
|
||||
// 0 copper 1 silver 2 gold 3 estate 4 duchy 5 province 6 curse
|
||||
// 7 cellar … 32 artisan (Kingdom cards, in the order declared below)
|
||||
//
|
||||
// `plus` holds the vanilla bonuses (+cards/+actions/+buys/+coins) which the
|
||||
// engine auto-applies and the UI renders as the icon summary. Everything
|
||||
// beyond vanilla lives in DominionLogic's CARD_EFFECTS registry.
|
||||
|
||||
const def = (id, frame, cost, types, extra = {}) => ({
|
||||
id, name: extra.name ?? titleCase(id), frame, cost, types,
|
||||
plus: { cards: 0, actions: 0, buys: 0, coins: 0, ...(extra.plus ?? {}) },
|
||||
coin: extra.coin, // treasure value
|
||||
vp: extra.vp, // fixed victory points (Gardens is dynamic → handled in logic)
|
||||
text: extra.text ?? '',
|
||||
});
|
||||
|
||||
function titleCase(id) {
|
||||
return id.replace(/(^|\s)\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// ── Card table ────────────────────────────────────────────────────────────────
|
||||
const LIST = [
|
||||
// Base cards
|
||||
def('copper', 0, 0, ['treasure'], { coin: 1, text: 'Worth 1 Coin.' }),
|
||||
def('silver', 1, 3, ['treasure'], { coin: 2, text: 'Worth 2 Coins.' }),
|
||||
def('gold', 2, 6, ['treasure'], { coin: 3, text: 'Worth 3 Coins.' }),
|
||||
def('estate', 3, 2, ['victory'], { vp: 1, text: 'Worth 1 Victory Point.' }),
|
||||
def('duchy', 4, 5, ['victory'], { vp: 3, text: 'Worth 3 Victory Points.' }),
|
||||
def('province', 5, 8, ['victory'], { vp: 6, text: 'Worth 6 Victory Points.' }),
|
||||
def('curse', 6, 0, ['curse'], { vp: -1, text: 'Worth -1 Victory Point.' }),
|
||||
|
||||
// Kingdom cards (2nd-edition base set)
|
||||
def('cellar', 7, 2, ['action'], { name: 'Cellar', plus: { actions: 1 }, text: '+1 Action. Discard any number of cards, then draw that many.' }),
|
||||
def('chapel', 8, 2, ['action'], { name: 'Chapel', text: 'Trash up to 4 cards from your hand.' }),
|
||||
def('moat', 9, 2, ['action', 'reaction'],{ name: 'Moat', plus: { cards: 2 }, text: '+2 Cards. When another player plays an Attack card, you may first reveal this from your hand, to be unaffected by it.' }),
|
||||
def('harbinger', 10, 3, ['action'], { name: 'Harbinger', plus: { cards: 1, actions: 1 }, text: '+1 Card, +1 Action. Look through your discard pile. You may put a card from it onto your deck.' }),
|
||||
def('merchant', 11, 3, ['action'], { name: 'Merchant', plus: { cards: 1, actions: 1 }, text: '+1 Card, +1 Action. The first time you play a Silver this turn, +1 Coin.' }),
|
||||
def('vassal', 12, 3, ['action'], { name: 'Vassal', plus: { coins: 2 }, text: '+2 Coins. Discard the top card of your deck. If it is an Action card, you may play it.' }),
|
||||
def('village', 13, 3, ['action'], { name: 'Village', plus: { cards: 1, actions: 2 }, text: '+1 Card, +2 Actions.' }),
|
||||
def('workshop', 14, 3, ['action'], { name: 'Workshop', text: 'Gain a card costing up to 4 Coins.' }),
|
||||
def('bureaucrat', 15, 4, ['action', 'attack'], { name: 'Bureaucrat', text: 'Gain a Silver onto your deck. Each other player reveals a Victory card from their hand and puts it onto their deck (or reveals a hand with no Victory cards).' }),
|
||||
def('gardens', 16, 4, ['victory'], { name: 'Gardens', text: 'Worth 1 Victory Point per 10 cards you have (round down).' }),
|
||||
def('militia', 17, 4, ['action', 'attack'], { name: 'Militia', plus: { coins: 2 }, text: '+2 Coins. Each other player discards down to 3 cards in hand.' }),
|
||||
def('moneylender',18, 4, ['action'], { name: 'Moneylender', text: 'You may trash a Copper from your hand for +3 Coins.' }),
|
||||
def('poacher', 19, 4, ['action'], { name: 'Poacher', plus: { cards: 1, actions: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Coin. Discard a card per empty Supply pile.' }),
|
||||
def('remodel', 20, 4, ['action'], { name: 'Remodel', text: 'Trash a card from your hand. Gain a card costing up to 2 Coins more than it.' }),
|
||||
def('smithy', 21, 4, ['action'], { name: 'Smithy', plus: { cards: 3 }, text: '+3 Cards.' }),
|
||||
def('throneroom', 22, 4, ['action'], { name: 'Throne Room', text: 'You may play an Action card from your hand twice.' }),
|
||||
def('bandit', 23, 5, ['action', 'attack'], { name: 'Bandit', text: 'Gain a Gold. Each other player reveals the top 2 cards of their deck, trashes a revealed Treasure other than Copper, and discards the rest.' }),
|
||||
def('councilroom',24, 5, ['action'], { name: 'Council Room', plus: { cards: 4, buys: 1 }, text: '+4 Cards, +1 Buy. Each other player draws a card.' }),
|
||||
def('festival', 25, 5, ['action'], { name: 'Festival', plus: { actions: 2, buys: 1, coins: 2 }, text: '+2 Actions, +1 Buy, +2 Coins.' }),
|
||||
def('laboratory', 26, 5, ['action'], { name: 'Laboratory', plus: { cards: 2, actions: 1 }, text: '+2 Cards, +1 Action.' }),
|
||||
def('library', 27, 5, ['action'], { name: 'Library', text: 'Draw until you have 7 cards in hand, skipping any Action cards you choose to; set those aside, discarding them afterwards.' }),
|
||||
def('market', 28, 5, ['action'], { name: 'Market', plus: { cards: 1, actions: 1, buys: 1, coins: 1 }, text: '+1 Card, +1 Action, +1 Buy, +1 Coin.' }),
|
||||
def('mine', 29, 5, ['action'], { name: 'Mine', text: 'You may trash a Treasure from your hand. Gain a Treasure to your hand costing up to 3 Coins more than it.' }),
|
||||
def('sentry', 30, 5, ['action'], { name: 'Sentry', plus: { cards: 1, actions: 1 }, text: '+1 Card, +1 Action. Look at the top 2 cards of your deck. Trash and/or discard any number of them. Put the rest back on top in any order.' }),
|
||||
def('witch', 31, 5, ['action', 'attack'], { name: 'Witch', plus: { cards: 2 }, text: '+2 Cards. Each other player gains a Curse.' }),
|
||||
def('artisan', 32, 6, ['action'], { name: 'Artisan', plus: {}, text: 'Gain a card to your hand costing up to 5 Coins. Put a card from your hand onto your deck.' }),
|
||||
];
|
||||
|
||||
export const CARDS = Object.fromEntries(LIST.map((c) => [c.id, c]));
|
||||
|
||||
export function getCard(id) {
|
||||
const c = CARDS[id];
|
||||
if (!c) throw new Error(`Unknown Dominion card: ${id}`);
|
||||
return c;
|
||||
}
|
||||
|
||||
export const BASE_TREASURES = ['copper', 'silver', 'gold'];
|
||||
export const BASE_VICTORY = ['estate', 'duchy', 'province'];
|
||||
|
||||
// The 26 Kingdom cards, in frame order — the pool Random mode draws from.
|
||||
export const KINGDOM_POOL = LIST.filter((c) => c.frame >= 7).map((c) => c.id);
|
||||
|
||||
// Standard mode: the official "First Game" recommended Kingdom.
|
||||
export const FIRST_GAME = [
|
||||
'cellar', 'market', 'merchant', 'militia', 'mine',
|
||||
'moat', 'remodel', 'smithy', 'village', 'workshop',
|
||||
];
|
||||
|
||||
export function hasType(card, type) {
|
||||
return card.types.includes(type);
|
||||
}
|
||||
|
||||
export function isType(id, type) {
|
||||
return getCard(id).types.includes(type);
|
||||
}
|
||||
|
||||
// Pick 10 distinct Kingdom ids from the pool using the supplied rng (0..1).
|
||||
export function chooseRandomKingdom(rand) {
|
||||
const pool = KINGDOM_POOL.slice();
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
return pool.slice(0, 10).sort((a, b) => getCard(a).cost - getCard(b).cost);
|
||||
}
|
||||
|
||||
export function kingdomFor(deckMode, rand) {
|
||||
if (deckMode === 'random') return chooseRandomKingdom(rand);
|
||||
return FIRST_GAME.slice().sort((a, b) => getCard(a).cost - getCard(b).cost);
|
||||
}
|
||||
|
|
@ -0,0 +1,940 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.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 { getCard, isType } from './DominionCards.js';
|
||||
import {
|
||||
createInitialState, playAction, endActionPhase, playTreasure, playAllTreasures,
|
||||
buyCard, endTurn, resolvePending, isGameOver, finalScores,
|
||||
legalActionIids, canGain, emptyPileCount,
|
||||
} from './DominionLogic.js';
|
||||
import * as AI from './DominionAI.js';
|
||||
|
||||
const CX = GAME_WIDTH / 2;
|
||||
|
||||
const D = { bg: -1, supply: 5, inplay: 6, hand: 10, hud: 15, portrait: 20, prompt: 40, modal: 80, popup: 95 };
|
||||
|
||||
const SUPPLY_W = 100, SUPPLY_H = 144;
|
||||
const HAND_W = 132, HAND_H = 190;
|
||||
const PLAY_W = 78, PLAY_H = 112;
|
||||
|
||||
const AI_STEP_MS = 420;
|
||||
const AI_PENDING_MS = 520;
|
||||
|
||||
// Card-face colour styling by type.
|
||||
function typeStyle(def) {
|
||||
if (isType(def.id, 'curse')) return { art: 0x6a3d8f, border: 0x9b59b6, band: 0x1c1226 };
|
||||
if (isType(def.id, 'treasure')) {
|
||||
const art = def.id === 'gold' ? 0xd4af37 : def.id === 'silver' ? 0xb8bcc2 : 0xb87333;
|
||||
return { art, border: 0xc8a84b, band: 0x231b0e };
|
||||
}
|
||||
if (isType(def.id, 'victory')) return { art: 0x2e7d4f, border: 0x3fa86a, band: 0x10241a };
|
||||
if (isType(def.id, 'attack')) return { art: 0xbb9a6a, border: 0xd0563b, band: 0x231a12 };
|
||||
if (isType(def.id, 'reaction')) return { art: 0x8fb0cf, border: 0x4a90d9, band: 0x141d27 };
|
||||
return { art: 0xcdbb8f, border: 0xc8a84b, band: 0x231f17 }; // action
|
||||
}
|
||||
|
||||
function typeLine(def) {
|
||||
return def.types.map((t) => t.charAt(0).toUpperCase() + t.slice(1)).join(' – ');
|
||||
}
|
||||
|
||||
export default class DominionGame extends Phaser.Scene {
|
||||
constructor() { super('DominionGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.cardBack = data.cardBack ?? null;
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.deckMode = data.deckMode ?? 'standard';
|
||||
this.playerCount = this.opponents.length + 1;
|
||||
|
||||
this.gs = null;
|
||||
this.gameOver = false;
|
||||
this.handSprites = [];
|
||||
this.supplySprites = [];
|
||||
this.portraits = [];
|
||||
this.hoverVisible = false;
|
||||
this.hoverTimer = null;
|
||||
this.promptObjs = [];
|
||||
this.selection = new Set();
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildBackground();
|
||||
this.buildPortraits();
|
||||
this.buildHoverPopup();
|
||||
this.buildButtons();
|
||||
|
||||
this.input.on('pointermove', (p) => {
|
||||
this.lastPointer = { x: p.x, y: p.y };
|
||||
if (this.hoverVisible) this.positionHover(p.x, p.y);
|
||||
});
|
||||
|
||||
this.events.once('shutdown', () => {
|
||||
this.portraits.forEach((pt) => pt?.destroy?.());
|
||||
});
|
||||
|
||||
this.gs = createInitialState({
|
||||
seed: (Date.now() ^ (Math.random() * 1e9)) >>> 0,
|
||||
playerCount: this.playerCount,
|
||||
deckMode: this.deckMode,
|
||||
});
|
||||
this.render();
|
||||
this.scheduleAdvance(300);
|
||||
}
|
||||
|
||||
// ── Static scaffolding ─────────────────────────────────────────────────────
|
||||
|
||||
buildBackground() {
|
||||
const pf = this.playfield;
|
||||
if (pf?.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(CX, GAME_HEIGHT / 2, pf.key).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg);
|
||||
} else {
|
||||
this.add.image(CX, GAME_HEIGHT / 2, 'bg-room').setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg);
|
||||
}
|
||||
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(D.bg);
|
||||
}
|
||||
|
||||
// Opponent panels live on the right column; the human sits bottom-left.
|
||||
oppSlot(i) {
|
||||
const ys = this.opponents.length === 2 ? [300, 600] : [180, 430, 680];
|
||||
return { x: 1770, y: ys[i] ?? 180, r: 52 };
|
||||
}
|
||||
|
||||
buildPortraits() {
|
||||
createPlayerPortrait(this, 92, 928, 56, D.portrait, 'DominionGame');
|
||||
this.add.text(92, 928 + 56 + 14, this.seatName(0), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud);
|
||||
|
||||
this.opponents.forEach((opp, i) => {
|
||||
const s = this.oppSlot(i);
|
||||
const pt = createOpponentPortrait(this, opp, s.x, s.y, s.r, D.portrait);
|
||||
this.portraits[i + 1] = pt;
|
||||
this.add.text(s.x, s.y + s.r + 14, opp.name ?? `Player ${i + 2}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud);
|
||||
});
|
||||
}
|
||||
|
||||
buildButtons() {
|
||||
this.btnEndAction = new Button(this, 1640, 778, 'End Action Phase', () => this.humanEndAction(), {
|
||||
width: 240, height: 48, fontSize: 20,
|
||||
}).setDepth(D.hud).setVisible(false);
|
||||
this.btnPlayTreasure = new Button(this, 1640, 778, 'Play Treasures', () => this.humanPlayTreasures(), {
|
||||
width: 240, height: 48, fontSize: 20, bg: COLORS.panel, bgHover: COLORS.gold,
|
||||
}).setDepth(D.hud).setVisible(false);
|
||||
this.btnEndTurn = new Button(this, 1640, 836, 'End Turn', () => this.humanEndTurn(), {
|
||||
width: 240, height: 48, fontSize: 20, bg: COLORS.accent, bgHover: COLORS.gold,
|
||||
textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
|
||||
}).setDepth(D.hud).setVisible(false);
|
||||
|
||||
new Button(this, 92, 1028, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 130, height: 40, fontSize: 18,
|
||||
}).setDepth(D.hud);
|
||||
}
|
||||
|
||||
seatName(seat) {
|
||||
if (seat === 0) return auth.user?.username ?? 'You';
|
||||
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
|
||||
}
|
||||
|
||||
seatSkill(seat) {
|
||||
return this.opponents[seat - 1]?.skill ?? 3;
|
||||
}
|
||||
|
||||
// ── Render (dynamic layer) ───────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
|
||||
this.hideHover();
|
||||
this.dynamicLayer?.destroy(true);
|
||||
this.dynamicLayer = this.add.container(0, 0);
|
||||
this.handSprites = [];
|
||||
this.supplySprites = [];
|
||||
|
||||
this.renderSupply();
|
||||
this.renderInPlay();
|
||||
this.renderHand();
|
||||
this.renderCounts();
|
||||
this.renderHud();
|
||||
this.updateControls();
|
||||
}
|
||||
|
||||
renderSupply() {
|
||||
const gs = this.gs;
|
||||
const base = ['copper', 'silver', 'gold', 'estate', 'duchy', 'province', 'curse'];
|
||||
this.layoutPileRow(base, 100);
|
||||
const k = gs.kingdom;
|
||||
this.layoutPileRow(k.slice(0, 5), 274);
|
||||
this.layoutPileRow(k.slice(5, 10), 446);
|
||||
|
||||
// Trash indicator
|
||||
const tx = 1480, ty = 100;
|
||||
const t = this.add.text(tx, ty, `Trash\n${gs.trash.length}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(D.supply);
|
||||
this.dynamicLayer.add(t);
|
||||
}
|
||||
|
||||
layoutPileRow(ids, y) {
|
||||
const gap = 16;
|
||||
const totalW = ids.length * SUPPLY_W + (ids.length - 1) * gap;
|
||||
const startX = 760 - totalW / 2 + SUPPLY_W / 2;
|
||||
ids.forEach((id, i) => {
|
||||
const x = startX + i * (SUPPLY_W + gap);
|
||||
this.renderPile(id, x, y);
|
||||
});
|
||||
}
|
||||
|
||||
renderPile(id, x, y) {
|
||||
const gs = this.gs;
|
||||
const count = gs.supply[id] ?? 0;
|
||||
const def = getCard(id);
|
||||
const face = this.buildCardFace(SUPPLY_W, SUPPLY_H, def, { dimmed: count <= 0 });
|
||||
face.setPosition(x, y).setDepth(D.supply);
|
||||
this.dynamicLayer.add(face);
|
||||
|
||||
// count badge
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x000000, 0.82);
|
||||
g.fillCircle(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, 15);
|
||||
g.lineStyle(2, COLORS.accent, 1);
|
||||
g.strokeCircle(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, 15);
|
||||
g.setDepth(D.supply + 1);
|
||||
const ct = this.add.text(x + SUPPLY_W / 2 - 14, y - SUPPLY_H / 2 + 14, `${count}`, {
|
||||
fontFamily: 'Righteous', fontSize: '16px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.supply + 1);
|
||||
this.dynamicLayer.add(g);
|
||||
this.dynamicLayer.add(ct);
|
||||
|
||||
const hit = this.add.rectangle(x, y, SUPPLY_W, SUPPLY_H, 0x000000, 0).setDepth(D.supply + 2);
|
||||
this.dynamicLayer.add(hit);
|
||||
this.attachHover(hit, def);
|
||||
this.supplySprites.push({ id, x, y, hit, face });
|
||||
|
||||
// Normal buy wiring (human buy phase, no pending).
|
||||
if (!gs.pending && gs.turn === 0 && gs.phase === 'buy') {
|
||||
const p = gs.players[0];
|
||||
const affordable = count > 0 && p.buys > 0 && p.coins >= def.cost;
|
||||
if (affordable) {
|
||||
hit.setInteractive({ useHandCursor: true });
|
||||
hit.on('pointerup', () => this.humanBuy(id));
|
||||
this.markBuyable(face);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markBuyable(face) {
|
||||
const glow = this.add.rectangle(0, 0, SUPPLY_W + 6, SUPPLY_H + 6, COLORS.gold, 0);
|
||||
glow.setStrokeStyle(3, COLORS.gold, 0.95);
|
||||
face.addAt(glow, 0);
|
||||
}
|
||||
|
||||
renderInPlay() {
|
||||
const gs = this.gs;
|
||||
const p = gs.players[gs.turn];
|
||||
const cards = p.inPlay;
|
||||
if (cards.length === 0) return;
|
||||
const gap = 8;
|
||||
const totalW = Math.min(cards.length, 12) * (PLAY_W + gap);
|
||||
const startX = CX - totalW / 2 + PLAY_W / 2;
|
||||
cards.slice(0, 12).forEach((c, i) => {
|
||||
const def = getCard(c.id);
|
||||
const face = this.buildCardFace(PLAY_W, PLAY_H, def);
|
||||
face.setPosition(startX + i * (PLAY_W + gap), 610).setDepth(D.inplay);
|
||||
this.dynamicLayer.add(face);
|
||||
const hit = this.add.rectangle(startX + i * (PLAY_W + gap), 610, PLAY_W, PLAY_H, 0x000000, 0)
|
||||
.setDepth(D.inplay + 1);
|
||||
this.dynamicLayer.add(hit);
|
||||
this.attachHover(hit, def);
|
||||
});
|
||||
const lbl = this.add.text(CX, 540, `${this.seatName(gs.turn)} — in play`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.inplay);
|
||||
this.dynamicLayer.add(lbl);
|
||||
}
|
||||
|
||||
renderHand() {
|
||||
const gs = this.gs;
|
||||
const hand = gs.players[0].hand;
|
||||
const gap = Math.min(18, (1300 - hand.length * HAND_W) / Math.max(1, hand.length - 1));
|
||||
const step = HAND_W + Math.max(-HAND_W * 0.45, gap);
|
||||
const totalW = (hand.length - 1) * step + HAND_W;
|
||||
const startX = CX - totalW / 2 + HAND_W / 2;
|
||||
const baseY = 968;
|
||||
|
||||
const canPlayAction = !gs.pending && gs.turn === 0 && gs.phase === 'action' && gs.players[0].actions > 0;
|
||||
const canPlayTreasure = !gs.pending && gs.turn === 0 && gs.phase === 'buy';
|
||||
const legalAct = new Set(legalActionIids(gs));
|
||||
|
||||
hand.forEach((c, i) => {
|
||||
const def = getCard(c.id);
|
||||
const x = startX + i * step;
|
||||
const face = this.buildCardFace(HAND_W, HAND_H, def);
|
||||
face.setPosition(x, baseY).setDepth(D.hand + i);
|
||||
this.dynamicLayer.add(face);
|
||||
const hit = this.add.rectangle(x, baseY, HAND_W, HAND_H, 0x000000, 0).setDepth(D.hand + i + 1);
|
||||
this.dynamicLayer.add(hit);
|
||||
this.attachHover(hit, def);
|
||||
this.handSprites.push({ iid: c.iid, id: c.id, def, x, baseY, face, hit });
|
||||
|
||||
if (canPlayAction && legalAct.has(c.iid)) {
|
||||
hit.setInteractive({ useHandCursor: true });
|
||||
hit.on('pointerup', () => this.humanPlayAction(c.iid));
|
||||
this.highlightFace(face, COLORS.accent);
|
||||
} else if (canPlayTreasure && isType(c.id, 'treasure')) {
|
||||
hit.setInteractive({ useHandCursor: true });
|
||||
hit.on('pointerup', () => this.humanPlayTreasure(c.iid));
|
||||
this.highlightFace(face, COLORS.gold);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
highlightFace(face, color) {
|
||||
const glow = this.add.rectangle(0, 0, HAND_W + 6, HAND_H + 6, color, 0);
|
||||
glow.setStrokeStyle(3, color, 0.9);
|
||||
face.addAt(glow, 0);
|
||||
}
|
||||
|
||||
renderCounts() {
|
||||
const gs = this.gs;
|
||||
// Human deck/discard
|
||||
this.dynamicLayer.add(this.add.text(1380, 968, `Deck\n${gs.players[0].deck.length}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(D.hand));
|
||||
this.dynamicLayer.add(this.add.text(1500, 968, `Discard\n${gs.players[0].discard.length}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(D.hand));
|
||||
|
||||
this.opponents.forEach((opp, i) => {
|
||||
const seat = i + 1;
|
||||
const p = gs.players[seat];
|
||||
const s = this.oppSlot(i);
|
||||
const active = gs.turn === seat;
|
||||
const txt = `Hand ${p.hand.length} Deck ${p.deck.length} Disc ${p.discard.length}`;
|
||||
this.dynamicLayer.add(this.add.text(s.x, s.y + s.r + 38, txt, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '15px',
|
||||
color: active ? COLORS.goldHex : COLORS.mutedHex, align: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.55)', padding: { x: 8, y: 4 },
|
||||
}).setOrigin(0.5).setDepth(D.hud));
|
||||
});
|
||||
}
|
||||
|
||||
renderHud() {
|
||||
const gs = this.gs;
|
||||
const p = gs.players[gs.turn];
|
||||
const turnName = this.seatName(gs.turn);
|
||||
const phaseLbl = gs.phase === 'action' ? 'Action Phase' : gs.phase === 'buy' ? 'Buy Phase' : '';
|
||||
|
||||
this.dynamicLayer.add(this.add.text(CX, 700, `${turnName}'s turn — ${phaseLbl}`, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud));
|
||||
|
||||
const me = gs.players[gs.turn];
|
||||
const stat = `Actions ${me.actions} Buys ${me.buys} Coins ${me.coins}`;
|
||||
this.dynamicLayer.add(this.add.text(CX, 742, stat, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)', padding: { x: 14, y: 6 },
|
||||
}).setOrigin(0.5).setDepth(D.hud));
|
||||
|
||||
const provLeft = gs.supply.province ?? 0;
|
||||
this.dynamicLayer.add(this.add.text(CX, 786, `Provinces left: ${provLeft} Empty piles: ${emptyPileCount(gs)}/3`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.hud));
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const gs = this.gs;
|
||||
const humanTurn = gs.turn === 0 && !gs.pending && !this.gameOver;
|
||||
const action = humanTurn && gs.phase === 'action';
|
||||
const buy = humanTurn && gs.phase === 'buy';
|
||||
const hasTreasure = gs.players[0].hand.some((c) => isType(c.id, 'treasure'));
|
||||
this.btnEndAction.setVisible(action);
|
||||
this.btnPlayTreasure.setVisible(buy && hasTreasure);
|
||||
this.btnEndTurn.setVisible(buy);
|
||||
}
|
||||
|
||||
// ── Card face builder ───────────────────────────────────────────────────────
|
||||
|
||||
buildCardFace(w, h, def, { faceDown = false, dimmed = false } = {}) {
|
||||
const c = this.add.container(0, 0);
|
||||
const style = typeStyle(def);
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x000000, 0.35); g.fillRoundedRect(-w / 2 + 2, -h / 2 + 3, w, h, 8);
|
||||
g.fillStyle(0x14110b, 1); g.fillRoundedRect(-w / 2, -h / 2, w, h, 8);
|
||||
g.lineStyle(2, style.border, 1); g.strokeRoundedRect(-w / 2, -h / 2, w, h, 8);
|
||||
c.add(g);
|
||||
|
||||
if (faceDown) {
|
||||
if (this.textures.exists('cardbacks')) {
|
||||
c.add(this.add.image(0, 0, 'cardbacks', this.cardBack?.spriteIndex ?? 0).setDisplaySize(w - 6, h - 6));
|
||||
} else {
|
||||
c.add(this.add.rectangle(0, 0, w - 8, h - 8, 0x2a2a40));
|
||||
}
|
||||
if (dimmed) c.add(this.add.rectangle(0, 0, w, h, 0x000000, 0.5));
|
||||
return c;
|
||||
}
|
||||
|
||||
const artH = h * 0.58;
|
||||
const artTop = -h / 2;
|
||||
const bandTop = artTop + artH;
|
||||
const fsTitle = Phaser.Math.Clamp(Math.round(h * 0.085), 9, 20);
|
||||
const fsType = Phaser.Math.Clamp(Math.round(h * 0.05), 7, 12);
|
||||
|
||||
if (this.textures.exists('dominion-cards')) {
|
||||
c.add(this.add.image(0, 0, 'dominion-cards', def.frame).setDisplaySize(w - 6, h - 6));
|
||||
// legibility band over the lower portion
|
||||
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.9));
|
||||
} else {
|
||||
c.add(this.add.rectangle(0, artTop + artH / 2, w - 8, artH - 4, style.art));
|
||||
c.add(this.add.rectangle(0, bandTop + (h / 2 - bandTop) / 2, w - 6, h / 2 - bandTop, style.band, 0.95));
|
||||
// placeholder: name in the art area for identification
|
||||
c.add(this.add.text(0, artTop + artH / 2, def.name, {
|
||||
fontFamily: 'Righteous', fontSize: `${fsTitle}px`, color: '#1a1208',
|
||||
align: 'center', wordWrap: { width: w - 16 },
|
||||
}).setOrigin(0.5));
|
||||
}
|
||||
|
||||
// Title in the band
|
||||
c.add(this.add.text(0, bandTop + fsTitle * 0.9, def.name, {
|
||||
fontFamily: 'Righteous', fontSize: `${fsTitle}px`, color: COLORS.textHex,
|
||||
align: 'center', wordWrap: { width: w - 12 },
|
||||
}).setOrigin(0.5, 0.5));
|
||||
|
||||
// Type line (small)
|
||||
c.add(this.add.text(0, h / 2 - fsType * 1.2, typeLine(def), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: `${fsType}px`, color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5));
|
||||
|
||||
// Icon row (between title and type line)
|
||||
this.addIconRow(c, def, 0, bandTop + (h / 2 - bandTop) * 0.55, h);
|
||||
|
||||
// Cost coin (top-left)
|
||||
this.addCoinBadge(c, -w / 2 + 14, -h / 2 + 14, def.cost, Math.round(h * 0.075));
|
||||
|
||||
// VP badge (top-right) for victory cards
|
||||
if (isType(def.id, 'victory')) {
|
||||
const vpLabel = def.id === 'gardens' ? '★' : `${def.vp}`;
|
||||
this.addVpBadge(c, w / 2 - 14, -h / 2 + 14, vpLabel, Math.round(h * 0.075));
|
||||
}
|
||||
|
||||
if (dimmed) c.add(this.add.rectangle(0, 0, w, h, 0x000000, 0.5));
|
||||
return c;
|
||||
}
|
||||
|
||||
addIconRow(container, def, cx, cy, h) {
|
||||
const tokens = [];
|
||||
if (def.plus.cards) tokens.push(['cards', def.plus.cards, 0x2f6fb0]);
|
||||
if (def.plus.actions) tokens.push(['action', def.plus.actions, 0x3f9b54]);
|
||||
if (def.plus.buys) tokens.push(['buy', def.plus.buys, 0x8a5fb0]);
|
||||
if (def.plus.coins) tokens.push(['$', def.plus.coins, 0xd4a017]);
|
||||
if (def.coin !== undefined) tokens.push(['$', def.coin, 0xd4a017]); // treasure value
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
const fs = Phaser.Math.Clamp(Math.round(h * 0.06), 8, 13);
|
||||
const tw = fs * 4.2;
|
||||
const gap = 4;
|
||||
const totalW = tokens.length * tw + (tokens.length - 1) * gap;
|
||||
let x = cx - totalW / 2 + tw / 2;
|
||||
for (const [kind, val, color] of tokens) {
|
||||
const pill = this.add.rectangle(x, cy, tw, fs * 1.7, color, 0.95);
|
||||
pill.setStrokeStyle(1, 0x000000, 0.4);
|
||||
container.add(pill);
|
||||
const label = kind === '$' ? `+${val}$`
|
||||
: kind === 'cards' ? `+${val}🂠`
|
||||
: kind === 'action' ? `+${val}A`
|
||||
: `+${val}B`;
|
||||
container.add(this.add.text(x, cy, label, {
|
||||
fontFamily: 'Righteous', fontSize: `${fs}px`, color: '#ffffff',
|
||||
}).setOrigin(0.5));
|
||||
x += tw + gap;
|
||||
}
|
||||
}
|
||||
|
||||
addCoinBadge(container, x, y, value, r) {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0xd4a017, 1); g.fillCircle(x, y, r);
|
||||
g.lineStyle(1.5, 0x6b5310, 1); g.strokeCircle(x, y, r);
|
||||
container.add(g);
|
||||
container.add(this.add.text(x, y, `${value}`, {
|
||||
fontFamily: 'Righteous', fontSize: `${Math.round(r * 1.2)}px`, color: '#1a1208',
|
||||
}).setOrigin(0.5));
|
||||
}
|
||||
|
||||
addVpBadge(container, x, y, label, r) {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x2e7d4f, 1); g.fillCircle(x, y, r);
|
||||
g.lineStyle(1.5, 0x14241a, 1); g.strokeCircle(x, y, r);
|
||||
container.add(g);
|
||||
container.add(this.add.text(x, y, `${label}`, {
|
||||
fontFamily: 'Righteous', fontSize: `${Math.round(r * 1.1)}px`, color: '#ffffff',
|
||||
}).setOrigin(0.5));
|
||||
}
|
||||
|
||||
// ── Hover popup ───────────────────────────────────────────────────────────────
|
||||
|
||||
buildHoverPopup() {
|
||||
this.hoverPopup = this.add.container(-9999, -9999).setDepth(D.popup).setVisible(false);
|
||||
}
|
||||
|
||||
attachHover(hitObj, def) {
|
||||
hitObj.setInteractive({ useHandCursor: hitObj.input?.cursor === 'pointer' });
|
||||
hitObj.on('pointerover', () => {
|
||||
if (this.hoverTimer) this.hoverTimer.remove();
|
||||
this.hoverTimer = this.time.delayedCall(500, () => this.showHover(def));
|
||||
});
|
||||
hitObj.on('pointerout', () => {
|
||||
if (this.hoverTimer) { this.hoverTimer.remove(); this.hoverTimer = null; }
|
||||
this.hideHover();
|
||||
});
|
||||
}
|
||||
|
||||
showHover(def) {
|
||||
this.hoverPopup.removeAll(true);
|
||||
const w = 360;
|
||||
const pad = 16;
|
||||
const title = this.add.text(0, 0, def.name, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5, 0);
|
||||
const sub = this.add.text(0, 0, `${typeLine(def)} • Cost ${def.cost}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5, 0);
|
||||
const body = this.add.text(0, 0, def.text || '—', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex,
|
||||
align: 'center', wordWrap: { width: w - pad * 2 },
|
||||
}).setOrigin(0.5, 0);
|
||||
|
||||
title.setY(0);
|
||||
sub.setY(title.height + 6);
|
||||
body.setY(sub.y + sub.height + 12);
|
||||
const h = body.y + body.height + pad;
|
||||
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x0d1117, 0.97); g.fillRoundedRect(-w / 2, -pad, w, h + pad, 12);
|
||||
g.lineStyle(2, COLORS.accent, 0.95); g.strokeRoundedRect(-w / 2, -pad, w, h + pad, 12);
|
||||
|
||||
this.hoverPopup.add([g, title, sub, body]);
|
||||
this.hoverPopup.setData('w', w);
|
||||
this.hoverPopup.setData('h', h + pad);
|
||||
this.hoverVisible = true;
|
||||
this.hoverPopup.setVisible(true);
|
||||
const p = this.lastPointer ?? { x: CX, y: GAME_HEIGHT / 2 };
|
||||
this.positionHover(p.x, p.y);
|
||||
}
|
||||
|
||||
positionHover(px, py) {
|
||||
const w = this.hoverPopup.getData('w') ?? 360;
|
||||
const h = this.hoverPopup.getData('h') ?? 200;
|
||||
const x = Phaser.Math.Clamp(px + w / 2 + 24, w / 2 + 8, GAME_WIDTH - w / 2 - 8);
|
||||
const y = Phaser.Math.Clamp(py, h / 2 + 8, GAME_HEIGHT - h / 2 - 8);
|
||||
this.hoverPopup.setPosition(x, y);
|
||||
}
|
||||
|
||||
hideHover() {
|
||||
this.hoverVisible = false;
|
||||
this.hoverPopup.setVisible(false).setPosition(-9999, -9999);
|
||||
}
|
||||
|
||||
// ── Turn driver ───────────────────────────────────────────────────────────────
|
||||
|
||||
setState(s) {
|
||||
this.gs = s;
|
||||
this.clearPrompt();
|
||||
this.render();
|
||||
this.scheduleAdvance(10);
|
||||
}
|
||||
|
||||
scheduleAdvance(ms) {
|
||||
this.time.delayedCall(ms, () => this.advance());
|
||||
}
|
||||
|
||||
advance() {
|
||||
if (this.gameOver) return;
|
||||
const gs = this.gs;
|
||||
if (isGameOver(gs)) { this.onGameOver(); return; }
|
||||
|
||||
if (gs.pending) {
|
||||
if (gs.pending.seat === 0) {
|
||||
this.promptHuman(gs.pending);
|
||||
} else {
|
||||
const skill = this.seatSkill(gs.pending.seat);
|
||||
this.time.delayedCall(AI_PENDING_MS, () => {
|
||||
if (this.gameOver) return;
|
||||
const choice = AI.resolvePending(this.gs, skill);
|
||||
this.setState(resolvePending(this.gs, choice));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gs.turn === 0) {
|
||||
// Human turn — wait for button / card clicks.
|
||||
this.updateControls();
|
||||
return;
|
||||
}
|
||||
|
||||
// AI turn.
|
||||
this.time.delayedCall(AI_STEP_MS, () => this.aiStep());
|
||||
}
|
||||
|
||||
aiStep() {
|
||||
if (this.gameOver) return;
|
||||
const gs = this.gs;
|
||||
if (gs.pending || gs.turn === 0) { this.advance(); return; }
|
||||
const seat = gs.turn;
|
||||
const skill = this.seatSkill(seat);
|
||||
|
||||
if (gs.phase === 'action') {
|
||||
const iid = AI.chooseAction(gs, seat);
|
||||
if (iid != null) {
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.setState(playAction(gs, iid));
|
||||
} else {
|
||||
this.setState(endActionPhase(gs));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gs.phase === 'buy') {
|
||||
if (gs.players[seat].hand.some((c) => isType(c.id, 'treasure'))) {
|
||||
this.setState(playAllTreasures(gs));
|
||||
return;
|
||||
}
|
||||
const buy = AI.chooseBuy(gs, seat, skill);
|
||||
if (buy) {
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
if (buy === 'province') this.portraits[seat]?.playEmotion?.('happy');
|
||||
this.setState(buyCard(gs, buy));
|
||||
} else {
|
||||
this.setState(endTurn(gs));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Human actions (normal flow) ────────────────────────────────────────────
|
||||
|
||||
humanPlayAction(iid) {
|
||||
if (this.gs.pending || this.gs.turn !== 0) return;
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.setState(playAction(this.gs, iid));
|
||||
}
|
||||
|
||||
humanPlayTreasure(iid) {
|
||||
if (this.gs.pending || this.gs.turn !== 0) return;
|
||||
this.setState(playTreasure(this.gs, iid));
|
||||
}
|
||||
|
||||
humanPlayTreasures() {
|
||||
if (this.gs.pending || this.gs.turn !== 0) return;
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
this.setState(playAllTreasures(this.gs));
|
||||
}
|
||||
|
||||
humanBuy(id) {
|
||||
if (this.gs.pending || this.gs.turn !== 0) return;
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.setState(buyCard(this.gs, id));
|
||||
}
|
||||
|
||||
humanEndAction() {
|
||||
if (this.gs.pending || this.gs.turn !== 0) return;
|
||||
this.setState(endActionPhase(this.gs));
|
||||
}
|
||||
|
||||
humanEndTurn() {
|
||||
if (this.gs.pending || this.gs.turn !== 0) return;
|
||||
this.setState(endTurn(this.gs));
|
||||
}
|
||||
|
||||
// ── Human decision prompts ──────────────────────────────────────────────────
|
||||
|
||||
clearPrompt() {
|
||||
this.promptObjs.forEach((o) => o.destroy?.());
|
||||
this.promptObjs = [];
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
resolveHuman(choice) {
|
||||
this.clearPrompt();
|
||||
this.setState(resolvePending(this.gs, choice));
|
||||
}
|
||||
|
||||
promptBanner(text) {
|
||||
const t = this.add.text(CX, 500, text, {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex, align: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.78)', padding: { x: 18, y: 10 }, wordWrap: { width: 1000 },
|
||||
}).setOrigin(0.5).setDepth(D.prompt);
|
||||
this.promptObjs.push(t);
|
||||
return t;
|
||||
}
|
||||
|
||||
promptButton(x, label, cb, opts = {}) {
|
||||
const b = new Button(this, x, 560, label, cb, { width: 200, height: 46, fontSize: 20, ...opts })
|
||||
.setDepth(D.prompt);
|
||||
this.promptObjs.push(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
promptHuman(pend) {
|
||||
switch (pend.kind) {
|
||||
case 'cellarDiscard':
|
||||
return this.promptMultiHand(pend, { min: 0, max: 99, banner: 'Discard any number of cards, then draw that many.', confirm: 'Discard & Draw' });
|
||||
case 'chapelTrash':
|
||||
return this.promptMultiHand(pend, { min: 0, max: pend.max, banner: `Trash up to ${pend.max} cards from your hand.`, confirm: 'Trash' });
|
||||
case 'poacherDiscard':
|
||||
return this.promptMultiHand(pend, { min: pend.count, max: pend.count, banner: `Discard ${pend.count} card(s) — one per empty Supply pile.`, confirm: 'Discard' });
|
||||
case 'discardDownTo':
|
||||
return this.promptMultiHand(pend, { min: pend.count, max: pend.count, banner: `Militia: discard down to 3 (choose ${pend.count}).`, confirm: 'Discard' });
|
||||
case 'remodelTrash':
|
||||
return this.promptPickHand(pend, { filter: () => true, banner: 'Remodel: trash a card from your hand.', allowSkip: false });
|
||||
case 'mineTrash':
|
||||
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'treasure'), banner: 'Mine: trash a Treasure (or skip).', allowSkip: true });
|
||||
case 'throneChoose':
|
||||
return this.promptPickHand(pend, { filter: (c) => isType(c.id, 'action'), banner: 'Throne Room: choose an Action to play twice (or skip).', allowSkip: true });
|
||||
case 'artisanTopdeck':
|
||||
return this.promptPickHand(pend, { filter: () => true, banner: 'Artisan: put a card from your hand onto your deck.', allowSkip: false, key: 'iid' });
|
||||
case 'gainFromSupply':
|
||||
return this.promptGain(pend);
|
||||
case 'moneylenderTrash':
|
||||
return this.promptYesNo('Moneylender: trash a Copper for +3 Coins?', (yes) => this.resolveHuman({ confirm: yes }));
|
||||
case 'vassalPlay':
|
||||
return this.promptYesNo(`Vassal revealed ${getCard(pend.cardId).name}. Play it?`, (yes) => this.resolveHuman({ play: yes }));
|
||||
case 'libraryKeep':
|
||||
return this.promptYesNo(`Library drew ${getCard(pend.cardId).name}. Keep it? (No sets it aside.)`, (yes) => this.resolveHuman({ keep: yes }));
|
||||
case 'harbingerTopdeck':
|
||||
return this.promptPickList(this.gs.players[0].discard, 'Harbinger: put a card from your discard onto your deck (or skip).', true, (iid) => this.resolveHuman({ iid }));
|
||||
case 'banditTrash':
|
||||
return this.promptPickList(pend.options, 'Bandit: choose a Treasure to trash.', false, (iid) => this.resolveHuman({ iid }));
|
||||
case 'bureaucratTopdeck':
|
||||
return this.promptPickList(pend.options, 'Bureaucrat: choose a Victory card to put on your deck.', false, (iid) => this.resolveHuman({ iid }));
|
||||
case 'moatReveal':
|
||||
return this.promptYesNo('You are under attack! Reveal Moat to block it?', (yes) => this.resolveHuman({ reveal: yes }));
|
||||
case 'sentry':
|
||||
return this.promptSentry(pend);
|
||||
default:
|
||||
return this.resolveHuman({});
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-select over the hand (discard/trash N).
|
||||
promptMultiHand(pend, { min, max, banner, confirm }) {
|
||||
this.promptBanner(banner);
|
||||
const update = () => {
|
||||
const n = this.selection.size;
|
||||
confirmBtn.setEnabled(n >= min && n <= max);
|
||||
confirmBtn.setLabel(`${confirm} (${n})`);
|
||||
};
|
||||
const confirmBtn = this.promptButton(CX, () => {
|
||||
this.resolveHuman({ iids: [...this.selection] });
|
||||
}, () => {}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
|
||||
|
||||
for (const hs of this.handSprites) {
|
||||
hs.hit.setInteractive({ useHandCursor: true });
|
||||
hs.hit.removeAllListeners('pointerup');
|
||||
hs.hit.on('pointerup', () => {
|
||||
if (this.selection.has(hs.iid)) {
|
||||
this.selection.delete(hs.iid);
|
||||
hs.face.setY(hs.baseY);
|
||||
} else {
|
||||
if (this.selection.size >= max) return;
|
||||
this.selection.add(hs.iid);
|
||||
hs.face.setY(hs.baseY - 28);
|
||||
}
|
||||
update();
|
||||
});
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
// Single pick from the hand (immediate resolve). key = which choice field.
|
||||
promptPickHand(pend, { filter, banner, allowSkip, key = 'iid' }) {
|
||||
this.promptBanner(banner);
|
||||
if (allowSkip) {
|
||||
this.promptButton(CX + 230, 'Skip', () => this.resolveHuman({ [key]: null }), { variant: 'ghost' });
|
||||
}
|
||||
for (const hs of this.handSprites) {
|
||||
const ok = filter(hs);
|
||||
if (!ok) continue;
|
||||
hs.hit.setInteractive({ useHandCursor: true });
|
||||
hs.hit.removeAllListeners('pointerup');
|
||||
this.highlightFace(hs.face, COLORS.danger);
|
||||
hs.hit.on('pointerup', () => this.resolveHuman({ [key]: hs.iid }));
|
||||
}
|
||||
}
|
||||
|
||||
// Gain a card from the Supply (highlight eligible piles).
|
||||
promptGain(pend) {
|
||||
const treasureNote = pend.filterTreasure ? ' Treasure' : '';
|
||||
this.promptBanner(`Gain a${treasureNote} card costing up to ${pend.maxCost}.`);
|
||||
let any = false;
|
||||
for (const sp of this.supplySprites) {
|
||||
if (!canGain(this.gs, sp.id, pend.maxCost, pend.filterTreasure)) continue;
|
||||
any = true;
|
||||
sp.hit.setInteractive({ useHandCursor: true });
|
||||
sp.hit.removeAllListeners('pointerup');
|
||||
const glow = this.add.rectangle(sp.x, sp.y, SUPPLY_W + 8, SUPPLY_H + 8, COLORS.gold, 0)
|
||||
.setStrokeStyle(4, COLORS.gold, 0.95).setDepth(D.supply + 3);
|
||||
this.promptObjs.push(glow);
|
||||
sp.hit.on('pointerup', () => this.resolveHuman({ id: sp.id }));
|
||||
}
|
||||
if (!any) this.resolveHuman({ id: null });
|
||||
}
|
||||
|
||||
promptYesNo(banner, cb) {
|
||||
this.promptBanner(banner);
|
||||
this.promptButton(CX - 120, 'Yes', () => cb(true), { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex });
|
||||
this.promptButton(CX + 120, 'No', () => cb(false), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
// Modal list of cards to pick one (harbinger discard, bandit/bureaucrat options).
|
||||
promptPickList(cards, banner, allowSkip, cb) {
|
||||
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
this.promptObjs.push(overlay);
|
||||
this.promptObjs.push(this.add.text(CX, 360, banner, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
|
||||
wordWrap: { width: 1100 },
|
||||
}).setOrigin(0.5).setDepth(D.modal + 1));
|
||||
|
||||
const uniq = [];
|
||||
const seen = new Set();
|
||||
for (const c of cards) { if (!seen.has(c.iid)) { seen.add(c.iid); uniq.push(c); } }
|
||||
|
||||
const gap = 18;
|
||||
const totalW = Math.min(uniq.length, 8) * (HAND_W + gap);
|
||||
const startX = CX - totalW / 2 + (HAND_W + gap) / 2;
|
||||
uniq.slice(0, 8).forEach((c, i) => {
|
||||
const def = getCard(c.id);
|
||||
const face = this.buildCardFace(HAND_W, HAND_H, def).setPosition(startX + i * (HAND_W + gap), 560).setDepth(D.modal + 1);
|
||||
this.promptObjs.push(face);
|
||||
const hit = this.add.rectangle(startX + i * (HAND_W + gap), 560, HAND_W, HAND_H, 0x000000, 0)
|
||||
.setDepth(D.modal + 2).setInteractive({ useHandCursor: true });
|
||||
this.promptObjs.push(hit);
|
||||
this.attachHover(hit, def);
|
||||
hit.on('pointerup', () => cb(c.iid));
|
||||
});
|
||||
|
||||
if (allowSkip) {
|
||||
this.promptObjs.push(new Button(this, CX, 760, 'Skip', () => cb(null), { variant: 'ghost', width: 180 }).setDepth(D.modal + 2));
|
||||
}
|
||||
}
|
||||
|
||||
// Sentry modal: per-card Trash / Discard / Keep over the top 2 cards.
|
||||
promptSentry(pend) {
|
||||
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
this.promptObjs.push(overlay);
|
||||
this.promptObjs.push(this.add.text(CX, 320, 'Sentry: choose what to do with the top 2 cards of your deck.', {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center', wordWrap: { width: 1100 },
|
||||
}).setOrigin(0.5).setDepth(D.modal + 1));
|
||||
|
||||
const cards = pend.cards ?? [];
|
||||
const decisions = new Map(cards.map((c) => [c.iid, 'top'])); // default keep on top
|
||||
const gap = 80;
|
||||
const totalW = cards.length * (HAND_W + gap);
|
||||
const startX = CX - totalW / 2 + (HAND_W + gap) / 2;
|
||||
const labels = new Map();
|
||||
|
||||
cards.forEach((c, i) => {
|
||||
const def = getCard(c.id);
|
||||
const x = startX + i * (HAND_W + gap);
|
||||
const face = this.buildCardFace(HAND_W, HAND_H, def).setPosition(x, 540).setDepth(D.modal + 1);
|
||||
this.promptObjs.push(face);
|
||||
const hit = this.add.rectangle(x, 540, HAND_W, HAND_H, 0x000000, 0).setDepth(D.modal + 2);
|
||||
this.promptObjs.push(hit);
|
||||
this.attachHover(hit, def);
|
||||
|
||||
const lbl = this.add.text(x, 540 + HAND_H / 2 + 24, 'Keep on top', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2);
|
||||
this.promptObjs.push(lbl);
|
||||
labels.set(c.iid, lbl);
|
||||
|
||||
const cycle = ['top', 'discard', 'trash'];
|
||||
const text = { top: 'Keep on top', discard: 'Discard', trash: 'Trash' };
|
||||
const colors = { top: COLORS.goldHex, discard: COLORS.mutedHex, trash: COLORS.dangerHex };
|
||||
hit.setInteractive({ useHandCursor: true });
|
||||
hit.on('pointerup', () => {
|
||||
const cur = decisions.get(c.iid);
|
||||
const nxt = cycle[(cycle.indexOf(cur) + 1) % cycle.length];
|
||||
decisions.set(c.iid, nxt);
|
||||
lbl.setText(text[nxt]).setColor(colors[nxt]);
|
||||
});
|
||||
});
|
||||
|
||||
this.promptObjs.push(new Button(this, CX, 800, 'Confirm', () => {
|
||||
const trash = [], discard = [], top = [];
|
||||
for (const c of cards) {
|
||||
const d = decisions.get(c.iid);
|
||||
if (d === 'trash') trash.push(c.iid);
|
||||
else if (d === 'discard') discard.push(c.iid);
|
||||
else top.push(c.iid);
|
||||
}
|
||||
this.resolveHuman({ trash, discard, top });
|
||||
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, width: 200 }).setDepth(D.modal + 2));
|
||||
}
|
||||
|
||||
// ── Game over ───────────────────────────────────────────────────────────────
|
||||
|
||||
onGameOver() {
|
||||
if (this.gameOver) return;
|
||||
this.gameOver = true;
|
||||
this.clearPrompt();
|
||||
this.hideHover();
|
||||
this.updateControls();
|
||||
|
||||
const scores = finalScores(this.gs);
|
||||
const winners = new Set(this.gs.winnerSeats);
|
||||
const youWon = winners.has(0);
|
||||
playSound(this, youWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE);
|
||||
|
||||
this.recordHistory(scores);
|
||||
|
||||
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.7)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
const ordered = scores.slice().sort((a, b) => b.vp - a.vp);
|
||||
const lines = ordered.map((s) => `${this.seatName(s.seat)}: ${s.vp} VP${winners.has(s.seat) ? ' ★' : ''}`);
|
||||
const h = 220 + lines.length * 44;
|
||||
const box = this.add.rectangle(CX, GAME_HEIGHT / 2, 660, h, COLORS.panel, 1)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(D.modal + 1);
|
||||
const title = youWon ? (winners.size > 1 ? 'Tie game!' : 'You win!') : `${this.seatName(ordered[0].seat)} wins`;
|
||||
this.add.text(CX, GAME_HEIGHT / 2 - h / 2 + 48, title, {
|
||||
fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2);
|
||||
lines.forEach((line, i) => {
|
||||
this.add.text(CX, GAME_HEIGHT / 2 - h / 2 + 116 + i * 44, line, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal + 2);
|
||||
});
|
||||
|
||||
new Button(this, CX - 150, GAME_HEIGHT / 2 + h / 2 - 44, 'New game', () => this.scene.restart(), {
|
||||
width: 260, bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex,
|
||||
}).setDepth(D.modal + 2);
|
||||
new Button(this, CX + 150, GAME_HEIGHT / 2 + h / 2 - 44, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 260,
|
||||
}).setDepth(D.modal + 2);
|
||||
}
|
||||
|
||||
async recordHistory(scores) {
|
||||
const mine = scores.find((s) => s.seat === 0)?.vp ?? 0;
|
||||
const others = scores.filter((s) => s.seat !== 0).map((s) => s.vp);
|
||||
const winners = new Set(this.gs.winnerSeats);
|
||||
let result;
|
||||
if (winners.has(0) && winners.size === 1) result = 'win';
|
||||
else if (winners.has(0)) result = 'draw';
|
||||
else result = 'loss';
|
||||
try {
|
||||
await api.post('/history/single-player', {
|
||||
slug: 'dominion', score: mine, opponentScores: others, result,
|
||||
});
|
||||
} catch (_) { /* offline / not signed in */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,864 @@
|
|||
// Dominion — pure interactive state engine. No Phaser imports.
|
||||
//
|
||||
// Deck-building card game. Each turn: Action phase (play action cards) →
|
||||
// Buy phase (play treasures, buy cards) → Cleanup (discard everything, draw 5).
|
||||
// Game ends when the Province pile empties OR any 3 Supply piles empty.
|
||||
//
|
||||
// Card effects vary wildly, and many require a player decision (discard,
|
||||
// trash, gain, reveal, …). We model this with two channels carried in the
|
||||
// state:
|
||||
// • `queue` — a stack of effect "tasks" (plain data) processed depth-first.
|
||||
// • `pending` — the single decision a specific player must make right now.
|
||||
// `null` when nothing is awaited.
|
||||
// The driver (the scene for the human, the AI for opponents) resolves a
|
||||
// pending via `resolvePending`, which applies the choice and resumes the queue.
|
||||
|
||||
import {
|
||||
getCard, CARDS, BASE_TREASURES,
|
||||
kingdomFor, isType,
|
||||
} from './DominionCards.js';
|
||||
|
||||
// Mulberry32 — seedable PRNG (mirrors the other games).
|
||||
function rng(seed) {
|
||||
let a = (seed >>> 0) || 1;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function shuffleInPlace(arr, rand) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
}
|
||||
|
||||
const STARTING_COPPER = 7;
|
||||
const STARTING_ESTATE = 3;
|
||||
const HAND_SIZE = 5;
|
||||
|
||||
// ── State construction ──────────────────────────────────────────────────────
|
||||
|
||||
export function createInitialState({ seed, playerCount = 4, deckMode = 'standard' } = {}) {
|
||||
const rand = seed === undefined ? Math.random : rng(seed);
|
||||
const kingdom = kingdomFor(deckMode, rand);
|
||||
|
||||
const state = {
|
||||
playerCount,
|
||||
players: [],
|
||||
supply: {},
|
||||
kingdom,
|
||||
trash: [],
|
||||
turn: 0,
|
||||
phase: 'action',
|
||||
pending: null,
|
||||
queue: [],
|
||||
turnsTaken: Array(playerCount).fill(0),
|
||||
log: [],
|
||||
winnerSeats: [],
|
||||
seed: seed ?? null,
|
||||
deckMode,
|
||||
_rand: rand,
|
||||
_nextIid: 1,
|
||||
// transient per-effect scratch
|
||||
sentryLook: null,
|
||||
libraryAside: null,
|
||||
};
|
||||
|
||||
const victoryPile = playerCount <= 2 ? 8 : 12;
|
||||
|
||||
// Base supply.
|
||||
state.supply.copper = 60 - STARTING_COPPER * playerCount;
|
||||
state.supply.silver = 40;
|
||||
state.supply.gold = 30;
|
||||
state.supply.estate = victoryPile;
|
||||
state.supply.duchy = victoryPile;
|
||||
state.supply.province = victoryPile;
|
||||
state.supply.curse = 10 * (playerCount - 1);
|
||||
|
||||
// Kingdom supply (Victory-type Kingdom cards, e.g. Gardens, use the Victory count).
|
||||
for (const id of kingdom) {
|
||||
state.supply[id] = isType(id, 'victory') ? victoryPile : 10;
|
||||
}
|
||||
|
||||
// Players + starting decks (7 Copper, 3 Estate), shuffled, draw 5.
|
||||
for (let seat = 0; seat < playerCount; seat++) {
|
||||
const p = {
|
||||
seat,
|
||||
deck: [], hand: [], discard: [], inPlay: [],
|
||||
actions: 0, buys: 0, coins: 0,
|
||||
merchantSilverBonus: 0,
|
||||
firstSilverPlayed: false,
|
||||
};
|
||||
for (let i = 0; i < STARTING_COPPER; i++) p.deck.push(mint(state, 'copper'));
|
||||
for (let i = 0; i < STARTING_ESTATE; i++) p.deck.push(mint(state, 'estate'));
|
||||
shuffleInPlace(p.deck, rand);
|
||||
state.players.push(p);
|
||||
drawInto(state, p, HAND_SIZE);
|
||||
}
|
||||
|
||||
startTurn(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function mint(state, id) {
|
||||
return { iid: state._nextIid++, id };
|
||||
}
|
||||
|
||||
// ── Cloning ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function cloneInsts(arr) {
|
||||
return arr.map((c) => ({ iid: c.iid, id: c.id }));
|
||||
}
|
||||
|
||||
export function cloneState(state) {
|
||||
const out = {
|
||||
playerCount: state.playerCount,
|
||||
players: state.players.map((p) => ({
|
||||
seat: p.seat,
|
||||
deck: cloneInsts(p.deck),
|
||||
hand: cloneInsts(p.hand),
|
||||
discard: cloneInsts(p.discard),
|
||||
inPlay: cloneInsts(p.inPlay),
|
||||
actions: p.actions,
|
||||
buys: p.buys,
|
||||
coins: p.coins,
|
||||
merchantSilverBonus: p.merchantSilverBonus,
|
||||
firstSilverPlayed: p.firstSilverPlayed,
|
||||
})),
|
||||
supply: { ...state.supply },
|
||||
kingdom: state.kingdom.slice(),
|
||||
trash: cloneInsts(state.trash),
|
||||
turn: state.turn,
|
||||
phase: state.phase,
|
||||
pending: state.pending ? JSON.parse(JSON.stringify(state.pending)) : null,
|
||||
queue: state.queue.map((t) => JSON.parse(JSON.stringify(t))),
|
||||
turnsTaken: state.turnsTaken.slice(),
|
||||
log: state.log.map((e) => ({ ...e })),
|
||||
winnerSeats: state.winnerSeats.slice(),
|
||||
seed: state.seed,
|
||||
deckMode: state.deckMode,
|
||||
_rand: state._rand,
|
||||
_nextIid: state._nextIid,
|
||||
sentryLook: state.sentryLook ? cloneInsts(state.sentryLook) : null,
|
||||
libraryAside: state.libraryAside ? cloneInsts(state.libraryAside) : null,
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Card movement helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function reshuffle(state, p) {
|
||||
if (p.deck.length > 0 || p.discard.length === 0) return;
|
||||
p.deck = p.discard;
|
||||
p.discard = [];
|
||||
shuffleInPlace(p.deck, state._rand);
|
||||
state.log.push({ kind: 'shuffle', seat: p.seat });
|
||||
}
|
||||
|
||||
// Draw `n` from the top (index 0) of the deck into the hand.
|
||||
function drawInto(state, p, n) {
|
||||
let drawn = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (p.deck.length === 0) reshuffle(state, p);
|
||||
if (p.deck.length === 0) break;
|
||||
p.hand.push(p.deck.shift());
|
||||
drawn++;
|
||||
}
|
||||
if (drawn > 0) state.log.push({ kind: 'draw', seat: p.seat, n: drawn });
|
||||
return drawn;
|
||||
}
|
||||
|
||||
function gain(state, seat, id, dest = 'discard') {
|
||||
if ((state.supply[id] ?? 0) <= 0) return null;
|
||||
state.supply[id] -= 1;
|
||||
const inst = mint(state, id);
|
||||
const p = state.players[seat];
|
||||
if (dest === 'deck') p.deck.unshift(inst);
|
||||
else if (dest === 'hand') p.hand.push(inst);
|
||||
else p.discard.push(inst);
|
||||
state.log.push({ kind: 'gain', seat, id, dest });
|
||||
return inst;
|
||||
}
|
||||
|
||||
function trashFrom(state, p, iid) {
|
||||
const idx = p.hand.findIndex((c) => c.iid === iid);
|
||||
if (idx === -1) return null;
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
state.trash.push(c);
|
||||
state.log.push({ kind: 'trash', seat: p.seat, id: c.id });
|
||||
return c;
|
||||
}
|
||||
|
||||
function discardFromHand(state, p, iid) {
|
||||
const idx = p.hand.findIndex((c) => c.iid === iid);
|
||||
if (idx === -1) return null;
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
p.discard.push(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Turn lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
function startTurn(state) {
|
||||
const p = state.players[state.turn];
|
||||
p.actions = 1;
|
||||
p.buys = 1;
|
||||
p.coins = 0;
|
||||
p.merchantSilverBonus = 0;
|
||||
p.firstSilverPlayed = false;
|
||||
state.phase = 'action';
|
||||
state.pending = null;
|
||||
state.queue = [];
|
||||
state.turnsTaken[state.turn] += 1;
|
||||
state.log.push({ kind: 'turnStart', seat: state.turn });
|
||||
}
|
||||
|
||||
export function endActionPhase(state) {
|
||||
if (state.phase !== 'action' || state.pending) return state;
|
||||
const next = cloneState(state);
|
||||
next.phase = 'buy';
|
||||
next.log.push({ kind: 'phase', phase: 'buy', seat: next.turn });
|
||||
return next;
|
||||
}
|
||||
|
||||
export function endTurn(state) {
|
||||
if (state.pending) return state;
|
||||
if (state.phase === 'gameOver') return state;
|
||||
const next = cloneState(state);
|
||||
const p = next.players[next.turn];
|
||||
|
||||
// Cleanup: discard hand + in-play, draw a fresh hand.
|
||||
p.discard.push(...p.inPlay, ...p.hand);
|
||||
p.inPlay = [];
|
||||
p.hand = [];
|
||||
drawInto(next, p, HAND_SIZE);
|
||||
|
||||
next.log.push({ kind: 'turnEnd', seat: next.turn });
|
||||
|
||||
if (checkGameOver(next)) return next;
|
||||
|
||||
next.turn = (next.turn + 1) % next.playerCount;
|
||||
startTurn(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function checkGameOver(state) {
|
||||
const provinceEmpty = (state.supply.province ?? 0) <= 0;
|
||||
const emptyPiles = Object.values(state.supply).filter((c) => c <= 0).length;
|
||||
if (provinceEmpty || emptyPiles >= 3) {
|
||||
state.phase = 'gameOver';
|
||||
state.pending = null;
|
||||
state.queue = [];
|
||||
const scores = finalScores(state);
|
||||
const best = Math.max(...scores.map((s) => s.vp));
|
||||
// Tie-break: among top VP, fewest turns taken wins; still tied = shared.
|
||||
const topVp = scores.filter((s) => s.vp === best);
|
||||
const fewest = Math.min(...topVp.map((s) => state.turnsTaken[s.seat]));
|
||||
state.winnerSeats = topVp.filter((s) => state.turnsTaken[s.seat] === fewest).map((s) => s.seat);
|
||||
state.log.push({ kind: 'gameOver', winnerSeats: state.winnerSeats.slice() });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isGameOver(state) {
|
||||
return state.phase === 'gameOver';
|
||||
}
|
||||
|
||||
// ── Scoring ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function allCards(state, seat) {
|
||||
const p = state.players[seat];
|
||||
return [...p.deck, ...p.hand, ...p.discard, ...p.inPlay];
|
||||
}
|
||||
|
||||
export function finalScores(state) {
|
||||
return state.players.map((p) => {
|
||||
const cards = allCards(state, p.seat);
|
||||
let vp = 0;
|
||||
for (const c of cards) {
|
||||
const def = getCard(c.id);
|
||||
if (c.id === 'gardens') vp += Math.floor(cards.length / 10);
|
||||
else if (def.vp !== undefined) vp += def.vp;
|
||||
}
|
||||
return { seat: p.seat, vp, cards: cards.length };
|
||||
});
|
||||
}
|
||||
|
||||
// ── Playing cards ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function legalActionIids(state) {
|
||||
if (state.phase !== 'action' || state.pending) return [];
|
||||
const p = state.players[state.turn];
|
||||
if (p.actions <= 0) return [];
|
||||
return p.hand.filter((c) => isType(c.id, 'action')).map((c) => c.iid);
|
||||
}
|
||||
|
||||
export function playAction(state, iid) {
|
||||
if (state.phase !== 'action' || state.pending) return state;
|
||||
const p = state.players[state.turn];
|
||||
if (p.actions <= 0) return state;
|
||||
const idx = p.hand.findIndex((c) => c.iid === iid);
|
||||
if (idx === -1 || !isType(p.hand[idx].id, 'action')) return state;
|
||||
|
||||
const next = cloneState(state);
|
||||
const np = next.players[next.turn];
|
||||
const [card] = np.hand.splice(idx, 1);
|
||||
np.inPlay.push(card);
|
||||
np.actions -= 1;
|
||||
next.log.push({ kind: 'play', seat: next.turn, id: card.id });
|
||||
|
||||
next.queue.unshift({ type: 'effect', seat: next.turn, id: card.id });
|
||||
runQueue(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function playTreasure(state, iid) {
|
||||
if (state.phase !== 'buy' || state.pending) return state;
|
||||
const p = state.players[state.turn];
|
||||
const idx = p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'treasure'));
|
||||
if (idx === -1) return state;
|
||||
const next = cloneState(state);
|
||||
applyTreasure(next, next.turn, idx);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function playAllTreasures(state) {
|
||||
if (state.phase !== 'buy' || state.pending) return state;
|
||||
const next = cloneState(state);
|
||||
const p = next.players[next.turn];
|
||||
let idx;
|
||||
while ((idx = p.hand.findIndex((c) => isType(c.id, 'treasure'))) !== -1) {
|
||||
applyTreasure(next, next.turn, idx);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyTreasure(state, seat, handIdx) {
|
||||
const p = state.players[seat];
|
||||
const [card] = p.hand.splice(handIdx, 1);
|
||||
p.inPlay.push(card);
|
||||
const def = getCard(card.id);
|
||||
p.coins += def.coin ?? 0;
|
||||
if (card.id === 'silver' && !p.firstSilverPlayed) {
|
||||
p.coins += p.merchantSilverBonus;
|
||||
p.firstSilverPlayed = true;
|
||||
}
|
||||
state.log.push({ kind: 'playTreasure', seat, id: card.id });
|
||||
}
|
||||
|
||||
export function buyCard(state, id) {
|
||||
if (state.phase !== 'buy' || state.pending) return state;
|
||||
const p = state.players[state.turn];
|
||||
const def = CARDS[id];
|
||||
if (!def) return state;
|
||||
if (p.buys <= 0) return state;
|
||||
if ((state.supply[id] ?? 0) <= 0) return state;
|
||||
if (p.coins < def.cost) return state;
|
||||
|
||||
const next = cloneState(state);
|
||||
const np = next.players[next.turn];
|
||||
np.coins -= def.cost;
|
||||
np.buys -= 1;
|
||||
gain(next, next.turn, id, 'discard');
|
||||
next.log.push({ kind: 'buy', seat: next.turn, id });
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Effect engine ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Process queued tasks depth-first until the queue drains or a player decision
|
||||
// is required (`pending` set). Mutates `state`.
|
||||
function runQueue(state) {
|
||||
let guard = 0;
|
||||
while (state.queue.length > 0 && !state.pending) {
|
||||
if (++guard > 10000) break;
|
||||
const task = state.queue.shift();
|
||||
execTask(state, task);
|
||||
}
|
||||
}
|
||||
|
||||
function execTask(state, task) {
|
||||
switch (task.type) {
|
||||
case 'effect':
|
||||
applyEffect(state, task.seat, task.id);
|
||||
break;
|
||||
case 'draw':
|
||||
drawInto(state, state.players[task.seat], task.n);
|
||||
break;
|
||||
case 'gain':
|
||||
gain(state, task.seat, task.id, task.dest ?? 'discard');
|
||||
break;
|
||||
case 'discardDownTo': {
|
||||
const p = state.players[task.seat];
|
||||
const excess = p.hand.length - task.n;
|
||||
if (excess > 0) {
|
||||
state.pending = { seat: task.seat, kind: 'discardDownTo', count: excess, source: 'militia' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moatThen': {
|
||||
const p = state.players[task.seat];
|
||||
const hasMoat = p.hand.some((c) => c.id === 'moat');
|
||||
if (hasMoat) {
|
||||
state.pending = { seat: task.seat, kind: 'moatReveal', attack: task.attack, source: task.source };
|
||||
} else {
|
||||
state.queue.unshift(task.attack);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'witchCurse':
|
||||
gain(state, task.seat, 'curse', 'discard');
|
||||
break;
|
||||
case 'banditAttack':
|
||||
banditAttack(state, task.seat);
|
||||
break;
|
||||
case 'bureaucratAttack':
|
||||
bureaucratAttack(state, task.seat);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a card's printed effect for the active player `seat`.
|
||||
function applyEffect(state, seat, id) {
|
||||
const def = getCard(id);
|
||||
const p = state.players[seat];
|
||||
|
||||
// Vanilla bonuses.
|
||||
if (def.plus.actions) p.actions += def.plus.actions;
|
||||
if (def.plus.buys) p.buys += def.plus.buys;
|
||||
if (def.plus.coins) p.coins += def.plus.coins;
|
||||
if (def.plus.cards) drawInto(state, p, def.plus.cards);
|
||||
|
||||
// Card-specific.
|
||||
const fn = SPECIAL[id];
|
||||
if (fn) fn(state, seat);
|
||||
}
|
||||
|
||||
// Other players' seats in turn order starting after `seat`.
|
||||
function otherSeats(state, seat) {
|
||||
const out = [];
|
||||
for (let i = 1; i < state.playerCount; i++) out.push((seat + i) % state.playerCount);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Queue an attack against every other player, each gated by a Moat check.
|
||||
function queueAttack(state, seat, attackFactory, source) {
|
||||
const tasks = otherSeats(state, seat).map((o) => ({
|
||||
type: 'moatThen', seat: o, source, attack: attackFactory(o),
|
||||
}));
|
||||
state.queue.unshift(...tasks);
|
||||
}
|
||||
|
||||
const SPECIAL = {
|
||||
cellar(state, seat) {
|
||||
state.pending = { seat, kind: 'cellarDiscard' };
|
||||
},
|
||||
chapel(state, seat) {
|
||||
state.pending = { seat, kind: 'chapelTrash', max: 4 };
|
||||
},
|
||||
harbinger(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.discard.length > 0) state.pending = { seat, kind: 'harbingerTopdeck' };
|
||||
},
|
||||
merchant(state, seat) {
|
||||
state.players[seat].merchantSilverBonus += 1;
|
||||
},
|
||||
vassal(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.deck.length === 0) reshuffle(state, p);
|
||||
if (p.deck.length === 0) return;
|
||||
const top = p.deck.shift();
|
||||
p.discard.push(top);
|
||||
state.log.push({ kind: 'vassalDiscard', seat, id: top.id });
|
||||
if (isType(top.id, 'action')) {
|
||||
state.pending = { seat, kind: 'vassalPlay', cardIid: top.iid, cardId: top.id };
|
||||
}
|
||||
},
|
||||
workshop(state, seat) {
|
||||
state.pending = { seat, kind: 'gainFromSupply', maxCost: 4, dest: 'discard', source: 'workshop' };
|
||||
},
|
||||
bureaucrat(state, seat) {
|
||||
gain(state, seat, 'silver', 'deck');
|
||||
queueAttack(state, seat, (o) => ({ type: 'bureaucratAttack', seat: o }), 'bureaucrat');
|
||||
},
|
||||
militia(state, seat) {
|
||||
queueAttack(state, seat, (o) => ({ type: 'discardDownTo', seat: o, n: 3 }), 'militia');
|
||||
},
|
||||
moneylender(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.some((c) => c.id === 'copper')) {
|
||||
state.pending = { seat, kind: 'moneylenderTrash' };
|
||||
}
|
||||
},
|
||||
poacher(state, seat) {
|
||||
const empties = Object.values(state.supply).filter((c) => c <= 0).length;
|
||||
const p = state.players[seat];
|
||||
const count = Math.min(empties, p.hand.length);
|
||||
if (count > 0) state.pending = { seat, kind: 'poacherDiscard', count };
|
||||
},
|
||||
remodel(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.length > 0) state.pending = { seat, kind: 'remodelTrash' };
|
||||
},
|
||||
throneroom(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.some((c) => isType(c.id, 'action'))) {
|
||||
state.pending = { seat, kind: 'throneChoose' };
|
||||
}
|
||||
},
|
||||
bandit(state, seat) {
|
||||
gain(state, seat, 'gold', 'discard');
|
||||
queueAttack(state, seat, (o) => ({ type: 'banditAttack', seat: o }), 'bandit');
|
||||
},
|
||||
councilroom(state, seat) {
|
||||
const tasks = otherSeats(state, seat).map((o) => ({ type: 'draw', seat: o, n: 1 }));
|
||||
state.queue.unshift(...tasks);
|
||||
},
|
||||
library(state, seat) {
|
||||
state.libraryAside = [];
|
||||
libraryStep(state, seat);
|
||||
},
|
||||
mine(state, seat) {
|
||||
const p = state.players[seat];
|
||||
if (p.hand.some((c) => isType(c.id, 'treasure'))) {
|
||||
state.pending = { seat, kind: 'mineTrash' };
|
||||
}
|
||||
},
|
||||
sentry(state, seat) {
|
||||
const p = state.players[seat];
|
||||
const look = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (p.deck.length === 0) reshuffle(state, p);
|
||||
if (p.deck.length === 0) break;
|
||||
look.push(p.deck.shift());
|
||||
}
|
||||
if (look.length === 0) return;
|
||||
state.sentryLook = look;
|
||||
state.pending = { seat, kind: 'sentry', cards: look.map((c) => ({ iid: c.iid, id: c.id })) };
|
||||
},
|
||||
witch(state, seat) {
|
||||
queueAttack(state, seat, (o) => ({ type: 'witchCurse', seat: o }), 'witch');
|
||||
},
|
||||
artisan(state, seat) {
|
||||
state.pending = { seat, kind: 'gainFromSupply', maxCost: 5, dest: 'hand', source: 'artisan' };
|
||||
},
|
||||
};
|
||||
|
||||
// Library: draw until 7 in hand, pausing on each Action drawn to ask keep/skip.
|
||||
function libraryStep(state, seat) {
|
||||
const p = state.players[seat];
|
||||
while (p.hand.length < 7) {
|
||||
if (p.deck.length === 0) reshuffle(state, p);
|
||||
if (p.deck.length === 0) break;
|
||||
const c = p.deck.shift();
|
||||
if (isType(c.id, 'action')) {
|
||||
// Hold in hand provisionally; ask whether to set aside.
|
||||
p.hand.push(c);
|
||||
state.pending = { seat, kind: 'libraryKeep', cardIid: c.iid, cardId: c.id };
|
||||
return;
|
||||
}
|
||||
p.hand.push(c);
|
||||
}
|
||||
// Done — discard any set-aside cards.
|
||||
if (state.libraryAside && state.libraryAside.length) {
|
||||
p.discard.push(...state.libraryAside);
|
||||
}
|
||||
state.libraryAside = null;
|
||||
}
|
||||
|
||||
function banditAttack(state, seat) {
|
||||
const p = state.players[seat];
|
||||
const revealed = [];
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (p.deck.length === 0) reshuffle(state, p);
|
||||
if (p.deck.length === 0) break;
|
||||
revealed.push(p.deck.shift());
|
||||
}
|
||||
state.log.push({ kind: 'reveal', seat, ids: revealed.map((c) => c.id) });
|
||||
const trashable = revealed.filter((c) => isType(c.id, 'treasure') && c.id !== 'copper');
|
||||
if (trashable.length === 0) {
|
||||
p.discard.push(...revealed);
|
||||
} else if (trashable.length === 1) {
|
||||
const t = trashable[0];
|
||||
state.trash.push(t);
|
||||
state.log.push({ kind: 'trash', seat, id: t.id });
|
||||
p.discard.push(...revealed.filter((c) => c.iid !== t.iid));
|
||||
} else {
|
||||
// Two trashable treasures — the victim chooses which to trash.
|
||||
state._banditRevealed = revealed.map((c) => ({ iid: c.iid, id: c.id }));
|
||||
state.pending = {
|
||||
seat, kind: 'banditTrash',
|
||||
options: trashable.map((c) => ({ iid: c.iid, id: c.id })),
|
||||
revealed: revealed.map((c) => ({ iid: c.iid, id: c.id })),
|
||||
};
|
||||
// Stash the actual instances so resolution can place them.
|
||||
state._banditInsts = revealed;
|
||||
}
|
||||
}
|
||||
|
||||
function bureaucratAttack(state, seat) {
|
||||
const p = state.players[seat];
|
||||
const victories = p.hand.filter((c) => isType(c.id, 'victory'));
|
||||
if (victories.length === 0) {
|
||||
state.log.push({ kind: 'reveal', seat, ids: p.hand.map((c) => c.id) });
|
||||
return;
|
||||
}
|
||||
if (victories.length === 1) {
|
||||
const v = victories[0];
|
||||
const idx = p.hand.findIndex((c) => c.iid === v.iid);
|
||||
const [card] = p.hand.splice(idx, 1);
|
||||
p.deck.unshift(card);
|
||||
state.log.push({ kind: 'topdeck', seat, id: card.id });
|
||||
return;
|
||||
}
|
||||
state.pending = {
|
||||
seat, kind: 'bureaucratTopdeck',
|
||||
options: victories.map((c) => ({ iid: c.iid, id: c.id })),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Decision resolution ─────────────────────────────────────────────────────
|
||||
|
||||
export function resolvePending(state, choice) {
|
||||
if (!state.pending) return state;
|
||||
const next = cloneState(state);
|
||||
const pend = next.pending;
|
||||
const seat = pend.seat;
|
||||
const p = next.players[seat];
|
||||
next.pending = null;
|
||||
|
||||
switch (pend.kind) {
|
||||
case 'cellarDiscard': {
|
||||
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
|
||||
for (const iid of iids) discardFromHand(next, p, iid);
|
||||
drawInto(next, p, iids.length);
|
||||
break;
|
||||
}
|
||||
case 'chapelTrash': {
|
||||
const iids = (choice?.iids ?? []).slice(0, pend.max)
|
||||
.filter((id) => p.hand.some((c) => c.iid === id));
|
||||
for (const iid of iids) trashFrom(next, p, iid);
|
||||
break;
|
||||
}
|
||||
case 'harbingerTopdeck': {
|
||||
const iid = choice?.iid;
|
||||
const idx = iid != null ? p.discard.findIndex((c) => c.iid === iid) : -1;
|
||||
if (idx !== -1) {
|
||||
const [c] = p.discard.splice(idx, 1);
|
||||
p.deck.unshift(c);
|
||||
next.log.push({ kind: 'topdeck', seat, id: c.id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'vassalPlay': {
|
||||
if (choice?.play) {
|
||||
const idx = p.discard.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) {
|
||||
const [c] = p.discard.splice(idx, 1);
|
||||
p.inPlay.push(c);
|
||||
next.log.push({ kind: 'play', seat, id: c.id });
|
||||
next.queue.unshift({ type: 'effect', seat, id: c.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'gainFromSupply': {
|
||||
const id = choice?.id;
|
||||
if (id && canGain(next, id, pend.maxCost, pend.filterTreasure)) {
|
||||
gain(next, seat, id, pend.dest ?? 'discard');
|
||||
if (pend.source === 'artisan') {
|
||||
// Then put a card from hand onto the deck.
|
||||
if (p.hand.length > 0) next.pending = { seat, kind: 'artisanTopdeck' };
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'artisanTopdeck': {
|
||||
const iid = choice?.iid;
|
||||
const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid) : -1;
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
p.deck.unshift(c);
|
||||
next.log.push({ kind: 'topdeck', seat, id: c.id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'remodelTrash': {
|
||||
const iid = choice?.iid;
|
||||
const c = iid != null ? trashFrom(next, p, iid) : null;
|
||||
if (c) {
|
||||
const maxCost = getCard(c.id).cost + 2;
|
||||
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'discard', source: 'remodel' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'mineTrash': {
|
||||
const iid = choice?.iid;
|
||||
const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'treasure')) : -1;
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
next.trash.push(c);
|
||||
next.log.push({ kind: 'trash', seat, id: c.id });
|
||||
const maxCost = getCard(c.id).cost + 3;
|
||||
next.pending = { seat, kind: 'gainFromSupply', maxCost, dest: 'hand', filterTreasure: true, source: 'mine' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'moneylenderTrash': {
|
||||
if (choice?.confirm) {
|
||||
const idx = p.hand.findIndex((c) => c.id === 'copper');
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
next.trash.push(c);
|
||||
p.coins += 3;
|
||||
next.log.push({ kind: 'trash', seat, id: c.id });
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'throneChoose': {
|
||||
const iid = choice?.iid;
|
||||
const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid && isType(c.id, 'action')) : -1;
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
p.inPlay.push(c);
|
||||
next.log.push({ kind: 'play', seat, id: c.id, throne: true });
|
||||
next.queue.unshift({ type: 'effect', seat, id: c.id }, { type: 'effect', seat, id: c.id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'libraryKeep': {
|
||||
if (!choice?.keep) {
|
||||
// Set the just-drawn action aside (remove from hand → aside pile).
|
||||
const idx = p.hand.findIndex((c) => c.iid === pend.cardIid);
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
(next.libraryAside ??= []).push(c);
|
||||
}
|
||||
}
|
||||
libraryStep(next, seat);
|
||||
break;
|
||||
}
|
||||
case 'sentry': {
|
||||
const look = next.sentryLook ?? [];
|
||||
const byIid = new Map(look.map((c) => [c.iid, c]));
|
||||
const trashIds = new Set(choice?.trash ?? []);
|
||||
const discardIds = new Set(choice?.discard ?? []);
|
||||
// Trash & discard chosen cards.
|
||||
for (const c of look) {
|
||||
if (trashIds.has(c.iid)) { next.trash.push(c); next.log.push({ kind: 'trash', seat, id: c.id }); }
|
||||
else if (discardIds.has(c.iid)) { p.discard.push(c); }
|
||||
}
|
||||
// Remaining go back on top in the given order (or default order).
|
||||
const keep = look.filter((c) => !trashIds.has(c.iid) && !discardIds.has(c.iid));
|
||||
const order = (choice?.top ?? keep.map((c) => c.iid));
|
||||
const ordered = order.map((iid) => byIid.get(iid)).filter((c) => c && keep.includes(c));
|
||||
for (const c of keep) if (!ordered.includes(c)) ordered.push(c);
|
||||
// Top of deck = index 0; push so the first listed ends up on top.
|
||||
for (let i = ordered.length - 1; i >= 0; i--) p.deck.unshift(ordered[i]);
|
||||
next.sentryLook = null;
|
||||
break;
|
||||
}
|
||||
case 'poacherDiscard': {
|
||||
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
|
||||
const need = Math.min(pend.count, p.hand.length);
|
||||
const chosen = iids.slice(0, need);
|
||||
// If the chooser under-selected, auto-fill from hand.
|
||||
while (chosen.length < need) {
|
||||
const c = p.hand.find((h) => !chosen.includes(h.iid));
|
||||
if (!c) break;
|
||||
chosen.push(c.iid);
|
||||
}
|
||||
for (const iid of chosen) discardFromHand(next, p, iid);
|
||||
break;
|
||||
}
|
||||
case 'discardDownTo': {
|
||||
const iids = (choice?.iids ?? []).filter((id) => p.hand.some((c) => c.iid === id));
|
||||
const chosen = iids.slice(0, pend.count);
|
||||
while (chosen.length < pend.count && chosen.length < p.hand.length) {
|
||||
const c = p.hand.find((h) => !chosen.includes(h.iid));
|
||||
if (!c) break;
|
||||
chosen.push(c.iid);
|
||||
}
|
||||
for (const iid of chosen) discardFromHand(next, p, iid);
|
||||
break;
|
||||
}
|
||||
case 'moatReveal': {
|
||||
if (choice?.reveal) {
|
||||
next.log.push({ kind: 'moat', seat });
|
||||
} else {
|
||||
next.queue.unshift(pend.attack);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'banditTrash': {
|
||||
const insts = state._banditInsts ?? [];
|
||||
const chosenIid = choice?.iid ?? (pend.options[0] && pend.options[0].iid);
|
||||
for (const c of insts) {
|
||||
if (c.iid === chosenIid) { next.trash.push({ iid: c.iid, id: c.id }); next.log.push({ kind: 'trash', seat, id: c.id }); }
|
||||
else p.discard.push({ iid: c.iid, id: c.id });
|
||||
}
|
||||
delete next._banditInsts;
|
||||
delete next._banditRevealed;
|
||||
break;
|
||||
}
|
||||
case 'bureaucratTopdeck': {
|
||||
const iid = choice?.iid ?? pend.options[0]?.iid;
|
||||
const idx = iid != null ? p.hand.findIndex((c) => c.iid === iid) : -1;
|
||||
if (idx !== -1) {
|
||||
const [c] = p.hand.splice(idx, 1);
|
||||
p.deck.unshift(c);
|
||||
next.log.push({ kind: 'topdeck', seat, id: c.id });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
runQueue(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── Query helpers (used by the AI and UI) ──────────────────────────────────────
|
||||
|
||||
export function canGain(state, id, maxCost, filterTreasure = false) {
|
||||
const def = CARDS[id];
|
||||
if (!def) return false;
|
||||
if ((state.supply[id] ?? 0) <= 0) return false;
|
||||
if (def.cost > maxCost) return false;
|
||||
if (filterTreasure && !def.types.includes('treasure')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function affordableSupply(state, maxCost, filterTreasure = false) {
|
||||
return supplyIds(state).filter((id) => canGain(state, id, maxCost, filterTreasure));
|
||||
}
|
||||
|
||||
export function supplyIds(state) {
|
||||
return [...BASE_TREASURES, 'estate', 'duchy', 'province', 'curse', ...state.kingdom];
|
||||
}
|
||||
|
||||
export function emptyPileCount(state) {
|
||||
return Object.values(state.supply).filter((c) => c <= 0).length;
|
||||
}
|
||||
|
||||
export function handCoinValue(state, seat) {
|
||||
const p = state.players[seat];
|
||||
let coins = 0;
|
||||
let firstSilver = false;
|
||||
for (const c of p.hand) {
|
||||
const def = getCard(c.id);
|
||||
coins += def.coin ?? 0;
|
||||
if (c.id === 'silver' && !firstSilver) { coins += p.merchantSilverBonus; firstSilver = true; }
|
||||
}
|
||||
return coins;
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import CatanGame from './games/catan/CatanGame.js';
|
|||
import NertsGame from './games/nerts/NertsGame.js';
|
||||
import BingoGame from './games/bingo/BingoGame.js';
|
||||
import BaccaratGame from './games/baccarat/BaccaratGame.js';
|
||||
import DominionGame from './games/dominion/DominionGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -69,6 +70,7 @@ const config = {
|
|||
NertsGame,
|
||||
BingoGame,
|
||||
BaccaratGame,
|
||||
DominionGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -13,10 +13,11 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
this.playfield = data.playfield ?? null;
|
||||
this.cardBack = data.cardBack ?? null;
|
||||
this.tilePlacement = data.tilePlacement ?? 'standard';
|
||||
this.deckMode = data.deckMode ?? 'standard';
|
||||
}
|
||||
|
||||
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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame' };
|
||||
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', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
@ -24,6 +25,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
playfield: this.playfield,
|
||||
cardBack: this.cardBack,
|
||||
tilePlacement: this.tilePlacement,
|
||||
deckMode: this.deckMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.cardBackTiles = [];
|
||||
this.selectedTilePlacement = 'standard';
|
||||
this.selectedMatchVariant = 4;
|
||||
this.selectedDeckMode = 'standard';
|
||||
this._initializing = false;
|
||||
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
||||
}
|
||||
|
|
@ -113,6 +114,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
const isGoFish = this.gameDef.slug === 'gofish';
|
||||
if (isGoFish) this.buildMatchVariantSection(340, 1013);
|
||||
|
||||
if (this.gameDef.slug === 'dominion') this.buildDeckModeSection(340, 1013);
|
||||
|
||||
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
|
||||
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
|
||||
|
||||
|
|
@ -525,6 +528,52 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Dominion: Kingdom deck mode toggle ─────────────────────────────────────
|
||||
buildDeckModeSection(centerX, centerY) {
|
||||
const options = [
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
{ id: 'random', label: 'Random' },
|
||||
];
|
||||
const pillW = 150, pillH = 40, pillGap = 12;
|
||||
const totalW = options.length * pillW + (options.length - 1) * pillGap;
|
||||
const labelY = centerY - 28;
|
||||
const pillY = centerY + 10;
|
||||
|
||||
const labelText = this.add.text(centerX, labelY, 'Kingdom', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '20px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
|
||||
this.children.moveBelow(labelBg, labelText);
|
||||
|
||||
this._deckModeBtns = [];
|
||||
options.forEach((opt, i) => {
|
||||
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
|
||||
const isSelected = this.selectedDeckMode === opt.id;
|
||||
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
|
||||
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
|
||||
this.children.moveBelow(pillBg, bg);
|
||||
this.add.text(x, pillY, opt.label, {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '16px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const refresh = () => {
|
||||
this._deckModeBtns.forEach(({ bg: b, id }) =>
|
||||
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted)
|
||||
);
|
||||
};
|
||||
bg.on('pointerup', () => { this.selectedDeckMode = opt.id; refresh(); });
|
||||
bg.on('pointerover', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
|
||||
bg.on('pointerout', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
|
||||
this._deckModeBtns.push({ bg, id: opt.id });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Go Fish: match variant toggle ─────────────────────────────────────────
|
||||
buildMatchVariantSection(centerX, centerY) {
|
||||
const options = [
|
||||
|
|
@ -692,6 +741,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
cardBack: this.selectedCardBack,
|
||||
tilePlacement: this.selectedTilePlacement,
|
||||
matchVariant: this.selectedMatchVariant,
|
||||
deckMode: this.selectedDeckMode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
|
||||
|
||||
this.load.spritesheet('catan-special-cards', '/assets/images/catan-special-cards.png', { frameWidth: 270, frameHeight: 390 });
|
||||
|
||||
// Dominion card art. One 270×390 cell per card (art fills the top ~60%;
|
||||
// the title/icon band is drawn at runtime). Optional — the scene falls back
|
||||
// to procedural placeholders when the sheet is absent.
|
||||
this.load.spritesheet('dominion-cards', '/assets/images/dominioncards.png', { frameWidth: 270, frameHeight: 390 });
|
||||
}
|
||||
|
||||
async create() {
|
||||
|
|
|
|||
|
|
@ -41,3 +41,4 @@ registerGame({ slug: 'catan', name: 'Settlers of Catan', category: 'tabletop', c
|
|||
registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
|
||||
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });
|
||||
registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue