fertig-classic-games/public/src/games/parchisi/ParchisiGame.js

957 lines
34 KiB
JavaScript

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);
}
}