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:
parent
d9bf0432a6
commit
356a2e98c5
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue