feat: add single-player Parchisi game with AI

- Implement core Parchisi logic (ParchisiLogic.js) including board rules,
  move validation, and state management.
- Add Minimax-based AI (ParchisiAI.js) with heuristic evaluation for move
  selection, supporting up to 3 AI opponents.
- Create full Phaser game scene (ParchisiGame.js) with animated board,
  pawns, dice, and UI elements.
- Register new game assets (fonts, images, video animations).
- Update game registry to support 1-4 players with 3 AI opponents.
- Integrate Parchisi into the main scene loader and game room dispatch.
This commit is contained in:
Brian Fertig 2026-05-16 18:45:41 -06:00
parent 1f1897c8fe
commit d206cf6e5b
18 changed files with 1554 additions and 2 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,167 @@
import {
COLORS, ENTRY, HOME_ENTRY, TRACK_LEN,
cloneState, getValidMoves, applyMove,
blockadeSet, pawnsHome, pawnsInNest, isSafeTrack,
totalPipsRemaining,
} from './ParchisiLogic.js';
// Returns an ordered array of moves for the AI player to execute this turn.
// The caller should apply them one at a time (with animation), not all at once.
export function chooseMoves(state) {
if (state.phase !== 'move') return [];
const sequences = generateSequences(state, 0);
if (sequences.length === 0) return [];
const player = state.currentPlayer;
let best = sequences[0];
let bestScore = evaluateState(best.finalState, player);
for (let i = 1; i < sequences.length; i++) {
const sc = evaluateState(sequences[i].finalState, player);
if (sc > bestScore) { bestScore = sc; best = sequences[i]; }
}
return best.moves;
}
const MAX_DEPTH = 8; // typical doubles-with-bonus turn = 4 base + 2 bonuses
const BRANCH_CAP = 24; // cap branches per node to keep search tractable
function generateSequences(state, depth) {
if (depth > MAX_DEPTH || state.phase !== 'move' || state.movesLeft.length === 0) {
return [{ moves: [], finalState: state }];
}
const moves = getValidMoves(state);
if (moves.length === 0) {
// No legal move; apply a synthetic pass by clearing remaining dice.
const skipped = cloneState(state);
skipped.movesLeft = [];
return [{ moves: [], finalState: skipped }];
}
// Prioritize promising moves first, cap branching.
const ranked = moves
.map((m) => ({ m, h: moveHeuristic(state, m) }))
.sort((a, b) => b.h - a.h)
.slice(0, BRANCH_CAP);
const results = [];
const seen = new Set();
const player = state.currentPlayer;
for (const { m } of ranked) {
const next = applyMove(state, m);
// If turn changed or game over, sequence ends.
if (next.currentPlayer !== player || next.phase !== 'move') {
const key = boardHash(next);
if (!seen.has(key)) {
seen.add(key);
results.push({ moves: [m], finalState: next });
}
continue;
}
const subs = generateSequences(next, depth + 1);
for (const sub of subs) {
const key = boardHash(sub.finalState);
if (seen.has(key)) continue;
seen.add(key);
results.push({ moves: [m, ...sub.moves], finalState: sub.finalState });
}
}
return results.length > 0 ? results : [{ moves: [], finalState: state }];
}
function moveHeuristic(state, m) {
let h = 0;
if (m.hit) h += 100;
if (m.to.loc === 'home') h += 70;
if (m.to.home !== undefined) h += 20;
if (m.from.loc === 'nest') h += 15;
// Larger dice first slightly preferred to flush out 6s
h += (m.dieUsed ?? 0) * 0.5;
return h;
}
function boardHash(state) {
const parts = [];
for (const c of COLORS) {
const sorted = [...state.pawns[c]].map((p) => {
if (p.loc === 'nest') return 'N';
if (p.loc === 'home') return 'H';
if (p.home !== undefined) return `h${p.home}`;
return `t${p.track}`;
}).sort();
parts.push(`${c}:${sorted.join(',')}`);
}
parts.push(`m:${[...state.movesLeft].sort().join(',')}`);
return parts.join('|');
}
// ─── Evaluation ───────────────────────────────────────────────────────────
function evaluateState(state, player) {
let score = 0;
const opps = COLORS.filter((c) => c !== player);
// Pip progress
const ownPips = totalPipsRemaining(state, player);
score -= ownPips * 1.0;
for (const o of opps) score += totalPipsRemaining(state, o) * 0.25;
// Pawns home
score += pawnsHome(state, player) * 50;
for (const o of opps) score -= pawnsHome(state, o) * 25;
// Pawns out of nest (mobility)
for (const c of COLORS) {
const out = 4 - pawnsInNest(state, c);
if (c === player) score += out * 5;
else score -= out * 2;
}
// Own blockades — strong positional value
const myBlockades = countOwnBlockades(state, player);
score += myBlockades * 18;
// Blot exposure: own single pawns at risk from opponents behind them
score -= blotExposure(state, player) * 8;
return score;
}
function countOwnBlockades(state, player) {
const bset = blockadeSet(state);
let count = 0;
for (const idx of bset) {
const own = state.pawns[player].some((p) => p.track === idx);
if (own) count++;
}
return count;
}
// Count of own pawns that sit alone on a non-safe square within 12 squares
// in front of any opponent pawn (so an opponent could land on them).
function blotExposure(state, player) {
let expo = 0;
for (let i = 0; i < 4; i++) {
const p = state.pawns[player][i];
if (p.track === undefined) continue;
if (isSafeTrack(p.track)) continue;
// Is there an own partner on the same square? then not a blot
const own = state.pawns[player].filter((q) => q.track === p.track).length;
if (own >= 2) continue;
// Check opponent threats
for (const opp of COLORS) {
if (opp === player) continue;
for (const q of state.pawns[opp]) {
if (q.track === undefined) continue;
const dist = (p.track - q.track + TRACK_LEN) % TRACK_LEN;
if (dist >= 1 && dist <= 12) { expo++; break; }
}
}
}
return expo;
}
// Re-export for tests.
export { evaluateState };

View File

@ -0,0 +1,956 @@
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 {
createInitialState, rollDice, getValidMoves,
applyMove, hasAnyMove, isSafeTrack,
COLORS as PCOLORS, ENTRY, HOME_ENTRY, HOME_COL_LEN,
} from './ParchisiLogic.js';
import { chooseMoves } from './ParchisiAI.js';
// ── Layout constants ────────────────────────────────────────────────────────
const CELL = 50;
const GRID = 19;
const BOARD = CELL * GRID; // 950
const ORIGIN_X = (GAME_WIDTH - BOARD) / 2; // 485
const ORIGIN_Y = (GAME_HEIGHT - BOARD) / 2; // 65
const PAWN_R = 18;
const DEPTH = { felt: -1, board: 0, square: 1, label: 2, pawn: 10, highlight: 20, moving: 30, dice: 40, ui: 50, banner: 60 };
const COLOR_HEX = {
red: { fill: 0xc92a2a, ring: 0xff6b6b, dark: 0x7a1010 },
blue: { fill: 0x1864ab, ring: 0x4dabf7, dark: 0x0a3a6b },
yellow: { fill: 0xe6b800, ring: 0xffd43b, dark: 0x8a6d00 },
green: { fill: 0x2f9e44, ring: 0x69db7c, dark: 0x155724 },
};
const BOARD_BG = 0x1d2630;
const NEST_BORDER = 0x000000;
const TRACK_FILL = 0xeae3d0;
const TRACK_STROKE = 0x32281a;
const SAFE_FILL = 0xcfe6f7;
const CENTER_FILL = 0xeae3d0;
const HOME_GOAL = 0xffd700;
// ── Track index → (col, row) ────────────────────────────────────────────────
function trackXY(idx) {
if (idx <= 7) return { col: 8, row: 18 - idx };
if (idx <= 15) return { col: 7 - (idx - 8), row: 10 };
if (idx === 16) return { col: 0, row: 9 };
if (idx <= 24) return { col: idx - 17, row: 8 };
if (idx <= 32) return { col: 8, row: 7 - (idx - 25) };
if (idx === 33) return { col: 9, row: 0 };
if (idx <= 41) return { col: 10, row: idx - 34 };
if (idx <= 49) return { col: 11 + (idx - 42), row: 8 };
if (idx === 50) return { col: 18, row: 9 };
if (idx <= 58) return { col: 18 - (idx - 51), row: 10 };
if (idx <= 66) return { col: 10, row: 11 + (idx - 59) };
if (idx === 67) return { col: 9, row: 18 };
throw new Error(`bad track idx ${idx}`);
}
function homeXY(color, idx) {
if (color === 'red') return { col: 9, row: 17 - idx };
if (color === 'blue') return { col: 17 - idx, row: 9 };
if (color === 'yellow') return { col: 9, row: 1 + idx };
if (color === 'green') return { col: 1 + idx, row: 9 };
throw new Error(`bad color ${color}`);
}
// Final "home" cell at the center boundary per color.
function homeFinalXY(color) {
if (color === 'red') return { col: 9, row: 10 };
if (color === 'blue') return { col: 10, row: 9 };
if (color === 'yellow') return { col: 9, row: 8 };
if (color === 'green') return { col: 8, row: 9 };
}
function cellWorld(col, row) {
return { x: ORIGIN_X + col * CELL + CELL / 2, y: ORIGIN_Y + row * CELL + CELL / 2 };
}
// Per-color nest layout: 4 pawn slot positions in WORLD coords.
const NEST_RECT = {
red: { col0: 0, row0: 11, col1: 7, row1: 18 },
yellow: { col0: 11, row0: 0, col1: 18, row1: 7 },
blue: { col0: 11, row0: 11, col1: 18, row1: 18 },
green: { col0: 0, row0: 0, col1: 7, row1: 7 },
};
function nestSlotsWorld(color) {
const r = NEST_RECT[color];
const cx = ORIGIN_X + ((r.col0 + r.col1 + 1) / 2) * CELL;
const cy = ORIGIN_Y + ((r.row0 + r.row1 + 1) / 2) * CELL;
const off = 75;
return [
{ x: cx - off, y: cy - off },
{ x: cx + off, y: cy - off },
{ x: cx - off, y: cy + off },
{ x: cx + off, y: cy + off },
];
}
// Final "home" stacking slot — 4 positions clustered near the color's center
// boundary cell so all 4 pawns are visible once home.
function homeStackSlots(color) {
const { col, row } = homeFinalXY(color);
const base = cellWorld(col, row);
const o = 14;
return [
{ x: base.x - o, y: base.y - o },
{ x: base.x + o, y: base.y - o },
{ x: base.x - o, y: base.y + o },
{ x: base.x + o, y: base.y + o },
];
}
// Convert a pawn's logical loc into a WORLD position (used for non-nest/home).
function pawnLocWorld(loc, color, slotIdx = 0) {
if (loc === 'nest' || loc?.loc === 'nest') {
return nestSlotsWorld(color)[slotIdx];
}
if (loc === 'home' || loc?.loc === 'home') {
return homeStackSlots(color)[slotIdx];
}
if (loc.track !== undefined) {
const { col, row } = trackXY(loc.track);
return cellWorld(col, row);
}
if (loc.home !== undefined) {
const { col, row } = homeXY(color, loc.home);
return cellWorld(col, row);
}
return { x: 0, y: 0 };
}
// ── Scene ───────────────────────────────────────────────────────────────────
export default class ParchisiGame extends Phaser.Scene {
constructor() { super('ParchisiGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.pawnObjs = {}; // color → [container, ...] (4 each)
this.highlightObjs = [];
this.selectedPawnIdx = null;
this.diceContainers = [];
this.diceGraphics = [];
this.rollBtn = null;
this.statusText = null;
this.opponentPortraits = {}; // color → portrait controller
this.turnIndicator = null;
this.turnIndicatorGfx = null;
this.turnIndicatorPulseTween = null;
}
create() {
this.buildPlayfield();
this.buildBoard();
this.buildDice();
this.buildUI();
this.buildPlayerCards();
this.buildTurnIndicator();
this.buildPawns();
this.initGame();
}
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);
}
}
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.board);
// Board base
g.fillStyle(BOARD_BG, 1);
g.fillRoundedRect(ORIGIN_X - 6, ORIGIN_Y - 6, BOARD + 12, BOARD + 12, 12);
// Nests
for (const c of PCOLORS) this.drawNest(c);
// Outer track squares
for (let i = 0; i < 68; i++) this.drawTrackSquare(i);
// Home columns
for (const c of PCOLORS) this.drawHomeColumn(c);
// Center home triangle area
this.drawCenter();
}
drawNest(color) {
const r = NEST_RECT[color];
const c = COLOR_HEX[color];
const x = ORIGIN_X + r.col0 * CELL;
const y = ORIGIN_Y + r.row0 * CELL;
const w = (r.col1 - r.col0 + 1) * CELL;
const h = (r.row1 - r.row0 + 1) * CELL;
const g = this.add.graphics().setDepth(DEPTH.square);
g.fillStyle(c.fill, 0.95);
g.fillRoundedRect(x + 4, y + 4, w - 8, h - 8, 14);
g.lineStyle(3, c.dark, 1);
g.strokeRoundedRect(x + 4, y + 4, w - 8, h - 8, 14);
// Inner circle indicating nest area
g.fillStyle(0xffffff, 0.55);
g.fillCircle(x + w / 2, y + h / 2, Math.min(w, h) * 0.36);
g.lineStyle(2, c.dark, 0.8);
g.strokeCircle(x + w / 2, y + h / 2, Math.min(w, h) * 0.36);
}
drawTrackSquare(idx) {
const { col, row } = trackXY(idx);
const wp = cellWorld(col, row);
const g = this.add.graphics().setDepth(DEPTH.square);
// Determine fill: colored entry, safe, or normal
const entryColor = Object.keys(ENTRY).find((c) => ENTRY[c] === idx);
let fill = TRACK_FILL;
if (entryColor) fill = COLOR_HEX[entryColor].fill;
else if (isSafeTrack(idx)) fill = SAFE_FILL;
g.fillStyle(fill, 1);
g.fillRoundedRect(wp.x - CELL / 2 + 2, wp.y - CELL / 2 + 2, CELL - 4, CELL - 4, 6);
g.lineStyle(1.5, TRACK_STROKE, 0.8);
g.strokeRoundedRect(wp.x - CELL / 2 + 2, wp.y - CELL / 2 + 2, CELL - 4, CELL - 4, 6);
// Safety star marker
if (isSafeTrack(idx) && !entryColor) {
this.drawStar(g, wp.x, wp.y, 5, 10, 5, 0x2b5d80, 0.7);
}
}
drawHomeColumn(color) {
const c = COLOR_HEX[color];
for (let i = 0; i < HOME_COL_LEN; i++) {
const { col, row } = homeXY(color, i);
const wp = cellWorld(col, row);
const g = this.add.graphics().setDepth(DEPTH.square);
g.fillStyle(c.fill, 0.85);
g.fillRoundedRect(wp.x - CELL / 2 + 3, wp.y - CELL / 2 + 3, CELL - 6, CELL - 6, 5);
g.lineStyle(1.5, c.dark, 0.9);
g.strokeRoundedRect(wp.x - CELL / 2 + 3, wp.y - CELL / 2 + 3, CELL - 6, CELL - 6, 5);
}
// Final goal cell at center boundary
const { col, row } = homeFinalXY(color);
const wp = cellWorld(col, row);
const g = this.add.graphics().setDepth(DEPTH.square);
g.fillStyle(HOME_GOAL, 0.4);
g.fillCircle(wp.x, wp.y, CELL * 0.42);
g.lineStyle(2, c.dark, 0.9);
g.strokeCircle(wp.x, wp.y, CELL * 0.42);
}
drawCenter() {
// Big center triangle motif
const cx = ORIGIN_X + 9.5 * CELL;
const cy = ORIGIN_Y + 9.5 * CELL;
const r = CELL * 0.6;
const g = this.add.graphics().setDepth(DEPTH.square);
g.fillStyle(CENTER_FILL, 1);
g.fillCircle(cx, cy, r);
g.lineStyle(2, TRACK_STROKE, 0.9);
g.strokeCircle(cx, cy, r);
this.add.text(cx, cy, 'HOME', {
fontFamily: 'system-ui, sans-serif',
fontSize: '16px',
color: '#3a2010',
fontStyle: 'bold',
}).setOrigin(0.5).setDepth(DEPTH.label);
}
drawStar(g, cx, cy, points, outer, inner, color, alpha) {
g.fillStyle(color, alpha);
g.beginPath();
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.closePath();
g.fillPath();
}
// ── Dice ──────────────────────────────────────────────────────────────────
buildDice() {
const baseX = ORIGIN_X - 80;
const baseY = GAME_HEIGHT / 2 - 150;
for (let i = 0; i < 2; i++) {
const g = this.add.graphics();
const container = this.add.container(baseX, baseY + i * 80).setDepth(DEPTH.dice);
container.add(g);
this.diceContainers.push(container);
this.diceGraphics.push(g);
this.renderDieFace(i, 1);
container.setAlpha(0.25);
}
}
renderDieFace(idx, value) {
const g = this.diceGraphics[idx];
const s = 28;
g.clear();
g.fillStyle(0xf0e8d0, 1);
g.fillRoundedRect(-s, -s, s * 2, s * 2, 7);
g.lineStyle(2, 0x2c1a0e, 1);
g.strokeRoundedRect(-s, -s, s * 2, s * 2, 7);
const layouts = {
1: [[0, 0]],
2: [[-0.6, -0.6], [0.6, 0.6]],
3: [[-0.6, -0.6], [0, 0], [0.6, 0.6]],
4: [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]],
5: [[-0.6, -0.6], [0.6, -0.6], [0, 0], [-0.6, 0.6], [0.6, 0.6]],
6: [[-0.6, -0.6], [0.6, -0.6], [-0.6, 0], [0.6, 0], [-0.6, 0.6], [0.6, 0.6]],
};
g.fillStyle(0x1a1a1a, 1);
for (const [px, py] of layouts[value] ?? layouts[1]) {
g.fillCircle(px * 16, py * 16, 4);
}
}
animateDiceRoll(finalValues, onComplete) {
this.diceContainers.forEach((c) => c.setAlpha(1));
let elapsed = 0;
const totalMs = 650;
const tick = () => {
const interval = elapsed < 400 ? 60 : 110;
for (let i = 0; i < 2; i++) this.renderDieFace(i, Phaser.Math.Between(1, 6));
elapsed += interval;
if (elapsed < totalMs) {
this.time.delayedCall(interval, tick);
} else {
this.renderDieFace(0, finalValues[0]);
this.renderDieFace(1, finalValues[1]);
for (const c of this.diceContainers) {
this.tweens.add({ targets: c, scaleX: 1.2, scaleY: 1.2, duration: 80, yoyo: true });
}
this.time.delayedCall(120, onComplete);
}
};
tick();
}
updateDiceDisplay() {
if (!this.gs.dice) {
this.diceContainers.forEach((c) => c.setAlpha(0.25));
return;
}
this.diceContainers.forEach((c) => c.setAlpha(1));
this.renderDieFace(0, this.gs.dice[0]);
this.renderDieFace(1, this.gs.dice[1]);
// Dim used base-dice
const remaining = [...this.gs.movesLeft];
for (let i = 0; i < 2; i++) {
const v = this.gs.dice[i];
const idx = remaining.indexOf(v);
if (idx === -1) this.diceContainers[i].setAlpha(0.35);
else { this.diceContainers[i].setAlpha(1); remaining.splice(idx, 1); }
}
}
// ── UI / Portraits ────────────────────────────────────────────────────────
buildUI() {
const xLeft = ORIGIN_X - 80;
const yRoll = GAME_HEIGHT / 2 + 10;
this.rollBtn = new Button(this, xLeft, yRoll, 'Roll', () => this.onRollClick(), {
width: 110, height: 44, fontSize: 22,
});
this.rollBtn.setDepth(DEPTH.ui);
new Button(this, xLeft, yRoll + 70, 'New', () => this.initGame(), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(DEPTH.ui);
new Button(this, xLeft, yRoll + 120, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 110, height: 40, fontSize: 18,
}).setDepth(DEPTH.ui);
this.statusText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 30, '', {
fontFamily: 'system-ui, sans-serif',
fontSize: '22px',
color: UI.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
buildPlayerCards() {
const portraitR = 56;
// Left nests (red, green): col0=0, so left edge is ORIGIN_X
// Right nests (blue, yellow): col1=18, so right edge is ORIGIN_X + BOARD
const xLeft = ORIGIN_X - portraitR - 20;
const xRight = ORIGIN_X + BOARD + portraitR + 20;
// Red (human) — left side, bottom-left nest
const plY = this.nestCenterY('red');
this.add.circle(xLeft, plY, portraitR + 5, COLOR_HEX.red.fill, 0.6).setDepth(DEPTH.ui);
createPlayerPortrait(this, xLeft, plY, portraitR, DEPTH.ui + 1, 'ParchisiGame');
this.add.text(xLeft, plY + portraitR + 14, auth.user?.username ?? 'You', {
fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: UI.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 2);
// AI opponents: blue (right/bottom), yellow (right/top), green (left/top)
const aiColors = ['blue', 'yellow', 'green'];
aiColors.forEach((color, i) => {
const opp = this.opponents[i];
if (!opp) return;
const x = color === 'green' ? xLeft : xRight;
const y = this.nestCenterY(color);
this.add.circle(x, y, portraitR + 5, COLOR_HEX[color].fill, 0.6).setDepth(DEPTH.ui);
this.opponentPortraits[color] = createOpponentPortrait(this, opp, x, y, portraitR, DEPTH.ui + 1);
this.add.text(x, y + portraitR + 12, opp.name ?? color, {
fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: UI.textHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui + 2);
});
}
playEmotion(color, emotion) {
this.opponentPortraits[color]?.playEmotion(emotion);
}
nestCenterY(color) {
const r = NEST_RECT[color];
return ORIGIN_Y + ((r.row0 + r.row1 + 1) / 2) * CELL;
}
// ── Turn indicator ────────────────────────────────────────────────────────
buildTurnIndicator() {
const g = this.add.graphics();
this.turnIndicatorGfx = g;
this.turnIndicator = this.add.container(0, 0, [g]).setDepth(DEPTH.ui + 3).setAlpha(0);
}
_indicatorPos(color) {
const isLeft = color === 'red' || color === 'green';
const portR = 56;
const portBgR = portR + 5; // matches the bg circle radius in buildPlayerCards
const portX = isLeft ? ORIGIN_X - portR - 20 : ORIGIN_X + BOARD + portR + 20;
return {
x: isLeft ? portX - portBgR - 20 : portX + portBgR + 20,
y: this.nestCenterY(color),
side: isLeft ? 'left' : 'right',
};
}
_drawTurnTriangle(side) {
const g = this.turnIndicatorGfx;
g.clear();
const h = 16; // half-height
const d = 20; // depth from center to tip
g.fillStyle(0xffd700, 1);
g.lineStyle(2, 0xb8860b, 1);
g.beginPath();
if (side === 'left') {
// Points right →, sits to the left of the portrait
g.moveTo(d, 0);
g.lineTo(-10, -h);
g.lineTo(-10, h);
} else {
// Points left ←, sits to the right of the portrait
g.moveTo(-d, 0);
g.lineTo(10, -h);
g.lineTo(10, h);
}
g.closePath();
g.fillPath();
g.strokePath();
}
_startIndicatorPulse() {
if (this.turnIndicatorPulseTween) this.turnIndicatorPulseTween.stop();
this.turnIndicatorPulseTween = this.tweens.add({
targets: this.turnIndicator,
scaleX: { from: 1, to: 1.35 },
scaleY: { from: 1, to: 1.35 },
alpha: { from: 1, to: 0.55 },
duration: 560,
ease: 'Sine.easeInOut',
yoyo: true,
repeat: -1,
});
}
moveTurnIndicator(color, immediately = false) {
const { x, y, side } = this._indicatorPos(color);
if (this.turnIndicatorPulseTween) {
this.turnIndicatorPulseTween.stop();
this.turnIndicatorPulseTween = null;
}
this.turnIndicator.setScale(1);
if (immediately || this.turnIndicator.alpha === 0) {
this._drawTurnTriangle(side);
this.turnIndicator.setPosition(x, y).setAlpha(1);
this._startIndicatorPulse();
return;
}
this.tweens.add({
targets: this.turnIndicator,
x, y,
duration: 550,
ease: 'Cubic.easeInOut',
onComplete: () => {
this._drawTurnTriangle(side);
this._startIndicatorPulse();
},
});
}
// ── Pawn rendering ────────────────────────────────────────────────────────
buildPawns() {
for (const color of PCOLORS) {
this.pawnObjs[color] = [];
for (let i = 0; i < 4; i++) {
const slot = nestSlotsWorld(color)[i];
const c = this.makePawn(color, slot.x, slot.y);
c.setDepth(DEPTH.pawn);
c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, PAWN_R + 4), hitAreaCallback: Phaser.Geom.Circle.Contains });
c.on('pointerdown', () => this.onPawnClick(color, i));
this.pawnObjs[color].push(c);
}
}
}
makePawn(color, x, y) {
const c = COLOR_HEX[color];
const g = this.add.graphics();
g.fillStyle(0x000000, 0.3);
g.fillCircle(2, 3, PAWN_R);
g.fillStyle(c.dark, 1);
g.fillCircle(0, 0, PAWN_R);
g.fillStyle(c.fill, 1);
g.fillCircle(0, 0, PAWN_R - 4);
g.lineStyle(2, c.ring, 0.9);
g.strokeCircle(0, 0, PAWN_R - 2);
return this.add.container(x, y, [g]);
}
// ── Game flow ─────────────────────────────────────────────────────────────
initGame() {
this.clearHighlights();
this.animating = false;
this.selectedPawnIdx = null;
// Player order: red (human) first, then opponents.
this.gs = createInitialState(['red', 'blue', 'yellow', 'green']);
this.refreshAllPawns();
this.updateDiceDisplay();
this.updateButtons();
this.moveTurnIndicator('red', true);
this.setStatus('Your turn — roll the dice');
}
refreshAllPawns() {
// Track stack offsets when multiple pawns share a track cell
const stackCounts = new Map();
for (const color of PCOLORS) {
for (let i = 0; i < 4; i++) {
const p = this.gs.pawns[color][i];
const obj = this.pawnObjs[color][i];
let { x, y } = this.pawnPositionForRender(p, color, i, stackCounts);
obj.setPosition(x, y);
obj.setScale(this.pawnScaleAt(p));
}
}
}
pawnPositionForRender(p, color, slotIdx, stackCounts) {
if (p.loc === 'nest') return nestSlotsWorld(color)[slotIdx];
if (p.loc === 'home') return homeStackSlots(color)[Math.min(slotIdx, 3)];
if (p.track !== undefined) {
const { col, row } = trackXY(p.track);
const base = cellWorld(col, row);
const key = `t${p.track}`;
const n = stackCounts.get(key) ?? 0;
stackCounts.set(key, n + 1);
// Side-by-side micro-offset for shared squares
const dx = (n % 2 === 0) ? -7 : 7;
const dy = n < 2 ? -3 : 6;
return { x: base.x + dx, y: base.y + dy };
}
if (p.home !== undefined) {
const { col, row } = homeXY(color, p.home);
return cellWorld(col, row);
}
return { x: 0, y: 0 };
}
pawnScaleAt(p) {
if (p.loc === 'nest') return 0.85;
if (p.loc === 'home') return 0.75;
return 1;
}
// ── Roll handling ─────────────────────────────────────────────────────────
onRollClick() {
if (this.animating || this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'red') return;
this.rollDiceFlow();
}
rollDiceFlow() {
this.animating = true;
this.updateButtons();
const next = rollDice(this.gs);
const [d1, d2] = next.dice;
this.animateDiceRoll([d1, d2], () => {
this.gs = next;
this.updateDiceDisplay();
// Three-doubles penalty path (logic already moved pawn to nest, ended turn)
if (this.gs.consecutiveDoubles === 0 && this.gs.phase === 'roll'
&& this.gs.movesLeft.length === 0 && d1 === d2) {
// We rolled doubles but penalty triggered — refresh and continue
this.refreshAllPawns();
this.setStatus('Three doubles — penalty applied!');
this.animating = false;
this.afterTurn();
return;
}
if (this.gs.phase === 'move' && !hasAnyMove(this.gs)) {
this.setStatus('No legal moves — turn passed');
this.time.delayedCall(1200, () => {
// Skip remaining dice; force turn end.
this.gs.movesLeft = [];
this.gs.lastWasDoubles = false;
this.endCurrentTurn();
this.animating = false;
this.afterTurn();
});
return;
}
this.animating = false;
this.updateButtons();
if (this.gs.currentPlayer !== 'red') {
this.time.delayedCall(600, () => this.runAITurnMoves());
} else {
this.setStatus('Choose a pawn to move');
}
});
}
endCurrentTurn() {
// Advance turn manually when applyMove won't (no moves played).
const players = this.gs.players;
const idx = players.indexOf(this.gs.currentPlayer);
this.gs.currentPlayer = players[(idx + 1) % players.length];
this.gs.consecutiveDoubles = 0;
this.gs.dice = null;
this.gs.movesLeft = [];
this.gs.lastWasDoubles = false;
this.gs.phase = 'roll';
}
// ── Pawn input (human) ────────────────────────────────────────────────────
onPawnClick(color, pawnIdx) {
if (this.animating) return;
if (this.gs.phase !== 'move') return;
if (color !== 'red' || this.gs.currentPlayer !== 'red') return;
const moves = getValidMoves(this.gs).filter((m) => m.pawnIdx === pawnIdx);
if (moves.length === 0) {
this.flashPawn(color, pawnIdx);
return;
}
this.clearHighlights();
this.selectedPawnIdx = pawnIdx;
this.pulsePawn(color, pawnIdx);
this.showDestinationHighlights(moves);
}
showDestinationHighlights(moves) {
// Group by destination world position
const groups = new Map();
for (const m of moves) {
const wp = this.destWorld(m.to, m.player);
const key = `${Math.round(wp.x)}_${Math.round(wp.y)}`;
if (!groups.has(key)) groups.set(key, { wp, moves: [] });
groups.get(key).moves.push(m);
}
for (const { wp, moves: ms } of groups.values()) {
const dot = this.add.graphics().setDepth(DEPTH.highlight);
dot.fillStyle(UI.accent, 0.85);
dot.fillCircle(wp.x, wp.y, 18);
dot.lineStyle(3, 0xffffff, 0.6);
dot.strokeCircle(wp.x, wp.y, 18);
this.tweens.add({ targets: dot, alpha: { from: 0.85, to: 0.25 }, duration: 600, yoyo: true, repeat: -1 });
const zone = this.add.zone(wp.x, wp.y, CELL, CELL)
.setInteractive({ useHandCursor: true })
.setDepth(DEPTH.highlight);
zone.on('pointerdown', () => this.onDestinationClick(ms));
this.highlightObjs.push(dot, zone);
}
}
destWorld(to, color) {
if (to.loc === 'home') return homeStackSlots(color)[0];
if (to.track !== undefined) {
const { col, row } = trackXY(to.track);
return cellWorld(col, row);
}
if (to.home !== undefined) {
const { col, row } = homeXY(color, to.home);
return cellWorld(col, row);
}
return { x: 0, y: 0 };
}
onDestinationClick(candidateMoves) {
if (this.animating) return;
// Prefer the move that uses the SMALLEST die (preserves flexibility),
// but prefer bonus 10/20 last so base dice get used first.
const sorted = [...candidateMoves].sort((a, b) => {
const av = a.combineDice ? 100 : a.dieUsed;
const bv = b.combineDice ? 100 : b.dieUsed;
return av - bv;
});
const move = sorted[0];
this.clearHighlights();
this.executeMove(move, () => this.afterMove());
}
afterMove() {
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
if (this.gs.currentPlayer === 'red' && this.gs.phase === 'move') {
if (!hasAnyMove(this.gs)) {
this.setStatus('No more legal moves — turn passes');
this.time.delayedCall(900, () => {
this.gs.movesLeft = [];
this.gs.lastWasDoubles = false;
this.endCurrentTurn();
this.afterTurn();
});
return;
}
this.setStatus('Choose a pawn');
this.updateButtons();
return;
}
if (this.gs.phase === 'roll') {
this.afterTurn();
} else {
// AI continues
this.time.delayedCall(450, () => this.runAITurnMoves());
}
}
afterTurn() {
this.updateButtons();
this.moveTurnIndicator(this.gs.currentPlayer);
if (this.gs.currentPlayer === 'red') {
this.setStatus('Your turn — roll the dice');
} else {
const opp = this.opponents[['blue', 'yellow', 'green'].indexOf(this.gs.currentPlayer)];
this.setStatus(`${opp?.name ?? this.gs.currentPlayer}'s turn`);
this.time.delayedCall(700, () => this.runAITurn());
}
}
// ── AI turn ───────────────────────────────────────────────────────────────
runAITurn() {
if (this.animating) return;
if (this.gs.currentPlayer === 'red') return;
if (this.gs.phase !== 'roll') return;
this.animating = true;
this.updateButtons();
const next = rollDice(this.gs);
const [d1, d2] = next.dice;
this.animateDiceRoll([d1, d2], () => {
this.gs = next;
this.updateDiceDisplay();
if (this.gs.phase === 'roll') {
// Three-doubles penalty triggered inside rollDice
this.refreshAllPawns();
this.animating = false;
this.afterTurn();
return;
}
if (!hasAnyMove(this.gs)) {
this.time.delayedCall(900, () => {
this.gs.movesLeft = [];
this.gs.lastWasDoubles = false;
this.endCurrentTurn();
this.animating = false;
this.afterTurn();
});
return;
}
this.runAITurnMoves();
});
}
runAITurnMoves() {
if (this.gs.phase !== 'move') { this.animating = false; this.afterTurn(); return; }
this.animating = true;
const moves = chooseMoves(this.gs);
if (moves.length === 0) {
// Force turn end if AI somehow returns no moves
this.gs.movesLeft = [];
this.gs.lastWasDoubles = false;
this.endCurrentTurn();
this.animating = false;
this.afterTurn();
return;
}
this.playAIMoves(moves, 0);
}
playAIMoves(moves, i) {
if (i >= moves.length || this.gs.phase === 'game_over') {
this.animating = false;
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
// If AI got another roll (doubles), continue
if (this.gs.phase === 'roll' && this.gs.currentPlayer !== 'red') {
this.time.delayedCall(700, () => this.runAITurn());
return;
}
this.afterTurn();
return;
}
this.executeMove(moves[i], () => {
this.time.delayedCall(280, () => this.playAIMoves(moves, i + 1));
});
}
// ── Move execution + animation ────────────────────────────────────────────
executeMove(move, onComplete) {
const obj = this.pawnObjs[move.player][move.pawnIdx];
const from = this.locWorld(move.from, move.player);
const to = this.locWorld(move.to, move.player);
// Hit animation (opponent pawn slides back to nest)
if (move.hit) {
const oppObj = this.pawnObjs[move.hit.color][move.hit.pawnIdx];
const oppHome = nestSlotsWorld(move.hit.color)[move.hit.pawnIdx];
this.tweens.add({ targets: oppObj, x: oppHome.x, y: oppHome.y, duration: 380, ease: 'Quad.easeIn' });
this.playEmotion(move.hit.color, 'upset');
}
obj.setDepth(DEPTH.moving);
const midX = (from.x + to.x) / 2;
const midY = Math.min(from.y, to.y) - 80;
const prog = { t: 0 };
this.tweens.add({
targets: prog, t: 1, duration: 380, ease: 'Cubic.easeInOut',
onUpdate: () => {
const t = prog.t;
const inv = 1 - t;
obj.x = inv * inv * from.x + 2 * inv * t * midX + t * t * to.x;
obj.y = inv * inv * from.y + 2 * inv * t * midY + t * t * to.y;
},
onComplete: () => {
obj.setDepth(DEPTH.pawn);
this.gs = applyMove(this.gs, move);
this.refreshAllPawns();
this.updateDiceDisplay();
// Happy emotion when AI captures or homes
if (move.player !== 'red' && (move.hit || move.to.loc === 'home')) {
this.playEmotion(move.player, 'happy');
}
onComplete?.();
},
});
}
locWorld(loc, color) {
if (loc.loc === 'nest') {
// Use a representative nest slot (slot 0)
return nestSlotsWorld(color)[0];
}
if (loc.loc === 'home') return homeStackSlots(color)[0];
if (loc.track !== undefined) {
const { col, row } = trackXY(loc.track);
return cellWorld(col, row);
}
if (loc.home !== undefined) {
const { col, row } = homeXY(color, loc.home);
return cellWorld(col, row);
}
return { x: 0, y: 0 };
}
// ── Highlights and pulsing ────────────────────────────────────────────────
pulsePawn(color, pawnIdx) {
const obj = this.pawnObjs[color][pawnIdx];
const ring = this.add.graphics().setDepth(DEPTH.highlight);
ring.lineStyle(3, 0xffd700, 1);
ring.strokeCircle(obj.x, obj.y, PAWN_R + 6);
this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.3 }, duration: 500, yoyo: true, repeat: -1 });
this.highlightObjs.push(ring);
}
flashPawn(color, pawnIdx) {
const obj = this.pawnObjs[color][pawnIdx];
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.selectedPawnIdx = null;
}
// ── UI updates ────────────────────────────────────────────────────────────
updateButtons() {
const canRoll = !this.animating
&& this.gs.phase === 'roll'
&& this.gs.currentPlayer === 'red';
this.rollBtn?.setEnabled(canRoll);
}
setStatus(msg) { this.statusText?.setText(msg); }
// ── Game over ─────────────────────────────────────────────────────────────
onGameOver() {
const winner = this.gs.winner;
const isHuman = winner === 'red';
if (!isHuman) this.playEmotion(winner, 'happy');
else {
for (const c of ['blue', 'yellow', 'green']) this.playEmotion(c, 'upset');
}
const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, 720, 320, 0x0a0e14, 0.92)
.setStrokeStyle(3, UI.accent).setDepth(DEPTH.banner);
const oppName = (() => {
const i = ['blue', 'yellow', 'green'].indexOf(winner);
return i >= 0 ? (this.opponents[i]?.name ?? winner) : 'You';
})();
const msg = isHuman
? '🎉 You Win!\nAll four pawns home!'
: `${oppName} wins this round.\nBetter luck next game!`;
const txt = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 50, msg, {
fontFamily: 'system-ui, sans-serif',
fontSize: '32px',
color: isHuman ? '#ffd700' : UI.textHex,
align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
new Button(this, GAME_WIDTH / 2 - 100, GAME_HEIGHT / 2 + 80, 'Play Again', () => {
overlay.destroy(); txt.destroy(); this.initGame();
}, { width: 170, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, GAME_WIDTH / 2 + 100, GAME_HEIGHT / 2 + 80, 'Leave', () => {
this.scene.start('GameMenu');
}, { variant: 'ghost', width: 170, fontSize: 22 }).setDepth(DEPTH.banner + 1);
}
}

View File

@ -0,0 +1,427 @@
// Pure Parcheesi (American standard) rules. No Phaser dependency.
//
// Board model:
// - 68-square outer track (indices 0..67), clockwise.
// - 4 colors with fixed entry squares 17 apart: red=0, blue=17, yellow=34, green=51.
// - Each color travels 67 outer steps from its entry to its own home-entry
// (which is the square immediately CCW from the entry: entry-1 mod 68),
// then 7 squares up its home column, then 1 final step into home.
// - Total path from entry to home = 75 movement pips.
//
// Pawn position is one of:
// 'nest' | { track: 0..67 } | { home: 0..6 } | 'home'
//
// Safe squares: 12 total. The 4 colored entries + 8 white safeties spaced
// at +7 and +12 from each entry. Pawns cannot be captured on a safe square,
// EXCEPT a pawn entering the board (leaving the nest with a 5) bops any
// single opponent occupying its entry square.
export const COLORS = ['red', 'blue', 'yellow', 'green'];
export const ENTRY = { red: 0, blue: 17, yellow: 34, green: 51 };
// Home-entry = the LAST outer-track square a pawn occupies before turning
// into its home column. = (entry + 67) mod 68 = (entry - 1 + 68) mod 68.
export const HOME_ENTRY = { red: 67, blue: 16, yellow: 33, green: 50 };
export const TRACK_LEN = 68;
export const HOME_COL_LEN = 7;
export const PAWNS_PER_PLAYER = 4;
const SAFE_SET = new Set([
0, 7, 12,
17, 24, 29,
34, 41, 46,
51, 58, 63,
]);
export function isSafeTrack(idx) {
return SAFE_SET.has(idx);
}
export function createInitialState(playerOrder = COLORS) {
const pawns = {};
for (const c of COLORS) {
pawns[c] = Array.from({ length: PAWNS_PER_PLAYER }, () => ({ loc: 'nest' }));
}
return {
players: [...playerOrder],
pawns,
currentPlayer: playerOrder[0],
dice: null,
movesLeft: [], // remaining die values: 1..6, plus bonus 10/20
consecutiveDoubles: 0,
lastWasDoubles: false, // if true and dice exhausted, same player re-rolls
phase: 'roll', // 'roll' | 'move' | 'game_over'
winner: null,
log: [], // optional, for surfaces — capped
};
}
export function cloneState(state) {
return JSON.parse(JSON.stringify(state));
}
// ─── Dice ──────────────────────────────────────────────────────────────────
export function rollDice(state) {
const d1 = Math.ceil(Math.random() * 6);
const d2 = Math.ceil(Math.random() * 6);
return rollSpecificDice(state, d1, d2);
}
export function rollSpecificDice(state, d1, d2) {
const s = cloneState(state);
s.dice = [d1, d2];
const doubles = d1 === d2;
if (doubles) {
s.consecutiveDoubles += 1;
s.lastWasDoubles = true;
if (s.consecutiveDoubles >= 3) {
// Three-doubles penalty: furthest-from-home pawn of current player
// goes back to the nest; turn ends. No moves played.
applyThreeDoublesPenalty(s);
return s;
}
const allOut = pawnsInNest(s, s.currentPlayer) === 0;
if (allOut) {
// Bonus moves: also use the "back" of each die (1↔6, 2↔5, 3↔4)
s.movesLeft = [d1, d2, 7 - d1, 7 - d2];
} else {
s.movesLeft = [d1, d2];
}
} else {
s.consecutiveDoubles = 0;
s.lastWasDoubles = false;
s.movesLeft = [d1, d2];
}
s.phase = 'move';
return s;
}
function applyThreeDoublesPenalty(s) {
const player = s.currentPlayer;
// Hasbro rule: "the pawn farthest along on its journey to Home" =
// the MOST ADVANCED pawn (smallest remaining distance, but >0).
let bestIdx = -1;
let bestDist = Infinity;
for (let i = 0; i < PAWNS_PER_PLAYER; i++) {
const p = s.pawns[player][i];
if (p.loc === 'nest' || p.loc === 'home') continue;
const dist = pawnDistanceToHome(p, player);
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
}
if (bestIdx >= 0) s.pawns[player][bestIdx] = { loc: 'nest' };
endTurn(s, true);
}
// Mutates state in place — used during sequencing.
export function endTurn(s, fromPenalty = false) {
s.dice = null;
s.movesLeft = [];
s.lastWasDoubles = false;
if (fromPenalty) s.consecutiveDoubles = 0;
const idx = s.players.indexOf(s.currentPlayer);
s.currentPlayer = s.players[(idx + 1) % s.players.length];
s.consecutiveDoubles = 0;
s.phase = 'roll';
return s;
}
// ─── Geometry helpers ──────────────────────────────────────────────────────
// Steps remaining from a pawn's current position to its home circle (>=0).
// nest = 76 (5 to enter + 75 from entry to home).
export function pawnDistanceToHome(pawn, color) {
if (pawn.loc === 'nest') return 76;
if (pawn.loc === 'home') return 0;
if (pawn.home !== undefined) return (HOME_COL_LEN - pawn.home); // home: 0..6 → 7..1 then home → 0
if (pawn.track !== undefined) {
// Distance from track t to home-entry, then +7 (cols) + 1 (home circle) = +8.
const homeEntry = HOME_ENTRY[color];
const d = (homeEntry - pawn.track + TRACK_LEN) % TRACK_LEN;
return d + 8;
}
return 0;
}
function pawnsInNest(s, color) {
return s.pawns[color].filter((p) => p.loc === 'nest').length;
}
function pawnsHome(s, color) {
return s.pawns[color].filter((p) => p.loc === 'home').length;
}
function pawnsOnTrack(s) {
// returns Map<trackIdx, Array<{color, pawnIdx}>>
const m = new Map();
for (const c of COLORS) {
for (let i = 0; i < PAWNS_PER_PLAYER; i++) {
const p = s.pawns[c][i];
if (p.track !== undefined) {
const arr = m.get(p.track) ?? [];
arr.push({ color: c, pawnIdx: i });
m.set(p.track, arr);
}
}
}
return m;
}
// A blockade is 2 same-color pawns on one outer-track square.
// Returns Set of trackIdx that are blockades.
function blockadeSet(s) {
const set = new Set();
const map = pawnsOnTrack(s);
for (const [idx, arr] of map) {
if (arr.length >= 2 && arr.every((p) => p.color === arr[0].color)) {
set.add(idx);
}
}
return set;
}
// Returns occupant of a track square: null | {color, count}.
function trackOccupants(s, idx) {
const arr = [];
for (const c of COLORS) {
for (let i = 0; i < PAWNS_PER_PLAYER; i++) {
if (s.pawns[c][i].track === idx) arr.push({ color: c, pawnIdx: i });
}
}
return arr;
}
// Returns count of pawns of this color in this home-col index.
function homeColOccupants(s, color, hIdx) {
return s.pawns[color].filter((p) => p.home === hIdx).length;
}
// ─── Move generation ───────────────────────────────────────────────────────
// Build the path squares a pawn would traverse moving `steps` from `from`.
// Returns an ORDERED array of positions for each step taken (length = steps).
// The final element is the landing square. Returns null if path is invalid
// (overshoots home).
function projectPath(from, color, steps) {
const path = [];
let pos = from;
for (let k = 0; k < steps; k++) {
if (pos.loc === 'nest') return null; // nest exit handled separately
if (pos === 'home' || pos.loc === 'home') return null;
if (pos.home !== undefined) {
const next = pos.home + 1;
if (next < HOME_COL_LEN) { pos = { home: next }; path.push(pos); }
else if (next === HOME_COL_LEN) { pos = { loc: 'home' }; path.push(pos); }
else return null; // overshoot
} else {
// On outer track
if (pos.track === HOME_ENTRY[color]) {
pos = { home: 0 };
path.push(pos);
} else {
const nextIdx = (pos.track + 1) % TRACK_LEN;
pos = { track: nextIdx };
path.push(pos);
}
}
}
return path;
}
// Check if a movement path is blocked by any blockade (track squares only).
// The first step's "from" is NOT included; we examine every square the pawn
// would *enter*. A blockade on the destination also blocks unless it's the
// final square AND a same-color blockade isn't an issue — for the standard
// rule, you cannot land on or pass any blockade (your own or opponents').
function pathBlocked(path, blockades) {
for (const sq of path) {
if (sq.track !== undefined && blockades.has(sq.track)) return true;
}
return false;
}
// Build a Move object describing a pawn step. dieUsed is the die value
// consumed (1..6, or 10/20 for bonuses).
function makeMove(player, pawnIdx, from, to, dieUsed, hit = null) {
const move = { player, pawnIdx, from, to, dieUsed, hit };
return move;
}
// All legal moves for the current player with currently remaining dice.
// Returns flat array of single-die moves. Sum-of-5 nest entries are also
// included as special moves with `combineDice: [d1, d2]` (dieUsed=5).
export function getValidMoves(s) {
if (s.phase !== 'move') return [];
const player = s.currentPlayer;
const moves = [];
const blockades = blockadeSet(s);
const uniqueDice = [...new Set(s.movesLeft)];
const inNest = s.pawns[player].some((p) => p.loc === 'nest');
// 1. Nest exits with a 5 on a single die
if (inNest && s.movesLeft.includes(5)) {
const entryIdx = ENTRY[player];
const occ = trackOccupants(s, entryIdx);
// Blocked if opponent has 2+ on entry
const oppCount = occ.filter((o) => o.color !== player).length;
const ownCount = occ.filter((o) => o.color === player).length;
const blockedByOppPair = oppCount >= 2;
const blockedByOwnTriple = ownCount >= 2; // can't make a 3-stack with own pawns
if (!blockedByOppPair && !blockedByOwnTriple) {
// Pick the lowest-index pawn still in nest
const pawnIdx = s.pawns[player].findIndex((p) => p.loc === 'nest');
// Bop on entry: if exactly one opponent pawn sits on the entry, capture
let hit = null;
if (oppCount === 1) {
const opp = occ.find((o) => o.color !== player);
hit = { color: opp.color, pawnIdx: opp.pawnIdx, sq: { track: entryIdx } };
}
moves.push(makeMove(player, pawnIdx, { loc: 'nest' }, { track: entryIdx }, 5, hit));
}
}
// 2. Nest exit using BOTH dice summing to 5 (not 5+5 doubles)
if (inNest && s.dice && s.dice[0] + s.dice[1] === 5 && s.dice[0] !== s.dice[1]
&& s.movesLeft.includes(s.dice[0]) && s.movesLeft.includes(s.dice[1])) {
const entryIdx = ENTRY[player];
const occ = trackOccupants(s, entryIdx);
const oppCount = occ.filter((o) => o.color !== player).length;
const ownCount = occ.filter((o) => o.color === player).length;
if (oppCount < 2 && ownCount < 2) {
const pawnIdx = s.pawns[player].findIndex((p) => p.loc === 'nest');
let hit = null;
if (oppCount === 1) {
const opp = occ.find((o) => o.color !== player);
hit = { color: opp.color, pawnIdx: opp.pawnIdx, sq: { track: entryIdx } };
}
const mv = makeMove(player, pawnIdx, { loc: 'nest' }, { track: entryIdx }, 5, hit);
mv.combineDice = [s.dice[0], s.dice[1]];
moves.push(mv);
}
}
// 3. Track / home-column pawn movements for each remaining die
for (let pawnIdx = 0; pawnIdx < PAWNS_PER_PLAYER; pawnIdx++) {
const p = s.pawns[player][pawnIdx];
if (p.loc === 'nest' || p.loc === 'home') continue;
for (const die of uniqueDice) {
const path = projectPath(p, player, die);
if (!path) continue;
if (pathBlocked(path, blockades)) continue;
const dest = path[path.length - 1];
// Validate landing
let hit = null;
if (dest.track !== undefined) {
const occ = trackOccupants(s, dest.track);
const own = occ.filter((o) => o.color === player);
const opp = occ.filter((o) => o.color !== player);
// Cannot land on opponent's blockade — already filtered by pathBlocked.
// Cannot land creating 3-stack of own color.
if (own.length >= 2) continue;
// Capture: exactly one opponent on a non-safe square
if (opp.length === 1 && !isSafeTrack(dest.track)) {
hit = { color: opp[0].color, pawnIdx: opp[0].pawnIdx, sq: { track: dest.track } };
}
// Cannot land on safe square already occupied by opponent (mutual safety)
if (opp.length >= 1 && isSafeTrack(dest.track)) continue;
if (opp.length >= 2) continue;
} else if (dest.home !== undefined) {
// Home column squares are safe; can't share with own pawn (max 1 per square in column).
if (homeColOccupants(s, player, dest.home) >= 1) continue;
}
// Landing in 'home' has no occupancy constraint
moves.push(makeMove(player, pawnIdx, copyLoc(p), dest, die, hit));
}
}
return moves;
}
function copyLoc(p) {
if (p.loc) return { loc: p.loc };
if (p.track !== undefined) return { track: p.track };
if (p.home !== undefined) return { home: p.home };
return p;
}
// ─── Move application ─────────────────────────────────────────────────────
// Apply a single move. Returns a NEW state. Handles capture (+20), home (+10),
// dice consumption, and end-of-turn (with doubles re-roll handling).
export function applyMove(state, move) {
const s = cloneState(state);
const player = move.player;
// Update pawn
s.pawns[player][move.pawnIdx] = locFromMove(move.to);
// Process capture
if (move.hit) {
s.pawns[move.hit.color][move.hit.pawnIdx] = { loc: 'nest' };
s.movesLeft.push(20);
}
// Process home arrival
const arrived = move.to.loc === 'home';
if (arrived) {
s.movesLeft.push(10);
}
// Consume die(s)
if (move.combineDice) {
for (const d of move.combineDice) {
const idx = s.movesLeft.indexOf(d);
if (idx !== -1) s.movesLeft.splice(idx, 1);
}
} else {
const idx = s.movesLeft.indexOf(move.dieUsed);
if (idx !== -1) s.movesLeft.splice(idx, 1);
}
// Win check
if (pawnsHome(s, player) >= PAWNS_PER_PLAYER) {
s.winner = player;
s.phase = 'game_over';
return s;
}
// End-of-turn handling
if (s.movesLeft.length === 0 || !hasAnyMove(s)) {
if (s.lastWasDoubles && s.consecutiveDoubles < 3) {
// Doubles → same player re-rolls
s.dice = null;
s.movesLeft = [];
s.lastWasDoubles = false;
s.phase = 'roll';
} else {
endTurn(s);
}
}
return s;
}
function locFromMove(to) {
if (to.loc === 'home') return { loc: 'home' };
if (to.track !== undefined) return { track: to.track };
if (to.home !== undefined) return { home: to.home };
return to;
}
export function hasAnyMove(s) {
return getValidMoves(s).length > 0;
}
// ─── Helpers exposed for AI / UI ──────────────────────────────────────────
export { pawnsHome, pawnsInNest, blockadeSet, trackOccupants };
// Compute total pip distance for a color (lower = better progress).
export function totalPipsRemaining(s, color) {
let total = 0;
for (const p of s.pawns[color]) total += pawnDistanceToHome(p, color);
return total;
}

View File

@ -14,6 +14,7 @@ import GameRoomScene from './scenes/GameRoomScene.js';
import BackgammonGame from './games/backgammon/BackgammonGame.js'; import BackgammonGame from './games/backgammon/BackgammonGame.js';
import HoldemGame from './games/holdem/HoldemGame.js'; import HoldemGame from './games/holdem/HoldemGame.js';
import BlackjackGame from './games/blackjack/BlackjackGame.js'; import BlackjackGame from './games/blackjack/BlackjackGame.js';
import ParchisiGame from './games/parchisi/ParchisiGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -41,6 +42,7 @@ const config = {
BackgammonGame, BackgammonGame,
HoldemGame, HoldemGame,
BlackjackGame, BlackjackGame,
ParchisiGame,
], ],
}; };

View File

@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame' }; const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -26,6 +26,6 @@ export function getGame(slug) {
// Built-in placeholders so the menu has something to show. // Built-in placeholders so the menu has something to show.
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false }); registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false });
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 2, maxPlayers: 4, multiplayerOnly: false }); registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false }); registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false }); registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });