343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
// 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;
|
|
}
|