feat: add Chinese Checkers game with 6-player AI and Phaser rendering

- Implement complete game logic (ChineseCheckersLogic.js) with hex board,
  move generation (steps/jump chains), and rule enforcement.
- Add single-ply heuristic AI (ChineseCheckersAI.js) that prioritizes pulling
  laggard pegs forward and entering the target triangle.
- Build Phaser-based UI (ChineseCheckersGame.js) with radial portraits,
  animated peg moves, and turn indicators.
- Register the game in the server registry and frontend routing.
This commit is contained in:
Brian Fertig 2026-05-17 20:22:19 -06:00
parent d9bf0432a6
commit 356a2e98c5
6 changed files with 947 additions and 1 deletions

View File

@ -0,0 +1,83 @@
import {
COLOR_TARGET, TRIANGLES, PEGS_PER_PLAYER,
getAllValidMoves, hexDistance, triangleAt,
} from './ChineseCheckersLogic.js';
// Use the TIP of the target triangle as the goal point. Filling the deep
// cells first prevents the classic Chinese Checkers endgame where the last
// peg can't reach a target cell because the base of the triangle is full.
const TARGET_TIP = {};
for (const [tri, cells] of Object.entries(TRIANGLES)) {
// Triangle cell arrays are listed base-first; the last entry is the tip.
const tip = cells[cells.length - 1];
TARGET_TIP[tri] = { q: tip[0], r: tip[1] };
}
// Returns the single best move for the current player, or null if no moves.
// Single-ply: try every legal move, score the resulting position, pick the
// best. Heuristic favors pulling the laggard peg forward and rewards landing
// inside the target triangle.
export function chooseMove(state, color) {
const moves = getAllValidMoves(state, color);
if (moves.length === 0) return null;
const baseScore = scorePosition(state, color);
let best = null;
let bestDelta = -Infinity;
for (const m of moves) {
const projected = projectMove(state, color, m);
const delta = baseScore - projected.score;
const tiebreak = m.path.length * 0.01; // prefer longer jump chains
const v = delta + tiebreak;
if (v > bestDelta) { bestDelta = v; best = m; }
}
return best;
}
// Cheap score (lower = better). We do not need to apply the move into a
// full new state — just swap one peg's position and recompute the score
// contribution from that peg and the laggard metric.
function projectMove(state, color, move) {
const peg = state.pegs[color][move.pegIdx];
const target = COLOR_TARGET[color];
const tip = TARGET_TIP[target];
const pegBefore = pegScore(peg.q, peg.r, tip, target);
const pegAfter = pegScore(move.q, move.r, tip, target);
let worstBefore = 0, worstAfter = 0;
for (let i = 0; i < PEGS_PER_PLAYER; i++) {
const p = state.pegs[color][i];
const dPre = hexDistance(p.q, p.r, tip.q, tip.r);
if (dPre > worstBefore) worstBefore = dPre;
const d = (i === move.pegIdx)
? hexDistance(move.q, move.r, tip.q, tip.r)
: dPre;
if (d > worstAfter) worstAfter = d;
}
const baseScore = scorePosition(state, color);
const score = baseScore - pegBefore + pegAfter + 0.7 * (worstAfter - worstBefore);
return { score };
}
function pegScore(q, r, tip, targetTri) {
// Distance to the target tip — pulls pegs into the DEEPEST cell first,
// so the base of the triangle stays open for later pegs.
let s = hexDistance(q, r, tip.q, tip.r);
// Big fixed bonus for landing inside the target triangle so the AI
// strongly prefers entering target over loitering nearby.
if (triangleAt(q, r) === targetTri) s -= 8;
return s;
}
function scorePosition(state, color) {
const target = COLOR_TARGET[color];
const tip = TARGET_TIP[target];
let total = 0;
for (const p of state.pegs[color]) {
total += pegScore(p.q, p.r, tip, target);
}
return total;
}

View File

@ -0,0 +1,518 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS as UI } 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 {
COLORS as CCOLORS, COLOR_HOME, TRIANGLES,
createInitialState, applyMove, getMovesForPeg,
hasAnyMove, allCells, currentColor, passTurn,
} from './ChineseCheckersLogic.js';
import { chooseMove } from './ChineseCheckersAI.js';
// ── Layout ──────────────────────────────────────────────────────────────────
const CELL = 56;
const PEG_R = 21;
const HOLE_R = 25;
const CX = GAME_WIDTH / 2;
const CY = GAME_HEIGHT / 2 + 10;
const SQRT3_OVER_2 = Math.sqrt(3) / 2;
const HUMAN_COLOR = 'blue';
// Turn rotation, clockwise around the star starting from human at south.
// AI seats follow in the same order, so opponents[0] → green, opponents[1] →
// yellow, etc.
const SEAT_ORDER = ['blue', 'green', 'yellow', 'red', 'purple', 'orange'];
const AI_COLORS = SEAT_ORDER.filter((c) => c !== HUMAN_COLOR);
const COLOR_HEX = {
red: { fill: 0xd62828, ring: 0xff8585, dark: 0x7a1010 },
yellow: { fill: 0xf6c000, ring: 0xffe066, dark: 0x8a6d00 },
green: { fill: 0x2f9e44, ring: 0x69db7c, dark: 0x155724 },
blue: { fill: 0x1971c2, ring: 0x4dabf7, dark: 0x0a3a6b },
orange: { fill: 0xf76707, ring: 0xffa94d, dark: 0x8c3e00 },
purple: { fill: 0x7950f2, ring: 0xb197fc, dark: 0x432599 },
};
const TRIANGLE_TINT = {
red: 0x3a0a0a,
yellow: 0x36290a,
green: 0x0e2c19,
blue: 0x0a2440,
orange: 0x3a1a05,
purple: 0x231248,
};
const DEPTH = {
felt: -1, tri: 0, hole: 1, label: 2,
peg: 10, highlight: 20, moving: 30,
ui: 50, banner: 60,
};
function hexToWorld(q, r) {
return {
x: CX + CELL * (q + r / 2),
y: CY + CELL * r * SQRT3_OVER_2,
};
}
// Six portrait anchors arranged radially around the star.
const PORTRAIT_POS = {
red: { x: CX, y: 80 },
yellow: { x: 1530, y: 320 },
green: { x: 1530, y: 760 },
blue: { x: CX, y: 1010 },
orange: { x: 390, y: 760 },
purple: { x: 390, y: 320 },
};
export default class ChineseCheckersGame extends Phaser.Scene {
constructor() { super('ChineseCheckersGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.selectedPegIdx = null;
this.pegObjs = {};
this.highlightObjs = [];
this.opponentPortraits = {};
this.opponentByColor = {};
this.turnIndicator = null;
this.turnIndicatorGfx = null;
this.statusText = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.assignOpponents();
this.buildPlayfield();
this.buildBoard();
this.buildUI();
this.buildPlayerCards();
this.buildPegs();
this.initGame();
}
assignOpponents() {
AI_COLORS.forEach((color, i) => {
this.opponentByColor[color] = this.opponents[i] ?? null;
});
}
// ── Background ────────────────────────────────────────────────────────────
buildPlayfield() {
const pf = this.playfield;
if (!pf) return;
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.felt);
} 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.felt);
}
}
// ── Board ────────────────────────────────────────────────────────────────
buildBoard() {
// Outer subtle backdrop circle behind the star.
const ring = this.add.graphics().setDepth(DEPTH.tri);
ring.fillStyle(0x0a0a0a, 0.65);
ring.fillCircle(CX, CY, 500);
// 6 colored triangles.
for (const color of CCOLORS) {
const tri = COLOR_HOME[color];
const pts = this.trianglePolygon(tri);
const g = this.add.graphics().setDepth(DEPTH.tri);
g.fillStyle(TRIANGLE_TINT[color], 0.9);
g.lineStyle(2.5, COLOR_HEX[color].fill, 0.95);
g.beginPath();
pts.forEach((p, i) => {
if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y);
});
g.closePath();
g.fillPath();
g.strokePath();
}
// Central hexagon backdrop.
const hex = this.add.graphics().setDepth(DEPTH.tri);
hex.fillStyle(0x1c1714, 0.85);
hex.beginPath();
for (let i = 0; i < 6; i++) {
const ang = Math.PI / 6 + i * Math.PI / 3;
const x = CX + (4 + 0.4) * CELL * SQRT3_OVER_2 * Math.cos(ang) * 1.1;
const y = CY + (4 + 0.4) * CELL * SQRT3_OVER_2 * Math.sin(ang) * 1.1;
if (i === 0) hex.moveTo(x, y); else hex.lineTo(x, y);
}
hex.closePath();
hex.fillPath();
// All 121 holes.
for (const { q, r } of allCells()) {
const w = hexToWorld(q, r);
const g = this.add.graphics().setDepth(DEPTH.hole);
g.fillStyle(0x000000, 0.6);
g.fillCircle(w.x, w.y, HOLE_R);
g.fillStyle(0x222428, 1);
g.fillCircle(w.x, w.y, HOLE_R - 5);
g.lineStyle(1.5, 0x404249, 1);
g.strokeCircle(w.x, w.y, HOLE_R - 5);
}
}
// Triangle outline using its base-row endpoints and tip cell.
trianglePolygon(tri) {
const cells = TRIANGLES[tri];
const base0 = cells[0]; // base-left
const base3 = cells[3]; // base-right
const tip = cells[9]; // tip
const w0 = hexToWorld(base0[0], base0[1]);
const w3 = hexToWorld(base3[0], base3[1]);
const wt = hexToWorld(tip[0], tip[1]);
const cx = (w0.x + w3.x + wt.x) / 3;
const cy = (w0.y + w3.y + wt.y) / 3;
const pad = HOLE_R + 6;
const out = (p) => {
const dx = p.x - cx, dy = p.y - cy;
const len = Math.hypot(dx, dy) || 1;
return { x: p.x + (dx / len) * pad, y: p.y + (dy / len) * pad };
};
return [out(w0), out(w3), out(wt)];
}
// ── UI / portraits ───────────────────────────────────────────────────────
buildUI() {
new Button(this, 80, GAME_HEIGHT - 70, 'New', () => this.initGame(), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(DEPTH.ui);
new Button(this, 80, GAME_HEIGHT - 25, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(DEPTH.ui);
this.statusText = this.add.text(CX, GAME_HEIGHT - 60, '', {
fontFamily: '"Julius Sans One"',
fontSize: '22px',
color: UI.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildPlayerCards() {
const portraitR = 50;
for (const color of CCOLORS) {
const { x, y } = PORTRAIT_POS[color];
const ring = this.add.circle(x, y, portraitR + 5, COLOR_HEX[color].fill, 0.75)
.setDepth(DEPTH.ui);
ring.setStrokeStyle(2, COLOR_HEX[color].dark);
if (color === HUMAN_COLOR) {
createPlayerPortrait(this, x, y, portraitR, DEPTH.ui + 1, 'ChineseCheckersGame');
const name = auth.user?.username ?? 'You';
this.add.text(x, y + portraitR + 14, name, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: UI.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 2);
} else {
const opp = this.opponentByColor[color];
if (opp) {
this.opponentPortraits[color] = createOpponentPortrait(this, opp, x, y, portraitR, DEPTH.ui + 1);
} else {
this.add.circle(x, y, portraitR, COLOR_HEX[color].dark).setDepth(DEPTH.ui + 1);
}
const name = opp?.name ?? this.colorTitle(color);
this.add.text(x, y + portraitR + 14, name, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: UI.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 2);
}
}
this.turnIndicatorGfx = this.add.graphics();
this.turnIndicator = this.add.container(0, 0, [this.turnIndicatorGfx])
.setDepth(DEPTH.ui + 3).setAlpha(0);
}
colorTitle(color) {
return color.charAt(0).toUpperCase() + color.slice(1);
}
colorLabel(color) {
if (color === HUMAN_COLOR) return 'You';
return this.opponentByColor[color]?.name ?? this.colorTitle(color);
}
drawTurnRing(color) {
if (!color) return;
const { x, y } = PORTRAIT_POS[color];
this.tweens.killTweensOf(this.turnIndicator);
const g = this.turnIndicatorGfx;
g.clear();
g.lineStyle(4, 0xffd700, 1);
g.strokeCircle(0, 0, 62);
this.turnIndicator.setPosition(x, y).setAlpha(1).setScale(1);
this.tweens.add({
targets: this.turnIndicator,
scale: { from: 1, to: 1.18 },
alpha: { from: 1, to: 0.5 },
duration: 600, yoyo: true, repeat: -1,
});
}
// ── Pegs ─────────────────────────────────────────────────────────────────
buildPegs() {
for (const color of CCOLORS) {
this.pegObjs[color] = [];
const home = TRIANGLES[COLOR_HOME[color]];
for (let i = 0; i < home.length; i++) {
const [q, r] = home[i];
const w = hexToWorld(q, r);
const c = this.makePeg(color, w.x, w.y);
c.setDepth(DEPTH.peg);
c.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Circle(0, 0, PEG_R + 6),
hitAreaCallback: Phaser.Geom.Circle.Contains,
});
c.on('pointerdown', () => this.onPegClick(color, i));
this.pegObjs[color].push(c);
}
}
}
makePeg(color, x, y) {
const c = COLOR_HEX[color];
const g = this.add.graphics();
g.fillStyle(0x000000, 0.35);
g.fillCircle(2, 3, PEG_R);
g.fillStyle(c.dark, 1);
g.fillCircle(0, 0, PEG_R);
g.fillStyle(c.fill, 1);
g.fillCircle(0, 0, PEG_R - 4);
g.lineStyle(2, c.ring, 0.9);
g.strokeCircle(0, 0, PEG_R - 2);
return this.add.container(x, y, [g]);
}
refreshAllPegs() {
for (const color of CCOLORS) {
for (let i = 0; i < this.pegObjs[color].length; i++) {
const peg = this.gs.pegs[color][i];
const w = hexToWorld(peg.q, peg.r);
this.pegObjs[color][i].setPosition(w.x, w.y);
}
}
}
// ── Game flow ────────────────────────────────────────────────────────────
initGame() {
this.clearHighlights();
this.animating = false;
this.selectedPegIdx = null;
this.gs = createInitialState(SEAT_ORDER);
this.refreshAllPegs();
const color = currentColor(this.gs);
this.drawTurnRing(color);
if (color === HUMAN_COLOR) {
this.setStatus('Your turn — click a peg');
} else {
this.setStatus(`${this.colorLabel(color)}'s turn`);
this.time.delayedCall(700, () => this.runAITurn());
}
}
// ── Human input ──────────────────────────────────────────────────────────
onPegClick(color, pegIdx) {
if (this.animating) return;
if (this.gs.phase !== 'play') return;
if (color !== HUMAN_COLOR) return;
if (currentColor(this.gs) !== HUMAN_COLOR) return;
const moves = getMovesForPeg(this.gs, color, pegIdx);
if (moves.length === 0) {
this.flashPeg(color, pegIdx);
return;
}
this.clearHighlights();
this.selectedPegIdx = pegIdx;
this.pulsePeg(color, pegIdx);
this.showDestinations(moves);
}
showDestinations(moves) {
for (const m of moves) {
const w = hexToWorld(m.q, m.r);
const fill = m.isJump ? 0xffd166 : 0x88ff88;
const dot = this.add.graphics().setDepth(DEPTH.highlight);
dot.fillStyle(fill, 0.7);
dot.fillCircle(w.x, w.y, 14);
dot.lineStyle(3, 0xffffff, 0.65);
dot.strokeCircle(w.x, w.y, 14);
this.tweens.add({
targets: dot, alpha: { from: 0.9, to: 0.3 },
duration: 620, yoyo: true, repeat: -1,
});
const zone = this.add.zone(w.x, w.y, HOLE_R * 2, HOLE_R * 2)
.setInteractive({ useHandCursor: true })
.setDepth(DEPTH.highlight);
zone.on('pointerdown', () => this.onDestinationClick(m));
this.highlightObjs.push(dot, zone);
}
}
onDestinationClick(move) {
if (this.animating) return;
this.clearHighlights();
this.executeMove(move, () => {
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
this.afterTurn();
});
}
// ── AI ───────────────────────────────────────────────────────────────────
runAITurn() {
if (this.gs.phase !== 'play') return;
const color = currentColor(this.gs);
if (color === HUMAN_COLOR) return;
if (!hasAnyMove(this.gs, color)) {
this.gs = passTurn(this.gs);
this.afterTurn();
return;
}
const move = chooseMove(this.gs, color);
if (!move) {
this.gs = passTurn(this.gs);
this.afterTurn();
return;
}
this.executeMove(move, () => {
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
this.afterTurn();
});
}
afterTurn() {
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
const color = currentColor(this.gs);
this.drawTurnRing(color);
if (color === HUMAN_COLOR) {
if (!hasAnyMove(this.gs, HUMAN_COLOR)) {
this.setStatus('No legal moves — passing');
this.time.delayedCall(900, () => {
this.gs = passTurn(this.gs);
this.afterTurn();
});
} else {
this.setStatus('Your turn — click a peg');
}
} else {
this.setStatus(`${this.colorLabel(color)}'s turn`);
this.time.delayedCall(450, () => this.runAITurn());
}
}
// ── Move execution / animation ───────────────────────────────────────────
executeMove(move, onComplete) {
this.animating = true;
const peg = this.pegObjs[move.color][move.pegIdx];
peg.setDepth(DEPTH.moving);
playSound(this, SFX.PIECE_CLICK);
const path = move.path;
let i = 1;
const stepOnce = () => {
if (i >= path.length) {
peg.setDepth(DEPTH.peg);
this.gs = applyMove(this.gs, move);
this.refreshAllPegs();
this.animating = false;
onComplete?.();
return;
}
const target = hexToWorld(path[i].q, path[i].r);
const from = { x: peg.x, y: peg.y };
const midX = (from.x + target.x) / 2;
const arcLift = move.isJump ? 60 : 14;
const midY = Math.min(from.y, target.y) - arcLift;
const prog = { t: 0 };
this.tweens.add({
targets: prog, t: 1,
duration: move.isJump ? 280 : 260,
ease: 'Cubic.easeInOut',
onUpdate: () => {
const t = prog.t;
const inv = 1 - t;
peg.x = inv * inv * from.x + 2 * inv * t * midX + t * t * target.x;
peg.y = inv * inv * from.y + 2 * inv * t * midY + t * t * target.y;
},
onComplete: () => {
if (move.isJump && i < path.length - 1) playSound(this, SFX.PIECE_CLICK);
i += 1;
stepOnce();
},
});
};
stepOnce();
}
// ── Highlight helpers ────────────────────────────────────────────────────
pulsePeg(color, pegIdx) {
const obj = this.pegObjs[color][pegIdx];
const ring = this.add.graphics().setDepth(DEPTH.highlight);
ring.lineStyle(3, 0xffd700, 1);
ring.strokeCircle(obj.x, obj.y, PEG_R + 8);
this.tweens.add({
targets: ring, alpha: { from: 1, to: 0.3 },
duration: 500, yoyo: true, repeat: -1,
});
this.highlightObjs.push(ring);
}
flashPeg(color, pegIdx) {
const obj = this.pegObjs[color][pegIdx];
this.tweens.add({
targets: obj, alpha: { from: 1, to: 0.2 },
duration: 100, yoyo: true, repeat: 2,
});
}
clearHighlights() {
for (const o of this.highlightObjs) o.destroy();
this.highlightObjs = [];
this.selectedPegIdx = null;
}
setStatus(msg) { this.statusText?.setText(msg); }
// ── Game over ────────────────────────────────────────────────────────────
onGameOver() {
const winner = this.gs.winner;
const isHuman = winner === HUMAN_COLOR;
const overlay = this.add.rectangle(CX, CY, 760, 340, 0x0a0e14, 0.94)
.setStrokeStyle(3, UI.accent).setDepth(DEPTH.banner);
const winnerLabel = isHuman ? 'You' : this.colorLabel(winner);
const msg = isHuman
? 'You Win!\nAll ten pegs are home!'
: `${winnerLabel} wins this round.\nBetter luck next game!`;
const txt = this.add.text(CX, CY - 50, msg, {
fontFamily: '"Julius Sans One"',
fontSize: '34px',
color: isHuman ? '#ffd700' : UI.textHex,
align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
const playAgain = new Button(this, CX - 110, CY + 90, 'Play Again', () => {
overlay.destroy(); txt.destroy();
playAgain.destroy(); leaveBtn.destroy();
this.initGame();
}, { width: 190, fontSize: 22 });
playAgain.setDepth(DEPTH.banner + 1);
const leaveBtn = new Button(this, CX + 110, CY + 90, 'Leave', () => {
this.scene.start('GameMenu');
}, { variant: 'ghost', width: 190, fontSize: 22 });
leaveBtn.setDepth(DEPTH.banner + 1);
}
}

View File

@ -0,0 +1,342 @@
// Pure Chinese Checkers rules. No Phaser dependency.
//
// Board model:
// - 121 holes on a 6-pointed star, addressed in axial hex coords (q, r).
// - Inner hexagon of radius 4 (61 cells) + 6 triangular points of side 4
// (10 cells each), one per hex direction.
// - 6 colors, one per triangle. Each player has 10 pegs starting in their
// home triangle and must fill the OPPOSITE triangle (their target).
//
// A cell (q, r) is on the board iff at least 2 of {|q|, |r|, |s|}
// (where s = -q-r) are ≤ 4. Equivalently the inner hex plus 6 points where
// one axis is in (4, 8].
//
// Move types per turn (mutually exclusive):
// 1. Step — one hex to an adjacent empty cell.
// 2. Jump chain — over one or more adjacent pegs (own or opponent) to the
// empty cell directly on the other side, repeatable from each landing.
//
// Enforced rules:
// - A peg may never come to REST in another player's home or target triangle
// (jumping THROUGH is allowed).
// - Once a peg has left its own home triangle, it may not re-enter it.
// - A peg in its own target triangle may only move within the target.
export const COLORS = ['red', 'yellow', 'green', 'blue', 'orange', 'purple'];
// Triangle ids correspond to which axis-extreme defines the point.
// Pointy-top hex coords with +q east, +r south-east:
// r- = top/north, r+ = south
// q+ = NE, q- = SW
// s- = SE, s+ = NW
export const TRIANGLES = {
'r-': [[1,-5],[2,-5],[3,-5],[4,-5],[2,-6],[3,-6],[4,-6],[3,-7],[4,-7],[4,-8]],
'r+': [[-1,5],[-2,5],[-3,5],[-4,5],[-2,6],[-3,6],[-4,6],[-3,7],[-4,7],[-4,8]],
'q+': [[5,-1],[5,-2],[5,-3],[5,-4],[6,-2],[6,-3],[6,-4],[7,-3],[7,-4],[8,-4]],
'q-': [[-5,1],[-5,2],[-5,3],[-5,4],[-6,2],[-6,3],[-6,4],[-7,3],[-7,4],[-8,4]],
's+': [[-1,-4],[-2,-3],[-3,-2],[-4,-1],[-2,-4],[-3,-3],[-4,-2],[-3,-4],[-4,-3],[-4,-4]],
's-': [[1,4],[2,3],[3,2],[4,1],[2,4],[3,3],[4,2],[3,4],[4,3],[4,4]],
};
export const COLOR_HOME = {
red: 'r-',
yellow: 'q+',
green: 's-',
blue: 'r+',
orange: 'q-',
purple: 's+',
};
export const COLOR_TARGET = {
red: 'r+',
yellow: 'q-',
green: 's+',
blue: 'r-',
orange: 'q+',
purple: 's-',
};
export const PEGS_PER_PLAYER = 10;
export const cellKey = (q, r) => `${q},${r}`;
// ── Static board topology ───────────────────────────────────────────────────
const BOARD_CELLS = new Set();
const TRIANGLE_OF = new Map(); // key -> 'center' | triangle id
(function buildBoard() {
// Inner hexagon (radius 4): max(|q|,|r|,|s|) ≤ 4.
for (let q = -4; q <= 4; q++) {
const rMin = Math.max(-4, -q - 4);
const rMax = Math.min(4, -q + 4);
for (let r = rMin; r <= rMax; r++) {
BOARD_CELLS.add(cellKey(q, r));
TRIANGLE_OF.set(cellKey(q, r), 'center');
}
}
for (const [tri, cells] of Object.entries(TRIANGLES)) {
for (const [q, r] of cells) {
BOARD_CELLS.add(cellKey(q, r));
TRIANGLE_OF.set(cellKey(q, r), tri);
}
}
})();
export function isOnBoard(q, r) {
return BOARD_CELLS.has(cellKey(q, r));
}
export function triangleAt(q, r) {
return TRIANGLE_OF.get(cellKey(q, r)) ?? null;
}
export function allCells() {
return [...BOARD_CELLS].map((k) => {
const [q, r] = k.split(',').map(Number);
return { q, r };
});
}
// Six axial neighbor offsets.
const NEIGHBOR_OFFSETS = [
[ 1, 0], [-1, 0],
[ 0, 1], [ 0, -1],
[ 1, -1], [-1, 1],
];
const NEIGHBORS = new Map();
for (const k of BOARD_CELLS) {
const [q, r] = k.split(',').map(Number);
const arr = [];
for (const [dq, dr] of NEIGHBOR_OFFSETS) {
if (isOnBoard(q + dq, r + dr)) arr.push([q + dq, r + dr]);
}
NEIGHBORS.set(k, arr);
}
// Triangle centroid (for AI heuristic).
export const TRIANGLE_CENTROID = {};
for (const [id, cells] of Object.entries(TRIANGLES)) {
let sq = 0, sr = 0;
for (const [q, r] of cells) { sq += q; sr += r; }
TRIANGLE_CENTROID[id] = { q: sq / cells.length, r: sr / cells.length };
}
export function hexDistance(q1, r1, q2, r2) {
return (Math.abs(q1 - q2)
+ Math.abs(r1 - r2)
+ Math.abs((q1 + r1) - (q2 + r2))) / 2;
}
// ── State ──────────────────────────────────────────────────────────────────
export function createInitialState(seatColors = COLORS) {
if (seatColors.length !== 6) {
throw new Error('Chinese Checkers requires exactly 6 seats.');
}
const pegs = {};
const leftHome = {};
for (const color of seatColors) {
const home = TRIANGLES[COLOR_HOME[color]];
pegs[color] = home.map(([q, r]) => ({ q, r }));
leftHome[color] = Array(PEGS_PER_PLAYER).fill(false);
}
return {
seatColors: [...seatColors],
pegs,
leftHome,
currentSeat: 0,
phase: 'play', // 'play' | 'game_over'
winner: null,
finishedOrder: [], // colors in finishing order
lastMove: null,
};
}
export function cloneState(state) {
return {
seatColors: [...state.seatColors],
pegs: Object.fromEntries(
state.seatColors.map((c) => [c, state.pegs[c].map((p) => ({ q: p.q, r: p.r }))]),
),
leftHome: Object.fromEntries(
state.seatColors.map((c) => [c, [...state.leftHome[c]]]),
),
currentSeat: state.currentSeat,
phase: state.phase,
winner: state.winner,
finishedOrder: [...state.finishedOrder],
lastMove: state.lastMove
? { ...state.lastMove, path: state.lastMove.path.map((p) => ({ ...p })) }
: null,
};
}
export const currentColor = (state) => state.seatColors[state.currentSeat];
export function pegAt(state, q, r) {
for (const color of state.seatColors) {
const arr = state.pegs[color];
for (let i = 0; i < arr.length; i++) {
if (arr[i].q === q && arr[i].r === r) return { color, pegIdx: i };
}
}
return null;
}
function buildOccupiedSet(state) {
const occ = new Set();
for (const color of state.seatColors) {
for (const p of state.pegs[color]) occ.add(cellKey(p.q, p.r));
}
return occ;
}
// ── Move generation ────────────────────────────────────────────────────────
// All legal destinations for one peg. Each move:
// { color, pegIdx, q, r, isJump, path: [{q,r}, ...] }
// where path[0] is the peg's start and the last entry is the landing.
export function getMovesForPeg(state, color, pegIdx) {
if (state.phase !== 'play') return [];
if (state.finishedOrder.includes(color)) return [];
const peg = state.pegs[color][pegIdx];
const startKey = cellKey(peg.q, peg.r);
const startTri = triangleAt(peg.q, peg.r);
const targetTri = COLOR_TARGET[color];
const homeTri = COLOR_HOME[color];
const pegInTarget = startTri === targetTri;
// Treat the peg's own start square as empty when projecting jumps —
// it has vacated for the duration of the move.
const occ = buildOccupiedSet(state);
occ.delete(startKey);
const isLegalRest = (q, r) => {
if (pegInTarget) {
// Pegs already in target may only land in target.
return triangleAt(q, r) === targetTri;
}
const tri = triangleAt(q, r);
if (tri === 'center') return true;
if (tri === targetTri) return true;
if (tri === homeTri) {
// Once peg has left home, can't return.
return !state.leftHome[color][pegIdx];
}
// Any other player's home/target.
return false;
};
const moves = [];
// 1. Single step.
for (const [nq, nr] of NEIGHBORS.get(startKey)) {
if (occ.has(cellKey(nq, nr))) continue;
if (!isLegalRest(nq, nr)) continue;
moves.push({
color, pegIdx,
q: nq, r: nr,
isJump: false,
path: [{ q: peg.q, r: peg.r }, { q: nq, r: nr }],
});
}
// 2. Jump chain — BFS over reachable empty landings via jump-over-occupied.
const visited = new Set([startKey]);
const queue = [{ q: peg.q, r: peg.r, path: [{ q: peg.q, r: peg.r }] }];
while (queue.length) {
const node = queue.shift();
for (const [dq, dr] of NEIGHBOR_OFFSETS) {
const midQ = node.q + dq, midR = node.r + dr;
const landQ = node.q + 2 * dq, landR = node.r + 2 * dr;
if (!isOnBoard(landQ, landR)) continue;
const midKey = cellKey(midQ, midR);
const landKey = cellKey(landQ, landR);
if (!occ.has(midKey)) continue; // need a peg to jump over
if (occ.has(landKey)) continue; // landing must be empty
if (visited.has(landKey)) continue; // avoid loops
visited.add(landKey);
const newPath = [...node.path, { q: landQ, r: landR }];
if (isLegalRest(landQ, landR)) {
moves.push({
color, pegIdx,
q: landQ, r: landR,
isJump: true,
path: newPath,
});
}
// Continue BFS even if this landing can't be a rest — chain may exit
// back into legal territory.
queue.push({ q: landQ, r: landR, path: newPath });
}
}
return moves;
}
export function getAllValidMoves(state, color = currentColor(state)) {
const out = [];
for (let i = 0; i < PEGS_PER_PLAYER; i++) {
for (const m of getMovesForPeg(state, color, i)) out.push(m);
}
return out;
}
export function hasAnyMove(state, color = currentColor(state)) {
for (let i = 0; i < PEGS_PER_PLAYER; i++) {
if (getMovesForPeg(state, color, i).length > 0) return true;
}
return false;
}
// ── Move application ───────────────────────────────────────────────────────
export function applyMove(state, move) {
const s = cloneState(state);
const peg = s.pegs[move.color][move.pegIdx];
peg.q = move.q;
peg.r = move.r;
if (triangleAt(peg.q, peg.r) !== COLOR_HOME[move.color]) {
s.leftHome[move.color][move.pegIdx] = true;
}
s.lastMove = {
color: move.color,
pegIdx: move.pegIdx,
path: move.path.map((p) => ({ q: p.q, r: p.r })),
};
if (isColorFinished(s, move.color) && !s.finishedOrder.includes(move.color)) {
s.finishedOrder.push(move.color);
if (s.winner === null) s.winner = move.color;
}
if (s.finishedOrder.length >= s.seatColors.length - 1) {
s.phase = 'game_over';
return s;
}
// Advance turn, skipping any seats that have already finished.
do {
s.currentSeat = (s.currentSeat + 1) % s.seatColors.length;
} while (s.finishedOrder.includes(s.seatColors[s.currentSeat]));
return s;
}
export function isColorFinished(state, color) {
const target = COLOR_TARGET[color];
return state.pegs[color].every((p) => triangleAt(p.q, p.r) === target);
}
export function passTurn(state) {
// Used when a player has zero legal moves (extremely rare in CC).
const s = cloneState(state);
if (s.phase !== 'play') return s;
do {
s.currentSeat = (s.currentSeat + 1) % s.seatColors.length;
} while (s.finishedOrder.includes(s.seatColors[s.currentSeat]));
return s;
}

View File

@ -18,6 +18,7 @@ import ParchisiGame from './games/parchisi/ParchisiGame.js';
import YatziGame from './games/yatzi/YatziGame.js';
import SkipBoGame from './games/skipbo/SkipBoGame.js';
import Phase10Game from './games/phase10/Phase10Game.js';
import ChineseCheckersGame from './games/chinesecheckers/ChineseCheckersGame.js';
const config = {
type: Phaser.AUTO,
@ -49,6 +50,7 @@ const config = {
YatziGame,
SkipBoGame,
Phase10Game,
ChineseCheckersGame,
],
};

View File

@ -18,7 +18,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' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -32,3 +32,4 @@ registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardG
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'tabletop', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'tabletop', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });