diff --git a/public/assets/fonts/JuliusSansOne-Regular.ttf b/public/assets/fonts/JuliusSansOne-Regular.ttf new file mode 100644 index 0000000..478c047 Binary files /dev/null and b/public/assets/fonts/JuliusSansOne-Regular.ttf differ diff --git a/public/assets/fonts/Righteous-Regular.ttf b/public/assets/fonts/Righteous-Regular.ttf new file mode 100644 index 0000000..07fc0b4 Binary files /dev/null and b/public/assets/fonts/Righteous-Regular.ttf differ diff --git a/public/assets/images/background-menu.png b/public/assets/images/background-menu.png new file mode 100644 index 0000000..14f0ea1 Binary files /dev/null and b/public/assets/images/background-menu.png differ diff --git a/public/assets/images/main-title.png b/public/assets/images/main-title.png new file mode 100644 index 0000000..e4760da Binary files /dev/null and b/public/assets/images/main-title.png differ diff --git a/public/assets/images/opponents.png b/public/assets/images/opponents.png index 0e07884..dd7a4c2 100644 Binary files a/public/assets/images/opponents.png and b/public/assets/images/opponents.png differ diff --git a/public/assets/images/opponents.psd b/public/assets/images/opponents.psd index 21af91b..ec0d54c 100644 Binary files a/public/assets/images/opponents.psd and b/public/assets/images/opponents.psd differ diff --git a/public/assets/videos/bernie-happy.mp4 b/public/assets/videos/bernie-happy.mp4 new file mode 100644 index 0000000..c355e45 Binary files /dev/null and b/public/assets/videos/bernie-happy.mp4 differ diff --git a/public/assets/videos/bernie-idle.mp4 b/public/assets/videos/bernie-idle.mp4 new file mode 100644 index 0000000..75becf8 Binary files /dev/null and b/public/assets/videos/bernie-idle.mp4 differ diff --git a/public/assets/videos/bernie-upset.mp4 b/public/assets/videos/bernie-upset.mp4 new file mode 100644 index 0000000..800bc09 Binary files /dev/null and b/public/assets/videos/bernie-upset.mp4 differ diff --git a/public/assets/videos/fireball-happy.mp4 b/public/assets/videos/fireball-happy.mp4 new file mode 100644 index 0000000..22bc40e Binary files /dev/null and b/public/assets/videos/fireball-happy.mp4 differ diff --git a/public/assets/videos/fireball-idle.mp4 b/public/assets/videos/fireball-idle.mp4 new file mode 100644 index 0000000..65b77ac Binary files /dev/null and b/public/assets/videos/fireball-idle.mp4 differ diff --git a/public/assets/videos/fireball-upset.mp4 b/public/assets/videos/fireball-upset.mp4 new file mode 100644 index 0000000..1ebc5c4 Binary files /dev/null and b/public/assets/videos/fireball-upset.mp4 differ diff --git a/public/src/games/parchisi/ParchisiAI.js b/public/src/games/parchisi/ParchisiAI.js new file mode 100644 index 0000000..a0c554b --- /dev/null +++ b/public/src/games/parchisi/ParchisiAI.js @@ -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 }; diff --git a/public/src/games/parchisi/ParchisiGame.js b/public/src/games/parchisi/ParchisiGame.js new file mode 100644 index 0000000..0c13195 --- /dev/null +++ b/public/src/games/parchisi/ParchisiGame.js @@ -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); + } +} + + + + diff --git a/public/src/games/parchisi/ParchisiLogic.js b/public/src/games/parchisi/ParchisiLogic.js new file mode 100644 index 0000000..ec52768 --- /dev/null +++ b/public/src/games/parchisi/ParchisiLogic.js @@ -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> + 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; +} diff --git a/public/src/main.js b/public/src/main.js index d485fd1..e9a0696 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -14,6 +14,7 @@ import GameRoomScene from './scenes/GameRoomScene.js'; import BackgammonGame from './games/backgammon/BackgammonGame.js'; import HoldemGame from './games/holdem/HoldemGame.js'; import BlackjackGame from './games/blackjack/BlackjackGame.js'; +import ParchisiGame from './games/parchisi/ParchisiGame.js'; const config = { type: Phaser.AUTO, @@ -41,6 +42,7 @@ const config = { BackgammonGame, HoldemGame, BlackjackGame, + ParchisiGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 18a8b3f..364e421 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -18,7 +18,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/server/multiplayer/gameRegistry.js b/server/multiplayer/gameRegistry.js index e1dc43e..5eb5591 100644 --- a/server/multiplayer/gameRegistry.js +++ b/server/multiplayer/gameRegistry.js @@ -26,6 +26,6 @@ export function getGame(slug) { // 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: '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: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });