From 356a2e98c58ebaa6d837af1b42b0133a711695c7 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 17 May 2026 20:22:19 -0600 Subject: [PATCH] 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. --- .../chinesecheckers/ChineseCheckersAI.js | 83 +++ .../chinesecheckers/ChineseCheckersGame.js | 518 ++++++++++++++++++ .../chinesecheckers/ChineseCheckersLogic.js | 342 ++++++++++++ public/src/main.js | 2 + public/src/scenes/GameRoomScene.js | 2 +- server/multiplayer/gameRegistry.js | 1 + 6 files changed, 947 insertions(+), 1 deletion(-) create mode 100644 public/src/games/chinesecheckers/ChineseCheckersAI.js create mode 100644 public/src/games/chinesecheckers/ChineseCheckersGame.js create mode 100644 public/src/games/chinesecheckers/ChineseCheckersLogic.js diff --git a/public/src/games/chinesecheckers/ChineseCheckersAI.js b/public/src/games/chinesecheckers/ChineseCheckersAI.js new file mode 100644 index 0000000..39bfc36 --- /dev/null +++ b/public/src/games/chinesecheckers/ChineseCheckersAI.js @@ -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; +} diff --git a/public/src/games/chinesecheckers/ChineseCheckersGame.js b/public/src/games/chinesecheckers/ChineseCheckersGame.js new file mode 100644 index 0000000..cf90cf5 --- /dev/null +++ b/public/src/games/chinesecheckers/ChineseCheckersGame.js @@ -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); + } +} diff --git a/public/src/games/chinesecheckers/ChineseCheckersLogic.js b/public/src/games/chinesecheckers/ChineseCheckersLogic.js new file mode 100644 index 0000000..32a9ec9 --- /dev/null +++ b/public/src/games/chinesecheckers/ChineseCheckersLogic.js @@ -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; +} diff --git a/public/src/main.js b/public/src/main.js index d972ddb..66c5464 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -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, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 03f3b31..3836fb1 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -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, diff --git a/server/multiplayer/gameRegistry.js b/server/multiplayer/gameRegistry.js index 123f28f..00c6e57 100644 --- a/server/multiplayer/gameRegistry.js +++ b/server/multiplayer/gameRegistry.js @@ -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 });