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'; import { playSound, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.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: 1 + idx, row: 9 }; // west spoke if (color === 'blue') return { col: 9, row: 17 - idx }; // south spoke if (color === 'yellow') return { col: 17 - idx, row: 9 }; // east spoke if (color === 'green') return { col: 9, row: 1 + idx }; // north spoke throw new Error(`bad color ${color}`); } // Final "home" cell at the center boundary per color. function homeFinalXY(color) { if (color === 'red') return { col: 8, row: 9 }; // west spoke if (color === 'blue') return { col: 9, row: 10 }; // south spoke if (color === 'yellow') return { col: 10, row: 9 }; // east spoke if (color === 'green') return { col: 9, row: 8 }; // north spoke } 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; this.turnIndicatorMoveTween = null; this.bonusChip20 = null; this._bonusChip20Visible = false; } create() { new MusicPlayer(this, this.cache.json.get('music').tracks); this.buildPlayfield(); this.buildBoard(); this.buildDice(); this.buildUI(); this.buildBonusChip(); this.buildPlayerCards(); this.buildTurnIndicator(); this.buildPawns(); this.initGame(); this.buildDismissHandler(); } 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: '"Julius Sans One"', 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 - 80; 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) { playSound(this, SFX.DICE_ROLL); 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() { this.updateBonusChip(); 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 + 80; this.rollBtn = new Button(this, xLeft, yRoll, 'Roll', () => this.onRollClick(), { width: 110, height: 44, fontSize: 22, }); this.rollBtn.setDepth(DEPTH.ui); new Button(this, 80, GAME_HEIGHT - 70, 'New', () => this.initGame(), { variant: 'ghost', width: 110, height: 40, fontSize: 18, }).setDepth(DEPTH.ui); new Button(this, 80, GAME_HEIGHT - 25, '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: '"Julius Sans One"', fontSize: '22px', color: UI.textHex, }).setOrigin(0.5).setDepth(DEPTH.ui); } buildBonusChip() { const x = ORIGIN_X - 155; const y = GAME_HEIGHT / 2 - 40; const R = 26; const gfx = this.add.graphics(); gfx.fillStyle(0xffffff, 1); gfx.fillCircle(0, 0, R); gfx.lineStyle(3, 0x222222, 1); gfx.strokeCircle(0, 0, R); const label = this.add.text(0, 0, '20', { fontFamily: '"Julius Sans One"', fontSize: '20px', fontStyle: 'bold', color: '#111111', }).setOrigin(0.5); this.bonusChip20 = this.add.container(x, y, [gfx, label]) .setDepth(DEPTH.ui + 3) .setAlpha(0) .setScale(0.3); } updateBonusChip() { const should = !!(this.gs?.movesLeft?.includes(20)); if (should === this._bonusChip20Visible) return; this._bonusChip20Visible = should; this.tweens.killTweensOf(this.bonusChip20); if (should) { this.tweens.add({ targets: this.bonusChip20, alpha: 1, scale: 1, duration: 250, ease: 'Back.easeOut', }); } else { this.tweens.add({ targets: this.bonusChip20, alpha: 0, scale: 0.3, duration: 180, ease: 'Cubic.easeIn', }); } } 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: '"Julius Sans One"', 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: '"Julius Sans One"', 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, onArrive = null) { const { x, y, side } = this._indicatorPos(color); // Stop both running tweens before starting new ones if (this.turnIndicatorPulseTween) { this.turnIndicatorPulseTween.stop(); this.turnIndicatorPulseTween = null; } if (this.turnIndicatorMoveTween) { this.turnIndicatorMoveTween.stop(); this.turnIndicatorMoveTween = null; } this.turnIndicator.setScale(1); if (immediately || this.turnIndicator.alpha === 0) { this._drawTurnTriangle(side); this.turnIndicator.setPosition(x, y).setAlpha(1); this._startIndicatorPulse(); onArrive?.(); return; } this.turnIndicatorMoveTween = this.tweens.add({ targets: this.turnIndicator, x, y, duration: 400, ease: 'Cubic.easeInOut', onComplete: () => { this.turnIndicatorMoveTween = null; this._drawTurnTriangle(side); this._startIndicatorPulse(); onArrive?.(); }, }); } // ── 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]); } buildDismissHandler() { this.input.on('pointerdown', (_ptr, gameObjects) => { if (this.selectedPawnIdx === null || this.animating) return; const hitRelevant = gameObjects.some((o) => this.highlightObjs.includes(o) || PCOLORS.some((c) => this.pawnObjs[c]?.includes(o)) ); if (!hitRelevant) { this.clearHighlights(); this.showMovablePawnHints(); } }); } // ── 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'); this.showMovablePawnHints(); } }); } 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(); this.showMovablePawnHints(); return; } if (this.gs.phase === 'roll') { this.afterTurn(); } else { // AI continues this.time.delayedCall(450, () => this.runAITurnMoves()); } } afterTurn() { this.updateButtons(); if (this.gs.currentPlayer === 'red') { this.moveTurnIndicator('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`); // Start AI dice roll only after the indicator has finished travelling this.moveTurnIndicator(this.gs.currentPlayer, false, () => { this.time.delayedCall(250, () => 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; } this.afterTurn(); return; } this.executeMove(moves[i], () => { this.time.delayedCall(280, () => this.playAIMoves(moves, i + 1)); }); } // ── Move execution + animation ──────────────────────────────────────────── executeMove(move, onComplete) { playSound(this, SFX.PIECE_CLICK); 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; } showMovablePawnHints() { const moves = getValidMoves(this.gs); const movable = [...new Set(moves.map((m) => m.pawnIdx))]; for (const pawnIdx of movable) { const obj = this.pawnObjs['red'][pawnIdx]; const px = obj.x; const py = obj.y; // Static glow ring — pulses alpha const glow = this.add.graphics().setDepth(DEPTH.highlight); glow.lineStyle(2.5, 0x00e5b0, 0.9); glow.strokeCircle(px, py, PAWN_R + 6); this.tweens.add({ targets: glow, alpha: { from: 0.9, to: 0.25 }, duration: 550, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); // Expanding ripple ring const ripple = this.add.graphics().setDepth(DEPTH.highlight); ripple.setPosition(px, py); ripple.lineStyle(2, 0x00e5b0, 0.75); ripple.strokeCircle(0, 0, PAWN_R + 6); this.tweens.add({ targets: ripple, scaleX: 2.0, scaleY: 2.0, alpha: 0, duration: 950, repeat: -1, ease: 'Cubic.easeOut' }); this.highlightObjs.push(glow, ripple); } } // ── 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: '"Julius Sans One"', 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); } }