feat: add Checkers and Chess games with AI opponents and improve Dominion UX

- Implement Checkers and Chess with complete Phaser UI, pure logic modules, and alpha-beta minimax AI opponents featuring a 1-5 skill model.
- Extend opponent skill selector to support Checkers and Chess.
- Register new games in frontend routing and backend registry.
- Improve Dominion game UX: add persistent phase dials and turn arrow indicators, add confirmation prompts for ending actions/turns, and refine card animation timings.
- Update Dominion card asset files.
This commit is contained in:
Brian Fertig 2026-05-26 23:52:05 -06:00
parent 97d748b2f5
commit 71d279c835
14 changed files with 2208 additions and 12 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@ -0,0 +1,127 @@
// Checkers AI — alpha-beta minimax with a Nerts-style 1..5 skill model.
//
// Skill controls four axes (mirrors NertsAI's delay/miss/quality idea):
// • depth — search depth in full turns (deeper = stronger)
// • blunder — chance to ignore the best move and play a random legal one
// • noise — random value added to root move scores (flattens decisions)
// • delay — "thinking" pause (ms range) before the move, for natural pacing
// The scene asks for ONE step at a time; multi-jumps come back across ticks
// because applyMove keeps the same side to move until the chain ends.
import {
getValidMoves, applyMove, expandTurns, pieceCounts, SIZE,
} from './CheckersLogic.js';
const SKILL_PROFILES = {
1: { depth: 1, blunder: 0.45, noise: 90, delay: [900, 1500] },
2: { depth: 2, blunder: 0.28, noise: 55, delay: [800, 1300] },
3: { depth: 3, blunder: 0.15, noise: 30, delay: [650, 1100] },
4: { depth: 5, blunder: 0.05, noise: 12, delay: [500, 900] },
5: { depth: 7, blunder: 0.00, noise: 0, delay: [400, 800] },
};
const WIN = 100000;
const MAN = 100;
const KING = 175;
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
function evaluate(state, aiColor) {
if (state.phase === 'game_over') {
if (state.winner === aiColor) return WIN;
if (state.winner === 'draw') return 0;
return -WIN;
}
let score = 0;
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const p = state.board[r][c];
if (!p) continue;
let v = p.king ? KING : MAN;
if (!p.king) {
// Reward advancement toward the crown row.
const adv = p.color === 'white' ? (7 - r) : r;
v += adv * 3;
// Reward holding the back row (blocks opponent promotion).
const homeRow = p.color === 'white' ? 7 : 0;
if (r === homeRow) v += 8;
}
// Central files are worth slightly more than the edges.
v += (c >= 2 && c <= 5) ? 4 : 0;
score += (p.color === aiColor ? v : -v);
}
}
// Small mobility term.
const moves = getValidMoves(state).length;
score += (state.turn === aiColor ? moves : -moves) * 1.5;
return score;
}
function search(state, depth, alpha, beta, aiColor) {
if (state.phase === 'game_over' || depth <= 0) return evaluate(state, aiColor);
const outcomes = expandTurns(state);
if (outcomes.length === 0) return evaluate(state, aiColor);
if (state.turn === aiColor) {
let value = -Infinity;
for (const o of outcomes) {
value = Math.max(value, search(o.state, depth - 1, alpha, beta, aiColor));
alpha = Math.max(alpha, value);
if (alpha >= beta) break;
}
return value;
}
let value = Infinity;
for (const o of outcomes) {
value = Math.min(value, search(o.state, depth - 1, alpha, beta, aiColor));
beta = Math.min(beta, value);
if (beta <= alpha) break;
}
return value;
}
// Return one step move for `aiColor`, or null if it cannot move.
export function chooseMove(state, aiColor, skill) {
const prof = profileFor(skill);
const steps = getValidMoves(state);
if (steps.length === 0) return null;
if (steps.length === 1) return steps[0];
if (Math.random() < prof.blunder) {
return steps[Math.floor(Math.random() * steps.length)];
}
// Score each legal first step by the best end-of-turn position it can reach.
const byStep = new Map();
for (const o of expandTurns(state)) {
const key = stepKey(o.firstStep);
let val;
if (o.state.turn === aiColor && o.state.phase !== 'game_over') {
val = search(o.state, prof.depth, -Infinity, Infinity, aiColor);
} else {
val = search(o.state, prof.depth - 1, -Infinity, Infinity, aiColor);
}
if (!byStep.has(key) || val > byStep.get(key).val) {
byStep.set(key, { step: o.firstStep, val });
}
}
let best = null;
let bestScore = -Infinity;
for (const { step, val } of byStep.values()) {
const noisy = val + (Math.random() * 2 - 1) * prof.noise;
if (noisy > bestScore) { bestScore = noisy; best = step; }
}
return best ?? steps[0];
}
function stepKey(step) {
return `${step.from[0]},${step.from[1]}->${step.to[0]},${step.to[1]}`;
}

View File

@ -0,0 +1,532 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState, getValidMoves, applyMove, isGameOver, getWinner, pieceCounts, SIZE,
} from './CheckersLogic.js';
import { chooseMove, nextThinkDelay } from './CheckersAI.js';
// ── Layout ──────────────────────────────────────────────────────────────────
const SQ = 104;
const BOARD = SQ * SIZE; // 832
const BX = Math.round(GAME_WIDTH / 2 - BOARD / 2); // centered
const BY = Math.round(GAME_HEIGHT / 2 - BOARD / 2);
const FRAME = 30;
const CR = SQ * 0.38; // checker radius
const DEPTH = { board: 0, piece: 10, overlay: 20, moving: 30, ui: 50, banner: 60 };
const C = {
light: 0xebe6c8,
dark: 0x6f9c5a,
frame: 0x3a2414,
frameLt: 0x6b4423,
frameLn: 0x8b5c2a,
wRing: 0xfffaf0,
wFill: 0xe6d8b8,
wEdge: 0xb8a679,
bRing: 0x4a4654,
bFill: 0x201e26,
bEdge: 0x12111a,
kingGold: 0xffd54a,
selGold: 0xffd700,
};
export default class CheckersGame extends Phaser.Scene {
constructor() { super('CheckersGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.selected = null; // [r, c] of selected piece
this.pieceObjs = []; // [{ r, c, container }]
this.overlayObjs = []; // transient highlights / hints / selection ring
this.opponentPortrait = null;
this.turnText = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoard();
this.buildUI();
this.buildPlayerCards();
this.initGame();
}
// ── Construction ────────────────────────────────────────────────────────────
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('checkersParticle', 10, 10);
g.destroy();
}
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.board - 2);
} else if (pf?.fallbackColor) {
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
.setDepth(DEPTH.board - 2);
}
}
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.board);
// Wood frame
g.fillStyle(C.frame, 1);
g.fillRoundedRect(BX - FRAME, BY - FRAME, BOARD + FRAME * 2, BOARD + FRAME * 2, 12);
g.lineStyle(3, C.frameLt, 1);
g.strokeRoundedRect(BX - FRAME + 5, BY - FRAME + 5, BOARD + FRAME * 2 - 10, BOARD + FRAME * 2 - 10, 9);
g.lineStyle(1, C.frameLn, 0.6);
g.strokeRect(BX - 2, BY - 2, BOARD + 4, BOARD + 4);
// Squares
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const dark = (r + c) % 2 === 1;
g.fillStyle(dark ? C.dark : C.light, 1);
g.fillRect(BX + c * SQ, BY + r * SQ, SQ, SQ);
}
}
// Coordinate labels (ah bottom, 81 left)
for (let c = 0; c < SIZE; c++) {
this.add.text(BX + c * SQ + SQ / 2, BY + BOARD + 16, String.fromCharCode(97 + c), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
for (let r = 0; r < SIZE; r++) {
this.add.text(BX - 16, BY + r * SQ + SQ / 2, String(SIZE - r), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
}
buildUI() {
const cx = BX + BOARD / 2;
this.turnText = this.add.text(cx, BY + BOARD + 52, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
new Button(this, BX + BOARD + FRAME + 90, BY + 60, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 150, height: 46, fontSize: 20,
}).setDepth(DEPTH.ui);
new Button(this, BX + BOARD + FRAME + 90, BY + 124, 'New', () => this.initGame(), {
variant: 'ghost', width: 150, height: 46, fontSize: 20,
}).setDepth(DEPTH.ui);
}
buildPlayerCards() {
const opp = this.opponents[0];
const r = 78;
const depth = DEPTH.ui;
const avatarX = BX / 2;
const oppAY = BY + r + 20;
this.add.circle(avatarX, oppAY, r + 5, C.frame).setDepth(depth);
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(depth + 2);
this.oppTrayY = oppAY + r + 64;
const plrAY = BY + BOARD - r - 20;
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Checkers');
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 1).setDepth(depth + 2);
this.plrTrayY = plrAY - r - 56;
this.trayObjs = [];
}
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
// ── Game flow ─────────────────────────────────────────────────────────────
initGame() {
this.clearOverlays();
this.clearPieces();
this.animating = false;
this.selected = null;
this.gs = createInitialState();
this.renderAll();
this.showTurnBanner('Your Turn');
}
renderAll() {
this.clearPieces();
this.renderPieces();
this.renderTrays();
this.clearOverlays();
this.updateTurnText();
if (this.gs.phase === 'playing' && this.gs.turn === 'white'
&& !this.animating && !this.gs.mustContinueFrom) {
this.showMovableHints();
}
}
updateTurnText() {
if (!this.turnText) return;
if (this.gs.phase === 'game_over') { this.turnText.setText(''); return; }
this.turnText.setText(this.gs.turn === 'white' ? 'Your move' : 'Opponent thinking…');
}
// ── Pieces ──────────────────────────────────────────────────────────────────
sqToWorld(r, c) {
return { x: BX + c * SQ + SQ / 2, y: BY + r * SQ + SQ / 2 };
}
clearPieces() {
for (const o of this.pieceObjs) o.container.destroy();
this.pieceObjs = [];
}
renderPieces() {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const p = this.gs.board[r][c];
if (!p) continue;
const { x, y } = this.sqToWorld(r, c);
const cont = this.makeDisc(p.color, p.king, x, y);
cont.setDepth(DEPTH.piece);
cont.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Circle(0, 0, CR),
hitAreaCallback: Phaser.Geom.Circle.Contains,
});
cont.on('pointerdown', () => this.onPieceClick(r, c));
this.pieceObjs.push({ r, c, container: cont });
}
}
}
makeDisc(color, king, x, y) {
const g = this.add.graphics();
const ring = color === 'white' ? C.wRing : C.bRing;
const fill = color === 'white' ? C.wFill : C.bFill;
const edge = color === 'white' ? C.wEdge : C.bEdge;
// Shadow
g.fillStyle(0x000000, 0.28);
g.fillCircle(3, 5, CR);
// Outer ring + edge ridge
g.fillStyle(edge, 1);
g.fillCircle(0, 0, CR);
g.fillStyle(ring, 1);
g.fillCircle(0, 0, CR - 3);
// Inner face
g.fillStyle(fill, 1);
g.fillCircle(0, 0, CR - 8);
// Concentric ridge groove
g.lineStyle(2, edge, 0.5);
g.strokeCircle(0, 0, CR - 13);
// Top-left sheen
g.lineStyle(3, color === 'white' ? 0xffffff : 0x7a7690, 0.5);
g.beginPath();
g.arc(0, 0, CR - 11, Phaser.Math.DegToRad(200), Phaser.Math.DegToRad(320));
g.strokePath();
const items = [g];
if (king) {
const ringG = this.add.graphics();
ringG.lineStyle(3, C.kingGold, 0.95);
ringG.strokeCircle(0, 0, CR - 4);
const crown = this.add.graphics();
this.drawCrown(crown, C.kingGold);
items.push(ringG, crown);
}
return this.add.container(x, y, items);
}
drawCrown(g, color) {
const w = CR * 1.15, h = CR * 0.78;
const peakY = -h * 0.5, valleyY = -h * 0.02, baseY = h * 0.5;
const pts = [
{ x: -w / 2, y: baseY }, { x: -w / 2, y: peakY },
{ x: -w / 4, y: valleyY }, { x: 0, y: peakY },
{ x: w / 4, y: valleyY }, { x: w / 2, y: peakY },
{ x: w / 2, y: baseY },
];
g.fillStyle(color, 1);
g.fillPoints(pts, true);
g.lineStyle(1.5, 0x8a6a12, 0.8);
g.strokePoints(pts, true);
}
// ── Interaction ───────────────────────────────────────────────────────────
onPieceClick(r, c) {
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'white') return;
const piece = this.gs.board[r][c];
if (!piece || piece.color !== 'white') return;
if (this.gs.mustContinueFrom) {
const [mr, mc] = this.gs.mustContinueFrom;
if (mr !== r || mc !== c) return;
}
const moves = getValidMoves(this.gs).filter((m) => m.from[0] === r && m.from[1] === c);
if (moves.length === 0) { this.flashNoMove(r, c); return; }
this.selectPiece(r, c, moves);
}
selectPiece(r, c, moves) {
this.clearOverlays();
this.selected = [r, c];
const { x, y } = this.sqToWorld(r, c);
const ring = this.add.graphics().setDepth(DEPTH.overlay);
ring.lineStyle(4, C.selGold, 1);
ring.strokeCircle(x, y, CR + 5);
this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.35 }, duration: 520, yoyo: true, repeat: -1 });
this.overlayObjs.push(ring);
this.showDestinations(moves);
}
showDestinations(moves) {
const seen = new Set();
for (const m of moves) {
const key = `${m.to[0]},${m.to[1]}`;
if (seen.has(key)) continue;
seen.add(key);
const { x, y } = this.sqToWorld(m.to[0], m.to[1]);
const dot = this.add.graphics().setDepth(DEPTH.overlay);
const cap = m.isJump;
dot.fillStyle(cap ? C.selGold : COLORS.accent, cap ? 0.9 : 0.8);
dot.fillCircle(x, y, cap ? 24 : 18);
dot.lineStyle(3, 0xffffff, 0.4);
dot.strokeCircle(x, y, cap ? 24 : 18);
this.tweens.add({ targets: dot, alpha: { from: 0.9, to: 0.25 }, duration: 600, yoyo: true, repeat: -1 });
const zone = this.add.zone(x, y, SQ, SQ)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.overlay);
zone.on('pointerdown', () => this.onDestinationClick(m.to[0], m.to[1]));
this.overlayObjs.push(dot, zone);
}
}
showMovableHints() {
const froms = new Set(getValidMoves(this.gs).map((m) => `${m.from[0]},${m.from[1]}`));
for (const key of froms) {
const [r, c] = key.split(',').map(Number);
const { x, y } = this.sqToWorld(r, c);
const ring = this.add.graphics().setDepth(DEPTH.overlay - 1);
ring.lineStyle(3, C.selGold, 0.5);
ring.strokeCircle(x, y, CR + 3);
this.tweens.add({ targets: ring, alpha: { from: 0.5, to: 0.12 }, duration: 900, yoyo: true, repeat: -1 });
this.overlayObjs.push(ring);
}
}
onDestinationClick(r, c) {
if (this.animating || !this.selected) return;
const [sr, sc] = this.selected;
const move = getValidMoves(this.gs).find(
(m) => m.from[0] === sr && m.from[1] === sc && m.to[0] === r && m.to[1] === c
);
if (!move) return;
this.clearOverlays();
this.executeMove(move, 'white');
}
flashNoMove(r, c) {
const obj = this.pieceObjs.find((o) => o.r === r && o.c === c);
if (!obj) return;
this.tweens.add({ targets: obj.container, alpha: { from: 1, to: 0.3 }, duration: 110, yoyo: true, repeat: 2 });
}
clearOverlays() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
this.selected = null;
}
// ── Move execution + animation ──────────────────────────────────────────────
executeMove(move, mover) {
this.animating = true;
this.updateTurnText();
const obj = this.pieceObjs.find((o) => o.r === move.from[0] && o.c === move.from[1]);
const fromPos = this.sqToWorld(move.from[0], move.from[1]);
const toPos = this.sqToWorld(move.to[0], move.to[1]);
const container = obj ? obj.container : this.makeDisc(
mover, this.gs.board[move.from[0]][move.from[1]].king, fromPos.x, fromPos.y);
container.setDepth(DEPTH.moving);
// Fade out captured discs alongside the hop.
for (const [cr, cc] of move.captures) this.animateCapture(cr, cc);
this.animateArc(container, fromPos, toPos, () => {
this.gs = applyMove(this.gs, move);
this.renderAll();
this.animating = false;
if (move.captures.length > 0) {
this.playOpponentEmotion(mover === 'white' ? 'upset' : 'happy');
}
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
if (this.gs.turn === 'white') {
if (this.gs.mustContinueFrom) {
// Human must keep jumping with the same piece.
const [cr, cc] = this.gs.mustContinueFrom;
this.selectPiece(cr, cc, getValidMoves(this.gs));
}
} else if (this.gs.mustContinueFrom) {
// AI mid multi-jump — continue the chain quickly.
this.time.delayedCall(360, () => this.aiStep());
} else {
// Fresh AI turn.
this.startAITurn();
}
});
}
startAITurn() {
const name = this.opponents[0]?.name ?? 'Opponent';
this.showTurnBanner(`${name}'s Turn`);
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiStep(false));
}
aiStep() {
if (this.gs.phase !== 'playing' || this.gs.turn !== 'black') return;
const skill = this.opponents[0]?.skill ?? 3;
const move = chooseMove(this.gs, 'black', skill);
if (!move) return;
this.executeMove(move, 'black');
}
animateCapture(r, c) {
const obj = this.pieceObjs.find((o) => o.r === r && o.c === c);
if (!obj) return;
obj.container.setDepth(DEPTH.moving - 1);
this.tweens.add({
targets: obj.container,
scaleX: 0, scaleY: 0, alpha: 0,
angle: 90,
duration: 240, ease: 'Back.easeIn',
});
}
animateArc(container, from, to, onComplete) {
if (!container) { onComplete(); return; }
const midX = (from.x + to.x) / 2;
const midY = Math.min(from.y, to.y) - 80;
const prog = { t: 0 };
this.tweens.add({
targets: prog, t: 1, duration: 330, ease: 'Cubic.easeInOut',
onUpdate: () => {
const t = prog.t, inv = 1 - t;
container.x = inv * inv * from.x + 2 * inv * t * midX + t * t * to.x;
container.y = inv * inv * from.y + 2 * inv * t * midY + t * t * to.y;
},
onComplete: () => {
container.x = to.x; container.y = to.y;
this.tweens.add({
targets: container, scaleX: 1.25, scaleY: 0.78, duration: 60, yoyo: true, ease: 'Quad.easeOut',
onComplete: () => { playSound(this, SFX.PIECE_CLICK); onComplete(); },
});
},
});
}
// ── Captured trays ──────────────────────────────────────────────────────────
renderTrays() {
for (const o of this.trayObjs) o.destroy();
this.trayObjs = [];
const counts = pieceCounts(this.gs);
const avatarX = BX / 2;
// Opponent (black) captured white discs.
this.drawTray(avatarX, this.oppTrayY, 12 - counts.white, 'white');
// Player (white) captured black discs.
this.drawTray(avatarX, this.plrTrayY, 12 - counts.black, 'black');
}
drawTray(cx, cy, n, color) {
const ring = color === 'white' ? C.wRing : C.bRing;
const fill = color === 'white' ? C.wFill : C.bFill;
const per = 6, mr = 13, gap = 5;
for (let i = 0; i < n; i++) {
const col = i % per, row = Math.floor(i / per);
const x = cx - (per - 1) * (mr + gap) / 2 + col * (mr + gap);
const y = cy + row * (mr + gap);
const g = this.add.graphics().setDepth(DEPTH.ui);
g.fillStyle(0x000000, 0.3); g.fillCircle(x + 1, y + 1, mr);
g.fillStyle(ring, 1); g.fillCircle(x, y, mr);
g.fillStyle(fill, 1); g.fillCircle(x, y, mr - 3);
this.trayObjs.push(g);
}
}
// ── Banners / overlays ──────────────────────────────────────────────────────
showTurnBanner(text) {
const cx = BX + BOARD / 2;
const banner = this.add.text(cx, BY - 70, text, {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
backgroundColor: '#111923ee', padding: { x: 28, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner, y: BY - 14, duration: 320, ease: 'Back.easeOut',
onComplete: () => {
this.time.delayedCall(1100, () => {
this.tweens.add({ targets: banner, y: BY - 70, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
});
},
});
}
onGameOver() {
const winner = getWinner(this.gs);
const isHuman = winner === 'white';
const name = this.opponents[0]?.name ?? 'Opponent';
this.playOpponentEmotion(isHuman ? 'upset' : 'happy');
const cx = BX + BOARD / 2, cy = BY + BOARD / 2;
if (isHuman) {
const emitter = this.add.particles(cx, cy, 'checkersParticle', {
speed: { min: 150, max: 500 }, lifespan: 1400,
scale: { start: 1.5, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 5, frequency: 25, angle: { min: 0, max: 360 },
tint: [C.selGold, 0xffffff, COLORS.accent],
}).setDepth(DEPTH.banner);
this.time.delayedCall(2000, () => emitter.destroy());
}
this.time.delayedCall(450, () => {
const msg = isHuman
? '🎉 You Win!\nEvery opposing piece captured or trapped.'
: `${name} wins this time.\nBetter luck next game!`;
const overlay = this.add.rectangle(cx, cy, 720, 300, 0x0a0e14, 0.9)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
const txt = this.add.text(cx, cy - 40, msg, {
fontFamily: '"Julius Sans One"', fontSize: '30px',
color: isHuman ? '#ffd700' : COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
new Button(this, cx - 90, cy + 80, 'Play Again', () => {
overlay.destroy(); txt.destroy(); this.initGame();
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, cx + 90, cy + 80, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
});
}
}

View File

@ -0,0 +1,166 @@
// Pure game logic for American (English) draughts — no Phaser dependency.
//
// Board: 8×8 array, board[r][c] = null | { color: 'white'|'black', king: bool }.
// Row 0 is the top (Black's home); row 7 is the bottom (White's home).
// Only dark squares (where (r + c) is odd) are playable.
// White (the human) moves up the board (decreasing row) and crowns at row 0.
// Black (the AI) moves down the board (increasing row) and crowns at row 7.
// Captures are mandatory; a multi-jump continues with the same piece until no
// further jump exists. Reaching the crown row ends the turn even mid-chain.
export const SIZE = 8;
const WHITE_DIRS = [[-1, -1], [-1, 1]];
const BLACK_DIRS = [[1, -1], [1, 1]];
const KING_DIRS = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
function inBounds(r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
export function isDark(r, c) { return (r + c) % 2 === 1; }
function other(color) { return color === 'white' ? 'black' : 'white'; }
export function cloneState(state) {
return {
board: state.board.map((row) => row.map((cell) => (cell ? { ...cell } : null))),
turn: state.turn,
mustContinueFrom: state.mustContinueFrom ? [...state.mustContinueFrom] : null,
winner: state.winner,
phase: state.phase,
};
}
export function createInitialState() {
const board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (!isDark(r, c)) continue;
if (r <= 2) board[r][c] = { color: 'black', king: false };
else if (r >= 5) board[r][c] = { color: 'white', king: false };
}
}
return { board, turn: 'white', mustContinueFrom: null, winner: null, phase: 'playing' };
}
function dirsFor(piece) {
if (piece.king) return KING_DIRS;
return piece.color === 'white' ? WHITE_DIRS : BLACK_DIRS;
}
// Slides and jumps available to the single piece at [r, c].
function stepMovesFor(state, r, c) {
const piece = state.board[r][c];
const slides = [];
const jumps = [];
if (!piece) return { slides, jumps };
for (const [dr, dc] of dirsFor(piece)) {
const r1 = r + dr, c1 = c + dc;
if (!inBounds(r1, c1)) continue;
const adj = state.board[r1][c1];
if (adj === null) {
slides.push({ from: [r, c], to: [r1, c1], captures: [], isJump: false });
} else if (adj.color !== piece.color) {
const r2 = r1 + dr, c2 = c1 + dc;
if (inBounds(r2, c2) && state.board[r2][c2] === null) {
jumps.push({ from: [r, c], to: [r2, c2], captures: [[r1, c1]], isJump: true });
}
}
}
return { slides, jumps };
}
// Legal moves for the side to move. Captures are forced: if any jump exists,
// only jumps are returned. When a multi-jump is in progress, only further
// jumps from the locked square are legal.
export function getValidMoves(state) {
if (state.phase === 'game_over') return [];
const player = state.turn;
if (state.mustContinueFrom) {
const [r, c] = state.mustContinueFrom;
return stepMovesFor(state, r, c).jumps;
}
const allSlides = [];
const allJumps = [];
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const piece = state.board[r][c];
if (!piece || piece.color !== player) continue;
const { slides, jumps } = stepMovesFor(state, r, c);
allSlides.push(...slides);
allJumps.push(...jumps);
}
}
return allJumps.length > 0 ? allJumps : allSlides;
}
function countPieces(board, color) {
let n = 0;
for (const row of board) for (const cell of row) if (cell && cell.color === color) n++;
return n;
}
// Decide game over for the position with `state.turn` to move: that side loses
// if it has no pieces or no legal move.
function resolveEnd(state) {
const s = state;
if (countPieces(s.board, 'white') === 0) { s.winner = 'black'; s.phase = 'game_over'; return s; }
if (countPieces(s.board, 'black') === 0) { s.winner = 'white'; s.phase = 'game_over'; return s; }
if (getValidMoves(s).length === 0) { s.winner = other(s.turn); s.phase = 'game_over'; }
return s;
}
export function applyMove(state, move) {
const s = cloneState(state);
const [fr, fc] = move.from;
const [tr, tc] = move.to;
const piece = s.board[fr][fc];
s.board[fr][fc] = null;
for (const [cr, cc] of move.captures) s.board[cr][cc] = null;
let promoted = false;
if (!piece.king) {
if (piece.color === 'white' && tr === 0) { piece.king = true; promoted = true; }
if (piece.color === 'black' && tr === SIZE - 1) { piece.king = true; promoted = true; }
}
s.board[tr][tc] = piece;
// Continue a multi-jump with the same piece unless it just crowned.
if (move.isJump && !promoted) {
const further = stepMovesFor(s, tr, tc).jumps;
if (further.length > 0) {
s.mustContinueFrom = [tr, tc];
return s; // same player keeps moving
}
}
s.mustContinueFrom = null;
s.turn = other(s.turn);
return resolveEnd(s);
}
export function isGameOver(state) { return state.phase === 'game_over'; }
export function getWinner(state) { return state.winner; }
export function pieceCounts(state) {
return {
white: countPieces(state.board, 'white'),
black: countPieces(state.board, 'black'),
};
}
// Enumerate complete turns for the side to move. Each entry is the first step
// of the turn paired with the resulting end-of-turn state (multi-jumps are
// expanded internally). Used by the AI search.
export function expandTurns(state) {
const out = [];
const moves = getValidMoves(state);
for (const m of moves) {
const ns = applyMove(state, m);
if (ns.turn === state.turn && ns.phase !== 'game_over') {
for (const sub of expandTurns(ns)) out.push({ firstStep: m, state: sub.state });
} else {
out.push({ firstStep: m, state: ns });
}
}
return out;
}

View File

@ -0,0 +1,173 @@
// Chess AI — alpha-beta minimax with piece-square tables and a Nerts-style
// 1..5 skill model.
// • depth — search depth in plies (capped for browser responsiveness)
// • blunder — chance to ignore the best move and play a random legal one
// • noise — random value added to root move scores (flattens decisions)
// • delay — "thinking" pause (ms range) before moving, for natural pacing
// Captures are searched first so alpha-beta prunes hard at the higher depths.
import {
getLegalMoves, applyMoveRaw, isKingAttacked, SIZE,
} from './ChessLogic.js';
const SKILL_PROFILES = {
1: { depth: 1, blunder: 0.45, noise: 90, delay: [900, 1500] },
2: { depth: 2, blunder: 0.30, noise: 55, delay: [800, 1300] },
3: { depth: 2, blunder: 0.15, noise: 30, delay: [700, 1100] },
4: { depth: 3, blunder: 0.05, noise: 12, delay: [550, 950] },
5: { depth: 4, blunder: 0.00, noise: 0, delay: [450, 850] },
};
const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 };
const MATE = 1000000;
const PST = {
p: [
[0, 0, 0, 0, 0, 0, 0, 0],
[50, 50, 50, 50, 50, 50, 50, 50],
[10, 10, 20, 30, 30, 20, 10, 10],
[5, 5, 10, 25, 25, 10, 5, 5],
[0, 0, 0, 20, 20, 0, 0, 0],
[5, -5, -10, 0, 0, -10, -5, 5],
[5, 10, 10, -20, -20, 10, 10, 5],
[0, 0, 0, 0, 0, 0, 0, 0],
],
n: [
[-50, -40, -30, -30, -30, -30, -40, -50],
[-40, -20, 0, 0, 0, 0, -20, -40],
[-30, 0, 10, 15, 15, 10, 0, -30],
[-30, 5, 15, 20, 20, 15, 5, -30],
[-30, 0, 15, 20, 20, 15, 0, -30],
[-30, 5, 10, 15, 15, 10, 5, -30],
[-40, -20, 0, 5, 5, 0, -20, -40],
[-50, -40, -30, -30, -30, -30, -40, -50],
],
b: [
[-20, -10, -10, -10, -10, -10, -10, -20],
[-10, 0, 0, 0, 0, 0, 0, -10],
[-10, 0, 5, 10, 10, 5, 0, -10],
[-10, 5, 5, 10, 10, 5, 5, -10],
[-10, 0, 10, 10, 10, 10, 0, -10],
[-10, 10, 10, 10, 10, 10, 10, -10],
[-10, 5, 0, 0, 0, 0, 5, -10],
[-20, -10, -10, -10, -10, -10, -10, -20],
],
r: [
[0, 0, 0, 0, 0, 0, 0, 0],
[5, 10, 10, 10, 10, 10, 10, 5],
[-5, 0, 0, 0, 0, 0, 0, -5],
[-5, 0, 0, 0, 0, 0, 0, -5],
[-5, 0, 0, 0, 0, 0, 0, -5],
[-5, 0, 0, 0, 0, 0, 0, -5],
[-5, 0, 0, 0, 0, 0, 0, -5],
[0, 0, 0, 5, 5, 0, 0, 0],
],
q: [
[-20, -10, -10, -5, -5, -10, -10, -20],
[-10, 0, 0, 0, 0, 0, 0, -10],
[-10, 0, 5, 5, 5, 5, 0, -10],
[-5, 0, 5, 5, 5, 5, 0, -5],
[0, 0, 5, 5, 5, 5, 0, -5],
[-10, 5, 5, 5, 5, 5, 0, -10],
[-10, 0, 5, 0, 0, 0, 0, -10],
[-20, -10, -10, -5, -5, -10, -10, -20],
],
k: [
[-30, -40, -40, -50, -50, -40, -40, -30],
[-30, -40, -40, -50, -50, -40, -40, -30],
[-30, -40, -40, -50, -50, -40, -40, -30],
[-30, -40, -40, -50, -50, -40, -40, -30],
[-20, -30, -30, -40, -40, -30, -30, -20],
[-10, -20, -20, -20, -20, -20, -20, -10],
[20, 20, 0, 0, 0, 0, 20, 20],
[20, 30, 10, 0, 0, 10, 30, 20],
],
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
// Static evaluation from `aiColor`'s perspective (positive = good for AI).
function evaluate(board, aiColor) {
let score = 0; // white's perspective
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const p = board[r][c];
if (!p) continue;
const table = PST[p.type];
const pst = p.color === 'white' ? table[r][c] : table[SIZE - 1 - r][c];
const v = VALUE[p.type] + pst;
score += p.color === 'white' ? v : -v;
}
}
return aiColor === 'white' ? score : -score;
}
// Captures first (most-valuable-victim heuristic) for better pruning.
function orderMoves(board, moves) {
return moves
.map((m) => {
let s = 0;
if (m.capture) s += 10 * VALUE[board[m.capture[0]][m.capture[1]].type] - VALUE[m.piece];
if (m.promotion) s += VALUE[m.promotion];
return { m, s };
})
.sort((a, b) => b.s - a.s)
.map((x) => x.m);
}
function search(state, depth, alpha, beta, aiColor) {
const moves = getLegalMoves(state);
if (moves.length === 0) {
if (isKingAttacked(state.board, state.turn)) {
// Side to move is checkmated. Prefer faster mates via the depth bonus.
return state.turn === aiColor ? -(MATE + depth) : (MATE + depth);
}
return 0; // stalemate
}
if (depth <= 0) return evaluate(state.board, aiColor);
const ordered = orderMoves(state.board, moves);
if (state.turn === aiColor) {
let value = -Infinity;
for (const m of ordered) {
value = Math.max(value, search(applyMoveRaw(state, m), depth - 1, alpha, beta, aiColor));
alpha = Math.max(alpha, value);
if (alpha >= beta) break;
}
return value;
}
let value = Infinity;
for (const m of ordered) {
value = Math.min(value, search(applyMoveRaw(state, m), depth - 1, alpha, beta, aiColor));
beta = Math.min(beta, value);
if (beta <= alpha) break;
}
return value;
}
// Return the chosen move object for `aiColor`, or null if none.
export function chooseMove(state, aiColor, skill) {
const prof = profileFor(skill);
const moves = getLegalMoves(state);
if (moves.length === 0) return null;
if (Math.random() < prof.blunder) {
return moves[Math.floor(Math.random() * moves.length)];
}
const ordered = orderMoves(state.board, moves);
let best = null;
let bestScore = -Infinity;
for (const m of ordered) {
const val = search(applyMoveRaw(state, m), prof.depth - 1, -Infinity, Infinity, aiColor)
+ (Math.random() * 2 - 1) * prof.noise;
if (val > bestScore) { bestScore = val; best = m; }
}
return best ?? moves[0];
}

View File

@ -0,0 +1,462 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState, getLegalMoves, applyMove, isGameOver, findKing, SIZE,
} from './ChessLogic.js';
import { chooseMove, nextThinkDelay } from './ChessAI.js';
import { makePiece } from './ChessPieces.js';
const SQ = 104;
const BOARD = SQ * SIZE;
const BX = Math.round(GAME_WIDTH / 2 - BOARD / 2);
const BY = Math.round(GAME_HEIGHT / 2 - BOARD / 2);
const FRAME = 30;
const PSZ = SQ * 0.80;
const DEPTH = { board: 0, square: 1, piece: 10, overlay: 20, moving: 30, ui: 50, banner: 60 };
const C = {
light: 0xebe6c8,
dark: 0x6f9c5a,
frame: 0x3a2414,
frameLt: 0x6b4423,
frameLn: 0x8b5c2a,
sel: 0xffd700,
move: 0xc8a84b,
check: 0xd23b3b,
};
export default class ChessGame extends Phaser.Scene {
constructor() { super('ChessGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.selected = null;
this.selMoves = [];
this.pieceObjs = [];
this.overlayObjs = [];
this.checkObjs = [];
this.promoObjs = [];
this.opponentPortrait = null;
this.turnText = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoard();
this.buildInput();
this.buildUI();
this.buildPlayerCards();
this.initGame();
}
// ── Construction ────────────────────────────────────────────────────────────
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('chessParticle', 10, 10);
g.destroy();
}
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.board - 2);
} else if (pf?.fallbackColor) {
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
.setDepth(DEPTH.board - 2);
}
}
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.board);
g.fillStyle(C.frame, 1);
g.fillRoundedRect(BX - FRAME, BY - FRAME, BOARD + FRAME * 2, BOARD + FRAME * 2, 12);
g.lineStyle(3, C.frameLt, 1);
g.strokeRoundedRect(BX - FRAME + 5, BY - FRAME + 5, BOARD + FRAME * 2 - 10, BOARD + FRAME * 2 - 10, 9);
g.lineStyle(1, C.frameLn, 0.6);
g.strokeRect(BX - 2, BY - 2, BOARD + 4, BOARD + 4);
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const dark = (r + c) % 2 === 1;
g.fillStyle(dark ? C.dark : C.light, 1);
g.fillRect(BX + c * SQ, BY + r * SQ, SQ, SQ);
}
}
for (let c = 0; c < SIZE; c++) {
this.add.text(BX + c * SQ + SQ / 2, BY + BOARD + 16, String.fromCharCode(97 + c), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
for (let r = 0; r < SIZE; r++) {
this.add.text(BX - 16, BY + r * SQ + SQ / 2, String(SIZE - r), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
}
buildInput() {
const zone = this.add.zone(BX + BOARD / 2, BY + BOARD / 2, BOARD, BOARD)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.square);
zone.on('pointerdown', (pointer) => {
const c = Math.floor((pointer.x - BX) / SQ);
const r = Math.floor((pointer.y - BY) / SQ);
if (r >= 0 && r < SIZE && c >= 0 && c < SIZE) this.handleClick(r, c);
});
}
buildUI() {
const cx = BX + BOARD / 2;
this.turnText = this.add.text(cx, BY + BOARD + 52, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
new Button(this, BX + BOARD + FRAME + 90, BY + 60, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 150, height: 46, fontSize: 20,
}).setDepth(DEPTH.ui);
new Button(this, BX + BOARD + FRAME + 90, BY + 124, 'New', () => this.initGame(), {
variant: 'ghost', width: 150, height: 46, fontSize: 20,
}).setDepth(DEPTH.ui);
}
buildPlayerCards() {
const opp = this.opponents[0];
const r = 78;
const depth = DEPTH.ui;
const avatarX = BX / 2;
const oppAY = BY + r + 20;
this.add.circle(avatarX, oppAY, r + 5, C.frame).setDepth(depth);
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(depth + 2);
const plrAY = BY + BOARD - r - 20;
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Chess');
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 1).setDepth(depth + 2);
}
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
// ── Game flow ─────────────────────────────────────────────────────────────
initGame() {
this.clearOverlays();
this.clearCheck();
this.clearPromo();
this.clearPieces();
this.animating = false;
this.selected = null;
this.gs = createInitialState();
this.renderAll();
this.showTurnBanner('Your Turn — White');
}
renderAll() {
this.clearPieces();
this.renderPieces();
this.clearOverlays();
this.renderCheck();
this.updateTurnText();
}
updateTurnText() {
if (!this.turnText) return;
const st = this.gs.status;
if (isGameOver(this.gs)) { this.turnText.setText(''); return; }
let t = this.gs.turn === 'white' ? 'Your move' : 'Opponent thinking…';
if (st === 'check') t += ' — Check!';
this.turnText.setText(t);
}
sqToWorld(r, c) {
return { x: BX + c * SQ + SQ / 2, y: BY + r * SQ + SQ / 2 };
}
clearPieces() {
for (const o of this.pieceObjs) o.container.destroy();
this.pieceObjs = [];
}
renderPieces() {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const p = this.gs.board[r][c];
if (!p) continue;
const { x, y } = this.sqToWorld(r, c);
const cont = makePiece(this, p.type, p.color, x, y, PSZ).setDepth(DEPTH.piece);
this.pieceObjs.push({ r, c, container: cont });
}
}
}
// ── Interaction ───────────────────────────────────────────────────────────
handleClick(r, c) {
if (this.animating || this.gs.turn !== 'white' || isGameOver(this.gs)) return;
if (this.selected) {
const dests = this.selMoves.filter((m) => m.to[0] === r && m.to[1] === c);
if (dests.length > 0) {
if (dests[0].promotion) this.openPromotion(dests);
else { this.clearOverlays(); this.executeMove(dests[0], 'white'); }
return;
}
}
const piece = this.gs.board[r][c];
if (piece && piece.color === 'white') this.selectSquare(r, c);
else this.clearOverlays();
}
selectSquare(r, c) {
this.clearOverlays();
this.selected = [r, c];
this.selMoves = getLegalMoves(this.gs, [r, c]);
const { x, y } = this.sqToWorld(r, c);
const sq = this.add.rectangle(x, y, SQ, SQ, C.sel, 0.28).setDepth(DEPTH.overlay - 1);
this.overlayObjs.push(sq);
for (const m of this.selMoves) this.showDestination(m);
}
showDestination(m) {
const { x, y } = this.sqToWorld(m.to[0], m.to[1]);
const g = this.add.graphics().setDepth(DEPTH.overlay);
if (m.capture) {
g.lineStyle(5, C.move, 0.9);
g.strokeCircle(x, y, SQ * 0.42);
} else {
g.fillStyle(C.move, 0.8);
g.fillCircle(x, y, 16);
}
this.tweens.add({ targets: g, alpha: { from: 0.9, to: 0.3 }, duration: 620, yoyo: true, repeat: -1 });
this.overlayObjs.push(g);
}
clearOverlays() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
this.selected = null;
this.selMoves = [];
}
// ── Promotion picker ────────────────────────────────────────────────────────
openPromotion(dests) {
this.clearOverlays();
this.animating = true;
const opts = ['q', 'r', 'b', 'n'];
const cell = 120;
const cx = BX + BOARD / 2;
const cy = BY + BOARD / 2;
const startX = cx - (opts.length - 1) * cell / 2;
const panel = this.add.rectangle(cx, cy, opts.length * cell + 30, cell + 30, 0x0a0e14, 0.95)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
const title = this.add.text(cx, cy - cell / 2 - 30, 'Promote to', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
this.promoObjs.push(panel, title);
opts.forEach((opt, i) => {
const x = startX + i * cell;
const tile = this.add.rectangle(x, cy, cell - 16, cell - 16, COLORS.panel)
.setStrokeStyle(2, COLORS.muted).setInteractive({ useHandCursor: true }).setDepth(DEPTH.banner + 1);
const piece = makePiece(this, opt, 'white', x, cy, PSZ).setDepth(DEPTH.banner + 2);
tile.on('pointerover', () => tile.setStrokeStyle(3, COLORS.accent));
tile.on('pointerout', () => tile.setStrokeStyle(2, COLORS.muted));
tile.on('pointerdown', () => {
const mv = dests.find((d) => d.promotion === opt);
this.clearPromo();
this.animating = false;
this.executeMove(mv, 'white');
});
this.promoObjs.push(tile, piece);
});
}
clearPromo() {
for (const o of this.promoObjs) o.destroy();
this.promoObjs = [];
}
// ── Check highlight ──────────────────────────────────────────────────────────
renderCheck() {
this.clearCheck();
const st = this.gs.status;
if (st !== 'check' && st !== 'checkmate') return;
const k = findKing(this.gs.board, this.gs.turn);
if (!k) return;
const { x, y } = this.sqToWorld(k[0], k[1]);
const g = this.add.rectangle(x, y, SQ, SQ, C.check, 0.5).setDepth(DEPTH.square + 1);
this.tweens.add({ targets: g, alpha: { from: 0.55, to: 0.2 }, duration: 600, yoyo: true, repeat: -1 });
this.checkObjs.push(g);
}
clearCheck() {
for (const o of this.checkObjs) o.destroy();
this.checkObjs = [];
}
// ── Move execution + animation ──────────────────────────────────────────────
executeMove(move, mover) {
this.animating = true;
this.updateTurnText();
const obj = this.pieceObjs.find((o) => o.r === move.from[0] && o.c === move.from[1]);
const fromPos = this.sqToWorld(move.from[0], move.from[1]);
const toPos = this.sqToWorld(move.to[0], move.to[1]);
const container = obj ? obj.container : makePiece(this, move.piece, mover, fromPos.x, fromPos.y, PSZ);
container.setDepth(DEPTH.moving);
if (move.capture) this.animateCapture(move.capture);
if (move.castle) this.animateCastlingRook(move);
this.animateArc(container, fromPos, toPos, () => {
this.gs = applyMove(this.gs, move);
this.renderAll();
this.animating = false;
if (move.capture) this.playOpponentEmotion(mover === 'white' ? 'upset' : 'happy');
const st = this.gs.status;
if (isGameOver(this.gs)) { this.onGameOver(); return; }
if (st === 'check') {
this.playOpponentEmotion(this.gs.turn === 'black' ? 'upset' : 'happy');
this.showTurnBanner('Check!');
}
if (this.gs.turn === 'black') this.startAITurn(st === 'check');
else if (st !== 'check') this.showTurnBanner('Your move');
});
}
animateCastlingRook(move) {
const row = move.from[0];
const fromC = move.castle === 'K' ? 7 : 0;
const toC = move.castle === 'K' ? 5 : 3;
const rookObj = this.pieceObjs.find((o) => o.r === row && o.c === fromC);
if (!rookObj) return;
rookObj.container.setDepth(DEPTH.moving - 1);
const to = this.sqToWorld(row, toC);
this.tweens.add({ targets: rookObj.container, x: to.x, y: to.y, duration: 300, ease: 'Quad.easeInOut' });
}
startAITurn(skipBanner) {
const name = this.opponents[0]?.name ?? 'Opponent';
if (!skipBanner) this.showTurnBanner(`${name}'s Turn`);
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiMove());
}
aiMove() {
if (this.gs.turn !== 'black' || isGameOver(this.gs)) return;
const skill = this.opponents[0]?.skill ?? 3;
const move = chooseMove(this.gs, 'black', skill);
if (!move) return;
this.executeMove(move, 'black');
}
animateCapture(sq) {
const obj = this.pieceObjs.find((o) => o.r === sq[0] && o.c === sq[1]);
if (!obj) return;
obj.container.setDepth(DEPTH.moving - 1);
this.tweens.add({
targets: obj.container, scaleX: 0, scaleY: 0, alpha: 0, angle: 60,
duration: 230, ease: 'Back.easeIn',
});
}
animateArc(container, from, to, onComplete) {
if (!container) { onComplete(); return; }
const midX = (from.x + to.x) / 2;
const midY = Math.min(from.y, to.y) - 60;
const prog = { t: 0 };
this.tweens.add({
targets: prog, t: 1, duration: 320, ease: 'Cubic.easeInOut',
onUpdate: () => {
const t = prog.t, inv = 1 - t;
container.x = inv * inv * from.x + 2 * inv * t * midX + t * t * to.x;
container.y = inv * inv * from.y + 2 * inv * t * midY + t * t * to.y;
},
onComplete: () => {
container.x = to.x; container.y = to.y;
this.tweens.add({
targets: container, scaleX: 1.12, scaleY: 0.9, duration: 60, yoyo: true, ease: 'Quad.easeOut',
onComplete: () => { playSound(this, SFX.PIECE_CLICK); onComplete(); },
});
},
});
}
// ── Banners / overlays ──────────────────────────────────────────────────────
showTurnBanner(text) {
const cx = BX + BOARD / 2;
const banner = this.add.text(cx, BY - 70, text, {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
backgroundColor: '#111923ee', padding: { x: 28, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner, y: BY - 14, duration: 320, ease: 'Back.easeOut',
onComplete: () => {
this.time.delayedCall(1100, () => {
this.tweens.add({ targets: banner, y: BY - 70, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
});
},
});
}
onGameOver() {
const st = this.gs.status;
const winner = this.gs.winner;
const isHuman = winner === 'white';
const isDraw = winner === 'draw';
const name = this.opponents[0]?.name ?? 'Opponent';
this.playOpponentEmotion(isHuman ? 'upset' : isDraw ? 'happy' : 'happy');
const cx = BX + BOARD / 2, cy = BY + BOARD / 2;
if (isHuman) {
const emitter = this.add.particles(cx, cy, 'chessParticle', {
speed: { min: 150, max: 500 }, lifespan: 1400,
scale: { start: 1.5, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 5, frequency: 25, angle: { min: 0, max: 360 },
tint: [C.sel, 0xffffff, COLORS.accent],
}).setDepth(DEPTH.banner);
this.time.delayedCall(2000, () => emitter.destroy());
}
this.time.delayedCall(450, () => {
let msg;
if (st === 'stalemate') msg = 'Stalemate.\nThe game is a draw.';
else if (st === 'draw') msg = 'Draw.\nNeither side can force a win.';
else if (isHuman) msg = '🎉 Checkmate — You Win!';
else msg = `Checkmate.\n${name} wins this game.`;
const overlay = this.add.rectangle(cx, cy, 720, 300, 0x0a0e14, 0.9)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
const txt = this.add.text(cx, cy - 40, msg, {
fontFamily: '"Julius Sans One"', fontSize: '32px',
color: isHuman ? '#ffd700' : COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
new Button(this, cx - 90, cy + 80, 'Play Again', () => {
overlay.destroy(); txt.destroy(); this.initGame();
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, cx + 90, cy + 80, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
});
}
}

View File

@ -0,0 +1,325 @@
// Pure chess logic — no Phaser dependency.
//
// Board: 8×8 array, board[r][c] = null | { type, color }.
// type ∈ 'p','n','b','r','q','k'; color ∈ 'white','black'.
// Row 0 is the top (Black's back rank); row 7 is the bottom (White's back rank).
// White (the human) moves up the board (decreasing row); Black (AI) moves down.
//
// Move shape:
// { from:[r,c], to:[r,c], piece, capture:[r,c]|null, promotion:'q'|'r'|'b'|'n'|null,
// castle:'K'|'Q'|null, isEnPassant:bool, isDouble:bool }
export const SIZE = 8;
const KNIGHT = [[-2, -1], [-2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2], [2, -1], [2, 1]];
const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const ALL8 = [...DIAG, ...ORTH];
function inB(r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
export function other(color) { return color === 'white' ? 'black' : 'white'; }
export function cloneState(state) {
return {
board: state.board.map((row) => row.map((cell) => (cell ? { ...cell } : null))),
turn: state.turn,
castling: { ...state.castling },
enPassant: state.enPassant ? [...state.enPassant] : null,
halfmove: state.halfmove,
winner: state.winner,
status: state.status,
};
}
export function createInitialState() {
const back = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'];
const board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
for (let c = 0; c < SIZE; c++) {
board[0][c] = { type: back[c], color: 'black' };
board[1][c] = { type: 'p', color: 'black' };
board[6][c] = { type: 'p', color: 'white' };
board[7][c] = { type: back[c], color: 'white' };
}
return {
board, turn: 'white',
castling: { wK: true, wQ: true, bK: true, bQ: true },
enPassant: null, halfmove: 0, winner: null, status: 'playing',
};
}
function homeRow(color) { return color === 'white' ? 7 : 0; }
// ── Attack detection ─────────────────────────────────────────────────────────
export function isSquareAttacked(board, tr, tc, byColor) {
// Pawns
const pd = byColor === 'white' ? 1 : -1; // attacker sits one row toward its own side
for (const dc of [-1, 1]) {
const pr = tr + pd, pc = tc + dc;
if (inB(pr, pc)) {
const p = board[pr][pc];
if (p && p.color === byColor && p.type === 'p') return true;
}
}
// Knights
for (const [dr, dc] of KNIGHT) {
const r = tr + dr, c = tc + dc;
if (inB(r, c)) {
const p = board[r][c];
if (p && p.color === byColor && p.type === 'n') return true;
}
}
// King
for (const [dr, dc] of ALL8) {
const r = tr + dr, c = tc + dc;
if (inB(r, c)) {
const p = board[r][c];
if (p && p.color === byColor && p.type === 'k') return true;
}
}
// Diagonal sliders (bishop / queen)
for (const [dr, dc] of DIAG) {
let r = tr + dr, c = tc + dc;
while (inB(r, c)) {
const p = board[r][c];
if (p) {
if (p.color === byColor && (p.type === 'b' || p.type === 'q')) return true;
break;
}
r += dr; c += dc;
}
}
// Orthogonal sliders (rook / queen)
for (const [dr, dc] of ORTH) {
let r = tr + dr, c = tc + dc;
while (inB(r, c)) {
const p = board[r][c];
if (p) {
if (p.color === byColor && (p.type === 'r' || p.type === 'q')) return true;
break;
}
r += dr; c += dc;
}
}
return false;
}
export function findKing(board, color) {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const p = board[r][c];
if (p && p.type === 'k' && p.color === color) return [r, c];
}
}
return null;
}
export function isKingAttacked(board, color) {
const k = findKing(board, color);
return k ? isSquareAttacked(board, k[0], k[1], other(color)) : false;
}
// ── Pseudo-legal move generation ─────────────────────────────────────────────
function pushPawn(moves, from, to, promoRow, extra = {}) {
if (to[0] === promoRow) {
for (const pr of ['q', 'r', 'b', 'n']) {
moves.push({ from, to, piece: 'p', capture: extra.capture ?? null, promotion: pr,
castle: null, isEnPassant: !!extra.isEnPassant, isDouble: false });
}
} else {
moves.push({ from, to, piece: 'p', capture: extra.capture ?? null, promotion: null,
castle: null, isEnPassant: !!extra.isEnPassant, isDouble: !!extra.isDouble });
}
}
function generatePseudoMoves(state) {
const { board, turn: color, enPassant, castling } = state;
const moves = [];
const dir = color === 'white' ? -1 : 1;
const startRow = color === 'white' ? 6 : 1;
const promoRow = color === 'white' ? 0 : 7;
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const p = board[r][c];
if (!p || p.color !== color) continue;
const from = [r, c];
if (p.type === 'p') {
const one = r + dir;
if (inB(one, c) && board[one][c] === null) {
pushPawn(moves, from, [one, c], promoRow);
const two = r + 2 * dir;
if (r === startRow && board[two][c] === null) {
moves.push({ from, to: [two, c], piece: 'p', capture: null, promotion: null,
castle: null, isEnPassant: false, isDouble: true });
}
}
for (const dc of [-1, 1]) {
const nr = r + dir, nc = c + dc;
if (!inB(nr, nc)) continue;
const t = board[nr][nc];
if (t && t.color !== color) {
pushPawn(moves, from, [nr, nc], promoRow, { capture: [nr, nc] });
} else if (enPassant && enPassant[0] === nr && enPassant[1] === nc) {
pushPawn(moves, from, [nr, nc], promoRow, { capture: [r, nc], isEnPassant: true });
}
}
} else if (p.type === 'n') {
for (const [dr, dc] of KNIGHT) addStep(moves, board, from, r + dr, c + dc, color);
} else if (p.type === 'k') {
for (const [dr, dc] of ALL8) addStep(moves, board, from, r + dr, c + dc, color);
addCastling(moves, state, color);
} else {
const dirs = p.type === 'b' ? DIAG : p.type === 'r' ? ORTH : ALL8;
for (const [dr, dc] of dirs) {
let nr = r + dr, nc = c + dc;
while (inB(nr, nc)) {
const t = board[nr][nc];
if (t === null) {
moves.push({ from, to: [nr, nc], piece: p.type, capture: null, promotion: null, castle: null, isEnPassant: false, isDouble: false });
} else {
if (t.color !== color) moves.push({ from, to: [nr, nc], piece: p.type, capture: [nr, nc], promotion: null, castle: null, isEnPassant: false, isDouble: false });
break;
}
nr += dr; nc += dc;
}
}
}
}
}
return moves;
}
function addStep(moves, board, from, nr, nc, color) {
if (!inB(nr, nc)) return;
const t = board[nr][nc];
if (t === null) {
moves.push({ from, to: [nr, nc], piece: board[from[0]][from[1]].type, capture: null, promotion: null, castle: null, isEnPassant: false, isDouble: false });
} else if (t.color !== color) {
moves.push({ from, to: [nr, nc], piece: board[from[0]][from[1]].type, capture: [nr, nc], promotion: null, castle: null, isEnPassant: false, isDouble: false });
}
}
function addCastling(moves, state, color) {
const { board, castling } = state;
const row = homeRow(color);
const enemy = other(color);
// King must be home and not currently in check.
if (isSquareAttacked(board, row, 4, enemy)) return;
const kingHome = board[row][4];
if (!kingHome || kingHome.type !== 'k' || kingHome.color !== color) return;
const kRight = color === 'white' ? castling.wK : castling.bK;
const qRight = color === 'white' ? castling.wQ : castling.bQ;
if (kRight && board[row][5] === null && board[row][6] === null
&& board[row][7] && board[row][7].type === 'r' && board[row][7].color === color
&& !isSquareAttacked(board, row, 5, enemy) && !isSquareAttacked(board, row, 6, enemy)) {
moves.push({ from: [row, 4], to: [row, 6], piece: 'k', capture: null, promotion: null, castle: 'K', isEnPassant: false, isDouble: false });
}
if (qRight && board[row][1] === null && board[row][2] === null && board[row][3] === null
&& board[row][0] && board[row][0].type === 'r' && board[row][0].color === color
&& !isSquareAttacked(board, row, 3, enemy) && !isSquareAttacked(board, row, 2, enemy)) {
moves.push({ from: [row, 4], to: [row, 2], piece: 'k', capture: null, promotion: null, castle: 'Q', isEnPassant: false, isDouble: false });
}
}
// ── Applying moves ───────────────────────────────────────────────────────────
function clearRight(castling, sq) {
if (sq[0] === 7 && sq[1] === 0) castling.wQ = false;
else if (sq[0] === 7 && sq[1] === 7) castling.wK = false;
else if (sq[0] === 0 && sq[1] === 0) castling.bQ = false;
else if (sq[0] === 0 && sq[1] === 7) castling.bK = false;
}
// Apply a move without computing resulting status (used by search + applyMove).
export function applyMoveRaw(state, move) {
const s = cloneState(state);
const b = s.board;
const color = s.turn;
const piece = b[move.from[0]][move.from[1]];
s.enPassant = null;
if (move.capture) b[move.capture[0]][move.capture[1]] = null;
b[move.from[0]][move.from[1]] = null;
const placed = { type: move.promotion ?? piece.type, color };
b[move.to[0]][move.to[1]] = placed;
if (move.castle === 'K') {
const row = move.from[0];
b[row][5] = b[row][7]; b[row][7] = null;
} else if (move.castle === 'Q') {
const row = move.from[0];
b[row][3] = b[row][0]; b[row][0] = null;
}
if (move.isDouble) {
s.enPassant = [(move.from[0] + move.to[0]) / 2, move.to[1]];
}
// Castling rights
if (piece.type === 'k') {
if (color === 'white') { s.castling.wK = false; s.castling.wQ = false; }
else { s.castling.bK = false; s.castling.bQ = false; }
} else if (piece.type === 'r') {
clearRight(s.castling, move.from);
}
if (move.capture) clearRight(s.castling, move.capture);
s.halfmove = (piece.type === 'p' || move.capture) ? 0 : s.halfmove + 1;
s.turn = other(color);
return s;
}
// Legal moves for the side to move (optionally only from a given square).
export function getLegalMoves(state, from = null) {
const pseudo = generatePseudoMoves(state);
const color = state.turn;
const legal = [];
for (const m of pseudo) {
const ns = applyMoveRaw(state, m);
if (!isKingAttacked(ns.board, color)) legal.push(m);
}
if (from) return legal.filter((m) => m.from[0] === from[0] && m.from[1] === from[1]);
return legal;
}
function insufficientMaterial(board) {
const minors = [];
for (const row of board) {
for (const p of row) {
if (!p || p.type === 'k') continue;
if (p.type === 'p' || p.type === 'r' || p.type === 'q') return false;
minors.push(p.type);
}
}
return minors.length <= 1; // K vs K, or K+minor vs K
}
export function computeStatus(state) {
const inCheck = isKingAttacked(state.board, state.turn);
const hasMoves = getLegalMoves(state).length > 0;
if (!hasMoves) {
if (inCheck) return { status: 'checkmate', winner: other(state.turn) };
return { status: 'stalemate', winner: 'draw' };
}
if (insufficientMaterial(state.board)) return { status: 'draw', winner: 'draw' };
if (state.halfmove >= 100) return { status: 'draw', winner: 'draw' };
return { status: inCheck ? 'check' : 'playing', winner: null };
}
// Apply a move and compute the resulting game status (used by the scene).
export function applyMove(state, move) {
const ns = applyMoveRaw(state, move);
const { status, winner } = computeStatus(ns);
ns.status = status;
ns.winner = winner;
return ns;
}
export function isGameOver(state) {
return state.status === 'checkmate' || state.status === 'stalemate' || state.status === 'draw';
}

View File

@ -0,0 +1,127 @@
// Custom vector chess pieces drawn with Phaser graphics — no art assets.
// Each piece is a stylized silhouette (fill + dark outline + soft highlight)
// over a drop shadow, tinted ivory for white and charcoal for black.
//
// makePiece(scene, type, color, x, y, size) → Phaser.Container
const WHITE = { fill: 0xf2ead2, hi: 0xfffdf3, ol: 0x8a7c52 };
const BLACK = { fill: 0x2b2833, hi: 0x55506a, ol: 0x05040a };
function poly(g, pts, pal) {
g.fillStyle(pal.fill, 1);
g.fillPoints(pts, true);
g.lineStyle(2, pal.ol, 1);
g.strokePoints(pts, true);
}
function P(s, x, y) { return { x: x * s, y: y * s }; }
function pedestal(g, s, pal) {
g.fillStyle(pal.fill, 1);
g.fillRoundedRect(-0.26 * s, 0.34 * s, 0.52 * s, 0.12 * s, 4);
g.lineStyle(2, pal.ol, 1);
g.strokeRoundedRect(-0.26 * s, 0.34 * s, 0.52 * s, 0.12 * s, 4);
g.fillStyle(pal.fill, 1);
g.fillRoundedRect(-0.20 * s, 0.28 * s, 0.40 * s, 0.07 * s, 3);
g.lineStyle(2, pal.ol, 1);
g.strokeRoundedRect(-0.20 * s, 0.28 * s, 0.40 * s, 0.07 * s, 3);
}
function body(g, s, pal, topW, botW) {
poly(g, [P(s, -topW, 0.02), P(s, topW, 0.02), P(s, botW, 0.30), P(s, -botW, 0.30)], pal);
}
function sheen(g, s, pal) {
g.fillStyle(pal.hi, 0.3);
g.fillEllipse(-0.10 * s, 0.02 * s, 0.10 * s, 0.34 * s);
}
const DRAW = {
p(g, s, pal) {
pedestal(g, s, pal);
body(g, s, pal, 0.10, 0.16);
g.fillStyle(pal.fill, 1); g.fillCircle(0, -0.12 * s, 0.14 * s);
g.lineStyle(2, pal.ol, 1); g.strokeCircle(0, -0.12 * s, 0.14 * s);
g.fillStyle(pal.hi, 0.4); g.fillCircle(-0.04 * s, -0.16 * s, 0.05 * s);
},
r(g, s, pal) {
pedestal(g, s, pal);
body(g, s, pal, 0.13, 0.18);
poly(g, [
P(s, -0.20, -0.02), P(s, -0.20, -0.22), P(s, -0.13, -0.22), P(s, -0.13, -0.13),
P(s, -0.05, -0.13), P(s, -0.05, -0.22), P(s, 0.05, -0.22), P(s, 0.05, -0.13),
P(s, 0.13, -0.13), P(s, 0.13, -0.22), P(s, 0.20, -0.22), P(s, 0.20, -0.02),
], pal);
sheen(g, s, pal);
},
n(g, s, pal) {
pedestal(g, s, pal);
poly(g, [
P(s, -0.18, 0.30), P(s, -0.21, 0.08), P(s, -0.10, -0.08), P(s, -0.17, -0.20),
P(s, -0.06, -0.34), P(s, 0.06, -0.40), P(s, 0.11, -0.30), P(s, 0.21, -0.18),
P(s, 0.23, -0.05), P(s, 0.10, -0.03), P(s, 0.05, 0.05), P(s, 0.12, 0.16),
P(s, 0.18, 0.30),
], pal);
g.fillStyle(pal.ol, 1); g.fillCircle(0.04 * s, -0.22 * s, 0.025 * s); // eye
g.lineStyle(2, pal.ol, 0.7);
g.lineBetween(-0.04 * s, -0.30 * s, -0.10 * s, -0.16 * s); // mane
},
b(g, s, pal) {
pedestal(g, s, pal);
body(g, s, pal, 0.09, 0.16);
g.fillStyle(pal.fill, 1); g.fillEllipse(0, -0.14 * s, 0.24 * s, 0.34 * s);
g.lineStyle(2, pal.ol, 1); g.strokeEllipse(0, -0.14 * s, 0.24 * s, 0.34 * s);
g.lineStyle(2, pal.ol, 1); g.lineBetween(0.03 * s, -0.30 * s, -0.05 * s, -0.16 * s); // slit
g.fillStyle(pal.fill, 1); g.fillCircle(0, -0.34 * s, 0.05 * s);
g.lineStyle(2, pal.ol, 1); g.strokeCircle(0, -0.34 * s, 0.05 * s);
sheen(g, s, pal);
},
q(g, s, pal) {
pedestal(g, s, pal);
body(g, s, pal, 0.11, 0.18);
// crown band
g.fillStyle(pal.fill, 1); g.fillRoundedRect(-0.19 * s, -0.18 * s, 0.38 * s, 0.10 * s, 4);
g.lineStyle(2, pal.ol, 1); g.strokeRoundedRect(-0.19 * s, -0.18 * s, 0.38 * s, 0.10 * s, 4);
// five spikes with balls
const xs = [-0.17, -0.085, 0, 0.085, 0.17];
for (const x of xs) {
poly(g, [P(s, x - 0.045, -0.18), P(s, x + 0.045, -0.18), P(s, x, -0.36)], pal);
g.fillStyle(pal.fill, 1); g.fillCircle(x * s, -0.37 * s, 0.045 * s);
g.lineStyle(2, pal.ol, 1); g.strokeCircle(x * s, -0.37 * s, 0.045 * s);
}
sheen(g, s, pal);
},
k(g, s, pal) {
pedestal(g, s, pal);
body(g, s, pal, 0.11, 0.18);
// crown band
g.fillStyle(pal.fill, 1); g.fillRoundedRect(-0.18 * s, -0.20 * s, 0.36 * s, 0.10 * s, 4);
g.lineStyle(2, pal.ol, 1); g.strokeRoundedRect(-0.18 * s, -0.20 * s, 0.36 * s, 0.10 * s, 4);
// shoulder bumps
poly(g, [P(s, -0.18, -0.10), P(s, -0.10, -0.20), P(s, -0.02, -0.10)], pal);
poly(g, [P(s, 0.02, -0.10), P(s, 0.10, -0.20), P(s, 0.18, -0.10)], pal);
// cross
g.fillStyle(pal.fill, 1);
g.fillRoundedRect(-0.035 * s, -0.46 * s, 0.07 * s, 0.26 * s, 3);
g.fillRoundedRect(-0.11 * s, -0.40 * s, 0.22 * s, 0.06 * s, 3);
g.lineStyle(2, pal.ol, 1);
g.strokeRoundedRect(-0.035 * s, -0.46 * s, 0.07 * s, 0.26 * s, 3);
g.strokeRoundedRect(-0.11 * s, -0.40 * s, 0.22 * s, 0.06 * s, 3);
sheen(g, s, pal);
},
};
export function makePiece(scene, type, color, x, y, size) {
const pal = color === 'white' ? WHITE : BLACK;
const shadow = scene.add.graphics();
shadow.fillStyle(0x000000, 0.28);
shadow.fillEllipse(3, 0.42 * size + 3, 0.56 * size, 0.15 * size);
const g = scene.add.graphics();
(DRAW[type] ?? DRAW.p)(g, size, pal);
return scene.add.container(x, y, [shadow, g]);
}

View File

@ -79,12 +79,19 @@ export default class DominionGame extends Phaser.Scene {
this._handOrder = null;
this.oppHandSprites = {};
this.oppInPlaySprites = {};
this.phaseDials = [];
this.turnArrow = null;
this._arrowSeat = null;
this._boughtThisTurn = false;
this._suppressTurnUi = false;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildBackground();
this.buildPortraits();
this.buildTurnArrow();
this.buildPhaseDials();
this.buildHoverPopup();
this.buildButtons();
this.animLayer = this.add.container(0, 0).setDepth(D.hand + 50);
@ -103,6 +110,8 @@ export default class DominionGame extends Phaser.Scene {
this.events.once('shutdown', () => {
this.portraits.forEach((pt) => pt?.destroy?.());
this.turnArrow?.destroy();
this.phaseDials.forEach((d) => d?.destroy?.());
});
const initialState = createInitialState({
@ -180,6 +189,218 @@ export default class DominionGame extends Phaser.Scene {
return this.opponents[seat - 1]?.skill ?? 3;
}
// ── Turn arrow + phase dials (persistent overlay) ─────────────────────────────
// Yellow turn arrow, mirroring the Settlers of Catan indicator.
buildTurnArrow() {
const g = this.add.graphics().setDepth(D.portrait + 5);
g.fillStyle(0xffdd00, 1);
g.fillTriangle(-12, -15, -12, 15, 12, 0);
g.setPosition(-9999, -9999);
this.turnArrow = g;
this._arrowSeat = null;
this.tweens.add({
targets: g, scaleX: 1.4, scaleY: 1.4, duration: 700,
yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
}
// Portrait anchor the arrow points at, by seat.
arrowSeatPos(seat) {
if (seat === 0) return { x: 92, y: 928, r: 56 };
const s = this.oppSlot(seat - 1);
return { x: s.x, y: s.y, r: s.r };
}
buildPhaseDials() {
this.phaseDials[0] = this.makePhaseDial(168, 748, 60, 60);
this.opponents.forEach((opp, i) => {
const s = this.oppSlot(i);
const r = 26;
this.phaseDials[i + 1] = this.makePhaseDial(s.x + s.r + 14 + r, s.y, r);
});
}
// A 3-wedge ring (Action/Buy/Clean Up) that spins so the active wedge locks
// under a fixed top pointer. Returns a controller with setActive/setPhase.
makePhaseDial(x, y, outerR, labelDY = 0) {
const scene = this;
const PHASE = { action: 0, buy: 1, cleanup: 2 };
const colors = [0xd0563b, 0xd4a017, 0x4a90d9];
const TWO_PI_3 = (Math.PI * 2) / 3;
const HALF = Math.PI / 3;
const TOP = -Math.PI / 2;
const container = this.add.container(x, y).setDepth(D.portrait);
const ring = this.add.container(0, 0);
container.add(ring);
const a0 = [], a1 = [], mid = [];
for (let i = 0; i < 3; i++) {
const c = TOP + i * TWO_PI_3;
a0[i] = c - HALF; a1[i] = c + HALF; mid[i] = c;
}
const wedges = [];
for (let i = 0; i < 3; i++) {
const w = this.add.graphics();
ring.add(w);
wedges.push(w);
}
const paintWedges = (activeIdx) => {
for (let i = 0; i < 3; i++) {
const w = wedges[i];
const on = i === activeIdx;
w.clear();
w.fillStyle(colors[i], on ? 0.95 : 0.26);
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.fillPath();
w.lineStyle(on ? Math.max(2, outerR * 0.05) : 1.5, on ? colors[i] : COLORS.accent, on ? 1 : 0.55);
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.strokePath();
}
};
paintWedges(-1);
const ic = outerR * 0.22;
const midR = outerR * 0.66;
const drawSword = (g) => {
g.fillStyle(0xf2ead8, 1);
g.fillTriangle(-0.16 * ic, -0.5 * ic, 0.16 * ic, -0.5 * ic, 0, -1.15 * ic);
g.fillRect(-0.14 * ic, -0.5 * ic, 0.28 * ic, 0.85 * ic);
g.fillStyle(0xc8a84b, 1);
g.fillRect(-0.5 * ic, 0.3 * ic, ic, 0.16 * ic);
g.fillRect(-0.12 * ic, 0.46 * ic, 0.24 * ic, 0.5 * ic);
};
const drawCoin = (g) => {
g.fillStyle(0xd4a017, 1); g.fillCircle(0, 0, 0.95 * ic);
g.lineStyle(Math.max(1, 0.14 * ic), 0x6e5410, 1); g.strokeCircle(0, 0, 0.95 * ic);
g.lineStyle(Math.max(1, 0.14 * ic), 0xfff3c4, 0.9); g.strokeCircle(0, 0, 0.5 * ic);
};
const drawRefresh = (g) => {
g.lineStyle(Math.max(1.5, 0.2 * ic), 0xf2ead8, 1);
g.beginPath(); g.arc(0, 0, 0.85 * ic, Phaser.Math.DegToRad(-50), Phaser.Math.DegToRad(200), false); g.strokePath();
const end = Phaser.Math.DegToRad(200);
const ex = Math.cos(end) * 0.85 * ic, ey = Math.sin(end) * 0.85 * ic;
g.fillStyle(0xf2ead8, 1);
g.fillTriangle(ex - 0.45 * ic, ey - 0.1 * ic, ex + 0.1 * ic, ey - 0.5 * ic, ex + 0.15 * ic, ey + 0.35 * ic);
};
const drawers = [drawSword, drawCoin, drawRefresh];
const icons = [];
for (let i = 0; i < 3; i++) {
const g = this.add.graphics();
drawers[i](g);
g.setPosition(Math.cos(mid[i]) * midR, Math.sin(mid[i]) * midR);
ring.add(g);
icons.push(g);
}
const hub = this.add.graphics();
hub.fillStyle(0x141008, 0.88); hub.fillCircle(0, 0, outerR * 0.42);
hub.lineStyle(Math.max(1.5, outerR * 0.03), COLORS.accent, 0.7); hub.strokeCircle(0, 0, outerR * 0.42);
container.add(hub);
const pointer = this.add.graphics();
const pw = outerR * 0.16, ph = outerR * 0.3, ty = -outerR;
pointer.fillStyle(COLORS.gold, 1);
pointer.fillTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
pointer.lineStyle(1.5, COLORS.textDark, 0.6);
pointer.strokeTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
container.add(pointer);
const names = ['Action Phase', 'Buy Phase', 'Clean-up Phase'];
const nameHex = ['#d0563b', '#d4a017', '#4a90d9'];
const labelSize = Math.max(12, Math.round(outerR * 0.36));
const label = this.add.text(0, -(outerR + labelSize) - labelDY, '', {
fontFamily: 'Righteous', fontSize: `${labelSize}px`, color: nameHex[0],
}).setOrigin(0.5).setVisible(false);
label.setShadow(0, 2, '#000000', 4, false, true);
container.add(label);
const applyLabel = (idx) => { label.setText(names[idx]); label.setColor(nameHex[idx]); };
container.setAlpha(0.38);
return {
container,
_active: false,
_phaseIdx: 0,
_pulse: null,
_rot: null,
setActive(on) {
if (this._active === on) return;
this._active = on;
scene.tweens.add({ targets: container, alpha: on ? 1 : 0.38, duration: 300, ease: 'Sine.Out' });
if (on) {
paintWedges(this._phaseIdx);
applyLabel(this._phaseIdx);
label.setVisible(true);
this._pulse?.remove();
this._pulse = scene.tweens.add({
targets: container, scaleX: 1.06, scaleY: 1.06,
duration: 900, yoyo: true, repeat: -1, ease: 'Sine.InOut',
});
} else {
this._pulse?.remove(); this._pulse = null;
this._rot?.remove(); this._rot = null;
scene.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 200 });
ring.rotation = 0;
icons.forEach((g) => { g.rotation = 0; });
this._phaseIdx = 0;
paintWedges(-1);
label.setVisible(false);
}
},
setPhase(name) {
const idx = PHASE[name] ?? 0;
if (idx === this._phaseIdx) return;
this._phaseIdx = idx;
paintWedges(idx);
applyLabel(idx);
this._rot?.remove();
this._rot = scene.tweens.add({
targets: ring, rotation: -idx * TWO_PI_3, duration: 500, ease: 'Cubic.Out',
onUpdate: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
onComplete: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
});
},
destroy() {
this._pulse?.remove(); this._rot?.remove();
container.destroy(true);
},
};
}
// Reflects whose turn it is (arrow) and the active phase (dials) into the overlay.
updateTurnUi() {
if (this._suppressTurnUi) {
this.phaseDials[0]?.setActive(true);
this.phaseDials[0]?.setPhase('cleanup');
return;
}
const gs = this.gs;
if (!gs || !this.turnArrow) return;
if (this.gameOver) {
this.turnArrow.setVisible(false);
} else {
this.turnArrow.setVisible(true);
const seat = gs.turn;
const pos = this.arrowSeatPos(seat);
if (pos && this._arrowSeat !== seat) {
this._arrowSeat = seat;
const tx = pos.x - pos.r - 18, ty = pos.y;
if (this.turnArrow.x < 0) this.turnArrow.setPosition(tx, ty);
else this.tweens.add({ targets: this.turnArrow, x: tx, y: ty, duration: 600, ease: 'Cubic.Out' });
}
}
for (let seat = 0; seat < this.playerCount; seat++) {
const dial = this.phaseDials[seat];
if (!dial) continue;
const active = !this.gameOver && gs.turn === seat;
dial.setActive(active);
if (active) dial.setPhase(gs.phase === 'buy' ? 'buy' : 'action');
}
}
// ── Render (dynamic layer) ───────────────────────────────────────────────────
render() {
@ -199,6 +420,7 @@ export default class DominionGame extends Phaser.Scene {
this.renderCounts();
this.renderHud();
this.updateControls();
this.updateTurnUi();
}
renderSupply() {
@ -686,6 +908,10 @@ export default class DominionGame extends Phaser.Scene {
const gainEvent = newLog.find(e => e.kind === 'gain' && e.seat === 0 && e.dest !== 'hand');
if (allDiscarded.length > 0) {
if (newLog.some(e => e.kind === 'turnEnd' && e.seat === 0)) {
this.phaseDials[0]?.setPhase('cleanup');
this._suppressTurnUi = true;
}
const draws = (drawnCards.length > 0 && deckChanged) ? drawnCards : [];
this._animDiscardThenDraw(allDiscarded, draws, s);
return;
@ -840,17 +1066,35 @@ export default class DominionGame extends Phaser.Scene {
humanBuy(id) {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
this._boughtThisTurn = true;
playSound(this, SFX.CARD_SHOW);
this.setState(buyCard(this.gs, id));
}
humanEndAction() {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
const hand = this.gs.players[0].hand;
const hasActionCard = hand.some((c) => isType(c.id, 'action'));
const hasActionsLeft = this.gs.players[0].actions > 0;
if (hasActionCard && hasActionsLeft) {
this.showConfirm('You have unplayed action cards. End Action Phase anyway?', () => {
this._boughtThisTurn = false;
this.setState(endActionPhase(this.gs));
});
return;
}
this._boughtThisTurn = false;
this.setState(endActionPhase(this.gs));
}
humanEndTurn() {
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
if (!this._boughtThisTurn) {
this.showConfirm('You have not purchased anything this turn. End turn anyway?', () => {
this.setState(endTurn(this.gs));
});
return;
}
this.setState(endTurn(this.gs));
}
@ -994,6 +1238,25 @@ export default class DominionGame extends Phaser.Scene {
this.promptButton(CX + 120, 'No', () => cb(false), { variant: 'ghost' });
}
// "Are you sure?" modal — blocks interaction with underlying buttons.
showConfirm(message, onConfirm) {
this.clearPrompt();
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
.setDepth(D.modal).setInteractive();
this.promptObjs.push(overlay);
this.promptObjs.push(this.add.text(CX, GAME_HEIGHT / 2 - 40, message, {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
wordWrap: { width: 900 },
}).setOrigin(0.5).setDepth(D.modal + 1));
this.promptObjs.push(new Button(this, CX - 130, GAME_HEIGHT / 2 + 50, 'Confirm', () => {
this.clearPrompt();
onConfirm();
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, width: 200, height: 46, fontSize: 20 }).setDepth(D.modal + 1));
this.promptObjs.push(new Button(this, CX + 130, GAME_HEIGHT / 2 + 50, 'Cancel', () => {
this.clearPrompt();
}, { variant: 'ghost', width: 200, height: 46, fontSize: 20 }).setDepth(D.modal + 1));
}
// 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)
@ -1090,6 +1353,8 @@ export default class DominionGame extends Phaser.Scene {
this.clearPrompt();
this.hideHover();
this.updateControls();
this.turnArrow?.setVisible(false);
this.phaseDials.forEach((d) => d?.setActive(false));
const scores = finalScores(this.gs);
const winners = new Set(this.gs.winnerSeats);
@ -1261,6 +1526,7 @@ export default class DominionGame extends Phaser.Scene {
_animOppCleanup(seat, cleanupCards, newCards, newState) {
this._animating = true;
const slot = this.oppSlot(seat - 1);
this.phaseDials[seat]?.setPhase('cleanup');
// Capture sources before any render wipes the sprite arrays
const sources = cleanupCards.map(card => {
@ -1306,12 +1572,23 @@ export default class DominionGame extends Phaser.Scene {
this.clearPrompt();
this.render();
const slot = this.oppSlot(seat - 1);
const cardName = getCard(cardId).name;
const label = this.add.text(slot.x - slot.r - 74, slot.y, `Purchased:\n${cardName}`, {
fontFamily: 'Righteous', fontSize: '32px', color: '#FFD700',
align: 'right', stroke: '#000000', strokeThickness: 4,
}).setOrigin(1, 0.5).setDepth(D.hud + 5).setAlpha(1);
const src = { iid: -1, id: cardId, x: srcX, y: srcY };
this._animDiscardCard(src, slot.x, slot.y, () => {
this.tweens.add({
targets: label, alpha: 0, duration: 600, delay: 400, ease: 'Sine.easeIn',
onComplete: () => label.destroy(),
});
this._animating = false;
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
else this.scheduleAdvance(10);
});
}, { flyDuration: 1000, foldDuration: 500 });
}
_chainHumanGain(gainEvt, newState) {
@ -1368,6 +1645,8 @@ export default class DominionGame extends Phaser.Scene {
if (drawnCards.length > 0) {
this._animDrawCards(drawnCards, newState);
} else {
this._suppressTurnUi = false;
this.updateTurnUi();
this._animating = false;
if (this._pendingAnimState) {
const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s);
@ -1385,27 +1664,24 @@ export default class DominionGame extends Phaser.Scene {
animateNext();
}
_animDiscardCard(src, tx, ty, onComplete) {
_animDiscardCard(src, tx, ty, onComplete, { flyDuration = 500, foldDuration = 250 } = {}) {
const def = getCard(src.id);
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
fuCard.setPosition(src.x, src.y);
this.animLayer.add(fuCard);
// Phase 1 (0500ms): fly face-up to target
this.tweens.add({
targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeIn',
targets: fuCard, x: tx, y: ty, duration: flyDuration, ease: 'Cubic.easeIn',
onComplete: () => {
// Phase 2 (500750ms): fold to edge
this.tweens.add({
targets: fuCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
targets: fuCard, scaleX: 0, duration: foldDuration, ease: 'Sine.easeIn',
onComplete: () => {
fuCard.destroy();
// Phase 3 (7501000ms): unfold face-down
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
fdCard.setPosition(tx, ty).setScale(0, 1);
this.animLayer.add(fdCard);
this.tweens.add({
targets: fdCard, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
targets: fdCard, scaleX: 1, duration: foldDuration, ease: 'Sine.easeOut',
onComplete: () => { fdCard.destroy(); onComplete(); },
});
},
@ -1614,6 +1890,8 @@ export default class DominionGame extends Phaser.Scene {
this.handSprites.forEach(s => s.face.setAlpha(1));
this._animatingIids.clear();
this._animating = false;
this._suppressTurnUi = false;
this.updateTurnUi();
if (this._pendingAnimState) {
const s = this._pendingAnimState;

View File

@ -29,6 +29,8 @@ 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';
import CheckersGame from './games/checkers/CheckersGame.js';
import ChessGame from './games/chess/ChessGame.js';
const config = {
type: Phaser.AUTO,
@ -71,6 +73,8 @@ const config = {
BingoGame,
BaccaratGame,
DominionGame,
CheckersGame,
ChessGame,
],
};

View File

@ -17,7 +17,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame' };
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', checkers: 'CheckersGame', chess: 'ChessGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -355,9 +355,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
info.appendChild(name);
info.appendChild(bio);
// Skill control (Nerts only): pips always show the level; the +/- buttons
// appear only when this opponent is selected.
if (this.gameDef.slug === 'nerts') {
// Skill control: pips always show the level; the +/- buttons appear only
// when this opponent is selected. Enabled for games with a 15 AI skill.
if (['nerts', 'checkers', 'chess'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');

View File

@ -42,3 +42,5 @@ registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true,
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 });
registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });