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:
parent
1f1897c8fe
commit
d206cf6e5b
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.
|
|
@ -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 };
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue