import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } 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, endTurn, hasAnyMove, computePipCount, } from './BackgammonLogic.js'; import { chooseMoves } from './BackgammonAI.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; // ── Layout constants ────────────────────────────────────────────────────────── const BX = 300; // board outer left const BY = 40; // board outer top const BW = 1480; // board outer width const BH = 980; // board outer height const BORDER = 28; // wood border thickness const BAR_W = 72; // center bar width const BEAR_W = 110; // right bear-off column width const FX = BX + BORDER; // felt left const FY = BY + BORDER; // felt top const FW = BW - BORDER * 2; const FH = BH - BORDER * 2; const HALF_W = (FW - BAR_W - BEAR_W) / 2; // ~601 const PW = HALF_W / 6; // point width ~100 const PH = FH * 0.43; // triangle height ~400 const CR = PW * 0.41; // checker radius ~41 const BAR_X = FX + HALF_W; // bar left edge const BEAR_X = FX + HALF_W * 2 + BAR_W; // bear-off left edge // ───────────────────────────────────────────────────────────────────────────── const DEPTH = { board: 0, checker: 10, highlight: 20, movingChecker: 30, dice: 40, ui: 50, banner: 60 }; const C = { wood: 0x2c1a0e, woodLight: 0x5c3a1e, felt: 0x0d3b1a, feltLight: 0x124a22, triAmber: 0xc47c1a, triRed: 0x7c1a1a, barWood: 0x241508, wFill: 0xd4c5a0, wRing: 0xf0e8d0, bFill: 0x1a1a2e, bRing: 0x3a3a4e, gold: 0xffd700, dieIvory: 0xf0e8d0, diePip: 0x1a1a1a, }; export default class BackgammonGame extends Phaser.Scene { constructor() { super('Backgammon'); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.playfield = data.playfield ?? null; this.gs = null; // game state this.animating = false; this.selectedFrom = null; this.highlightObjs = []; this.checkerObjs = []; // [{ from, stackPos, color, container }] this.diceContainers = []; this.diceGraphics = []; this.rollBtn = null; this.turnText = null; this.statusText = null; this.pipWhiteText = null; this.pipBlackText = null; this.opponentPortrait = null; } create() { new MusicPlayer(this, this.cache.json.get('music').tracks); this.buildParticleTexture(); this.buildPlayfield(); this.buildBoard(); this.buildBearOffArea(); this.buildDice(); this.buildUI(); this.buildPlayerCards(); this.initGame(); } // ── Board Construction ────────────────────────────────────────────────────── 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.board - 1); } 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.board - 1); } } buildParticleTexture() { const g = this.make.graphics({ x: 0, y: 0, add: false }); g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5); g.generateTexture('bgParticle', 10, 10); g.destroy(); } buildBoard() { const g = this.add.graphics().setDepth(DEPTH.board); // Outer wood border g.fillStyle(C.wood, 1); g.fillRoundedRect(BX, BY, BW, BH, 8); g.lineStyle(3, C.woodLight, 1); g.strokeRoundedRect(BX + 4, BY + 4, BW - 8, BH - 8, 6); g.lineStyle(1, 0x8b5c2a, 0.5); g.strokeRoundedRect(BX + 2, BY + 2, BW - 4, BH - 4, 7); // Felt surface g.fillStyle(C.felt, 1); g.fillRect(FX, FY, FW, FH); // Subtle felt texture lines g.lineStyle(1, C.feltLight, 0.12); for (let y = FY + 20; y < FY + FH; y += 30) { g.lineBetween(FX, y, FX + FW, y); } // Center bar g.fillStyle(C.barWood, 1); g.fillRect(BAR_X, FY, BAR_W, FH); g.lineStyle(2, C.woodLight, 0.7); g.strokeRect(BAR_X, FY, BAR_W, FH); // 24 triangles for (let idx = 0; idx < 24; idx++) { const color = idx % 2 === 0 ? C.triAmber : C.triRed; g.fillStyle(color, 0.9); const { ax, bx, tipY, baseY } = this.triangleCoords(idx); g.fillTriangle(ax, baseY, bx, baseY, (ax + bx) / 2, tipY); // Subtle triangle outline g.lineStyle(1, 0x000000, 0.2); g.strokeTriangle(ax, baseY, bx, baseY, (ax + bx) / 2, tipY); } // Point number labels for (let idx = 0; idx < 24; idx++) { const label = String(idx + 1); const { ax, bx } = this.triangleCoords(idx); const cx = (ax + bx) / 2; const isBottom = idx < 12; const ly = isBottom ? FY + FH + 14 : FY - 14; this.add.text(cx, ly, label, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.board); } // Outer board decorative corner inlays g.fillStyle(C.woodLight, 0.3); g.fillRect(BX, BY, 28, 28); g.fillRect(BX + BW - 28, BY, 28, 28); g.fillRect(BX, BY + BH - 28, 28, 28); g.fillRect(BX + BW - 28, BY + BH - 28, 28, 28); } buildBearOffArea() { const g = this.add.graphics().setDepth(DEPTH.board); const midY = FY + FH / 2; // Background panel g.fillStyle(C.barWood, 1); g.fillRect(BEAR_X, FY, BEAR_W, FH); g.lineStyle(2, C.woodLight, 0.6); g.strokeRect(BEAR_X, FY, BEAR_W, FH); // Divider line g.lineStyle(1, C.woodLight, 0.4); g.lineBetween(BEAR_X, midY, BEAR_X + BEAR_W, midY); // Labels this.add.text(BEAR_X + BEAR_W / 2, FY + 18, 'OFF', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.board); this.add.text(BEAR_X + BEAR_W / 2, FY + FH - 18, 'OFF', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.board); // Pip count labels this.pipBlackText = this.add.text(BEAR_X + BEAR_W / 2, FY + FH / 4, '0', { fontFamily: '"Julius Sans One"', fontSize: '28px', color: '#3a3a4e', }).setOrigin(0.5).setDepth(DEPTH.ui); this.pipWhiteText = this.add.text(BEAR_X + BEAR_W / 2, FY + 3 * FH / 4, '0', { fontFamily: '"Julius Sans One"', fontSize: '28px', color: '#d4c5a0', }).setOrigin(0.5).setDepth(DEPTH.ui); } buildDice() { const diceY = FY + FH / 2; const barCX = BAR_X + BAR_W / 2; for (let i = 0; i < 2; i++) { const dy = diceY + (i === 0 ? -50 : 50); const g = this.add.graphics(); const container = this.add.container(barCX, dy).setDepth(DEPTH.dice); container.add(g); this.diceContainers.push(container); this.diceGraphics.push(g); this.renderDieFace(i, 1); container.setAlpha(0.2); } } renderDieFace(dieIndex, value) { const g = this.diceGraphics[dieIndex]; const s = 36; // half-size g.clear(); // Face g.fillStyle(C.dieIvory, 1); g.fillRoundedRect(-s, -s, s * 2, s * 2, 8); g.lineStyle(2, C.wood, 1); g.strokeRoundedRect(-s, -s, s * 2, s * 2, 8); // Pip positions (normalized -1 to 1 mapped to ±22px) const pipLayouts = { 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]], }; const pips = pipLayouts[value] ?? pipLayouts[1]; g.fillStyle(C.diePip, 1); for (const [px, py] of pips) { g.fillCircle(px * 22, py * 22, 6); } } buildUI() { const cx = BX + BW / 2; // Status message (also serves as turn label at bottom) this.turnText = this.add.text(cx, BY + BH + 18, '', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.ui); this.statusText = this.add.text(cx, BY + BH + 46, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.ui); // Roll dice button — inside bar area const barCX = BAR_X + BAR_W / 2; this.rollBtn = new Button(this, barCX, FY + FH / 2 + 130, 'Roll', () => this.onRollClick(), { width: 62, height: 36, fontSize: 18, }); this.rollBtn.setDepth(DEPTH.ui); // Leave button new Button(this, BX - 64, BY + BH / 2, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 110, height: 44, fontSize: 20, }).setDepth(DEPTH.ui); // New game button new Button(this, BX - 64, BY + BH / 2 + 60, 'New', () => this.initGame(), { variant: 'ghost', width: 110, height: 44, fontSize: 20, }).setDepth(DEPTH.ui); } // ── Player / Opponent Cards ───────────────────────────────────────────────── buildPlayerCards() { const opp = this.opponents[0]; const r = 80; const depth = DEPTH.ui; const avatarX = BX / 2; // centered in the left strip // Opponent avatar (upper-left) const oppAY = FY + r + 20; // Decorative wood-tone outer ring matching the board rail this.add.circle(avatarX, oppAY, r + 5, C.barWood).setDepth(depth); this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1); this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 240 }, align: 'center', }).setOrigin(0.5, 0).setDepth(depth + 2); // Player avatar (lower-left) const plrAY = FY + FH - r - 20; this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth); createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Backgammon'); this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 240 }, align: 'center', }).setOrigin(0.5, 1).setDepth(depth + 2); } playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); } // ── Game State ────────────────────────────────────────────────────────────── initGame() { this.clearCheckers(); this.clearHighlights(); this.animating = false; this.selectedFrom = null; this.gs = createInitialState(); this.renderAll(); this.setStatus(''); this.showTurnBanner('Your Turn'); } renderAll() { this.clearCheckers(); this.renderCheckers(); this.renderBarCheckers(); this.renderBorneOff(); this.updateDiceDisplay(); this.updatePipLabels(); this.updateButtonStates(); } clearCheckers() { for (const obj of this.checkerObjs) obj.container.destroy(); this.checkerObjs = []; } renderCheckers() { for (let idx = 0; idx < 24; idx++) { const pt = this.gs.points[idx]; if (!pt.color || pt.count === 0) continue; const max = Math.min(pt.count, 5); for (let s = 0; s < max; s++) { const pos = this.checkerScreenPos(idx, s); const c = this.makeChecker(pt.color, pos.x, pos.y); c.setDepth(DEPTH.checker); c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, CR), hitAreaCallback: Phaser.Geom.Circle.Contains }); c.on('pointerdown', () => this.onCheckerClick(idx)); this.checkerObjs.push({ from: idx, stackPos: s, color: pt.color, container: c }); } // Stack count badge if (pt.count > 5) { const pos = this.checkerScreenPos(idx, 4); this.add.text(pos.x, pos.y, String(pt.count), { fontFamily: '"Julius Sans One"', fontSize: '22px', color: pt.color === 'white' ? '#2c1a0e' : '#f0e8d0', }).setOrigin(0.5).setDepth(DEPTH.checker + 1); } } } renderBarCheckers() { const barCX = BAR_X + BAR_W / 2; for (const color of ['white', 'black']) { const count = this.gs.bar[color]; const baseY = color === 'white' ? FY + FH * 0.72 : FY + FH * 0.28; for (let s = 0; s < Math.min(count, 4); s++) { const dy = color === 'white' ? s * (CR * 2 + 2) : -(s * (CR * 2 + 2)); const c = this.makeChecker(color, barCX, baseY + dy); c.setDepth(DEPTH.checker); if (color === this.gs.currentPlayer) { c.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Circle(0, 0, CR), hitAreaCallback: Phaser.Geom.Circle.Contains }); c.on('pointerdown', () => this.onCheckerClick('bar')); } this.checkerObjs.push({ from: 'bar', stackPos: s, color, container: c }); } if (count > 4) { const dy = color === 'white' ? 3 * (CR * 2 + 2) : -(3 * (CR * 2 + 2)); this.add.text(barCX, baseY + dy, `×${count}`, { fontFamily: '"Julius Sans One"', fontSize: '20px', color: color === 'white' ? COLORS.textHex : COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.checker + 1); } } } renderBorneOff() { const cx = BEAR_X + BEAR_W / 2; for (const color of ['white', 'black']) { const count = this.gs.borneOff[color]; const baseY = color === 'white' ? FY + FH * 0.82 : FY + FH * 0.18; const rows = Math.min(count, 15); const colW = 18, rowH = 18; for (let i = 0; i < rows; i++) { const col = i % 5, row = Math.floor(i / 5); const dx = (col - 2) * colW; const dy = color === 'white' ? -row * rowH : row * rowH; const g2 = this.add.graphics().setDepth(DEPTH.checker); g2.fillStyle(color === 'white' ? C.wRing : C.bRing, 1); g2.fillCircle(cx + dx, baseY + dy, 7); const c2 = this.add.container(0, 0, [g2]); this.checkerObjs.push({ from: 'off', stackPos: i, color, container: c2 }); } } } makeChecker(color, x, y) { const g = this.add.graphics(); // Shadow g.fillStyle(0x000000, 0.25); g.fillCircle(3, 4, CR); // Outer ring g.fillStyle(color === 'white' ? C.wRing : C.bRing, 1); g.fillCircle(0, 0, CR); // Inner fill g.fillStyle(color === 'white' ? C.wFill : C.bFill, 1); g.fillCircle(0, 0, CR - 5); // Highlight arc (top-left sheen) g.lineStyle(3, color === 'white' ? 0xffffff : 0x6a6a8e, 0.55); g.beginPath(); g.arc(0, 0, CR - 8, Phaser.Math.DegToRad(200), Phaser.Math.DegToRad(320)); g.strokePath(); const container = this.add.container(x, y, [g]); return container; } // ── Screen Coordinate Helpers ─────────────────────────────────────────────── // Returns { ax, bx, tipY, baseY } for triangle drawing triangleCoords(idx) { const isBottom = idx < 12; let col; if (idx < 6) col = 5 - idx; // bottom-right: idx 0 rightmost (point 1), idx 5 leftmost (point 6) else if (idx < 12) col = 11 - idx; // bottom-left: idx 11 leftmost (point 12), idx 6 rightmost (point 7) else if (idx < 18) col = idx - 12; // top-left: idx 12 leftmost (point 13), idx 17 rightmost (point 18) else col = idx - 18; // top-right: idx 18 leftmost (point 19), idx 23 rightmost (point 24) const isRight = idx < 6 || idx >= 18; const halfStart = isRight ? BAR_X + BAR_W : FX; const ax = halfStart + col * PW; const bx = ax + PW; const baseY = isBottom ? FY + FH : FY; const tipY = isBottom ? FY + FH - PH : FY + PH; return { ax, bx, tipY, baseY }; } // Center x of a point pointCX(idx) { const { ax, bx } = this.triangleCoords(idx); return (ax + bx) / 2; } // Screen position of nth checker on a point (stack 0 = closest to board edge) checkerScreenPos(idx, stackPos) { const isBottom = idx < 12; const cx = this.pointCX(idx); const edge = isBottom ? FY + FH : FY; const dir = isBottom ? -1 : 1; const visualStack = Math.min(stackPos, 4); const y = edge + dir * (CR + visualStack * CR * 2); return { x: cx, y }; } barScreenPos(color) { return { x: BAR_X + BAR_W / 2, y: color === 'white' ? FY + FH * 0.72 : FY + FH * 0.28, }; } bearOffScreenPos(color) { return { x: BEAR_X + BEAR_W / 2, y: color === 'white' ? FY + FH * 0.82 : FY + FH * 0.18, }; } // ── Interaction ───────────────────────────────────────────────────────────── onCheckerClick(fromIdx) { if (this.animating) return; if (this.gs.phase !== 'move') return; if (this.gs.currentPlayer !== 'white') return; // If bar has white checkers, only bar selection is valid if (this.gs.bar.white > 0 && fromIdx !== 'bar') return; const pt = fromIdx === 'bar' ? { color: 'white', count: this.gs.bar.white } : this.gs.points[fromIdx]; if (pt.color !== 'white' || pt.count === 0) return; const validMoves = getValidMoves(this.gs).filter((m) => m.from === fromIdx); if (validMoves.length === 0) { this.flashNoMove(fromIdx); return; } this.clearHighlights(); this.selectedFrom = fromIdx; this.pulseSelectedChecker(fromIdx); this.showHighlights(validMoves); } pulseSelectedChecker(fromIdx) { const obj = this.checkerObjs.find( (o) => o.from === fromIdx && o.color === 'white' && o.stackPos === 0 ); if (!obj) return; // Gold ring overlay const ring = this.add.graphics().setDepth(DEPTH.highlight); ring.lineStyle(4, C.gold, 1); ring.strokeCircle(obj.container.x, obj.container.y, CR + 4); this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.3 }, duration: 500, yoyo: true, repeat: -1 }); this.highlightObjs.push(ring); } showHighlights(moves) { const destinations = [...new Set(moves.map((m) => m.to))]; for (const dest of destinations) { let hx, hy; if (dest === 'off') { const pos = this.bearOffScreenPos('white'); hx = pos.x; hy = pos.y; } else { const destStack = this.stackHeight(dest); hx = this.pointCX(dest); hy = this.checkerScreenPos(dest, destStack).y; } // Pulsing dot const dot = this.add.graphics().setDepth(DEPTH.highlight); dot.fillStyle(COLORS.accent, 0.85); dot.fillCircle(hx, hy, 20); dot.lineStyle(3, 0xffffff, 0.4); dot.strokeCircle(hx, hy, 20); this.tweens.add({ targets: dot, alpha: { from: 0.85, to: 0.2 }, duration: 600, yoyo: true, repeat: -1 }); // Invisible hit zone const zone = this.add.zone(hx, hy, PW, CR * 4) .setInteractive({ useHandCursor: true }) .setDepth(DEPTH.highlight); zone.on('pointerdown', () => this.onDestinationClick(dest)); this.highlightObjs.push(dot, zone); } } clearHighlights() { for (const obj of this.highlightObjs) obj.destroy(); this.highlightObjs = []; this.selectedFrom = null; } onDestinationClick(toIdx) { if (this.animating || this.selectedFrom === null) return; const validMoves = getValidMoves(this.gs).filter( (m) => m.from === this.selectedFrom && m.to === toIdx ); if (validMoves.length === 0) return; // Prefer smaller die to preserve flexibility const move = validMoves.sort((a, b) => a.dieUsed - b.dieUsed)[0]; this.clearHighlights(); this.executePlayerMove(move); } flashNoMove(fromIdx) { const obj = this.checkerObjs.find((o) => o.from === fromIdx && o.color === 'white'); if (!obj) return; this.tweens.add({ targets: obj.container, alpha: { from: 1, to: 0.2 }, duration: 120, yoyo: true, repeat: 3 }); } // ── Move Execution ────────────────────────────────────────────────────────── executePlayerMove(move) { this.animating = true; this.updateButtonStates(); // Find which container to animate (topmost checker of that point) const obj = this.checkerObjs.filter((o) => o.from === move.from && o.color === 'white') .sort((a, b) => b.stackPos - a.stackPos)[0]; const fromPos = move.from === 'bar' ? this.barScreenPos('white') : this.checkerScreenPos(move.from, obj ? obj.stackPos : 0); const toPos = move.to === 'off' ? this.bearOffScreenPos('white') : this.checkerScreenPos(move.to, this.stackHeight(move.to)); const animContainer = obj ? obj.container : null; if (animContainer) animContainer.setDepth(DEPTH.movingChecker); this.animateArc(animContainer, fromPos, toPos, () => { this.gs = applyMove(this.gs, move); this.renderAll(); this.animating = false; this.updateButtonStates(); if (move.hit) this.playOpponentEmotion('upset'); else if (move.to === 'off') this.playOpponentEmotion('upset'); if (this.gs.phase === 'game_over') { this.onGameOver(); } else if (this.gs.currentPlayer === 'black' && this.gs.phase === 'roll') { this.time.delayedCall(800, () => this.startAITurn()); } }); // Animate hit separately if needed if (move.hit) { const hitObj = this.checkerObjs.find((o) => o.from === move.to && o.color === 'black'); if (hitObj) { const barPos = this.barScreenPos('black'); this.animateSlide(hitObj.container, barPos, 250); } } } stackHeight(pointIdx) { if (pointIdx === 'off' || pointIdx === 'bar') return 0; const pt = this.gs.points[pointIdx]; return pt.color !== null ? pt.count : 0; } // ── Dice ──────────────────────────────────────────────────────────────────── onRollClick() { if (this.animating || this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'white') return; this.rollBtn.setEnabled(false); this.animating = true; const finalState = rollDice(this.gs); const [d1, d2] = finalState.dice; this.animateDiceRoll([d1, d2], () => { this.gs = finalState; this.updateDiceDisplay(); this.animating = false; if (d1 === d2) this.playOpponentEmotion('upset'); if (!hasAnyMove(this.gs)) { this.setStatus('No moves available — turn passed'); this.time.delayedCall(1800, () => { this.gs = endTurn(this.gs); this.setStatus(''); this.renderAll(); this.time.delayedCall(600, () => this.startAITurn()); }); } else { this.updateButtonStates(); } }); } animateDiceRoll(finalValues, onComplete) { playSound(this, SFX.DICE_ROLL); this.diceContainers.forEach((c) => c.setAlpha(1)); let elapsed = 0; const totalMs = 700; const phases = [{ until: 400, interval: 60 }, { until: 580, interval: 90 }, { until: totalMs, interval: 130 }]; const tick = () => { const phaseInterval = phases.find((p) => elapsed < p.until)?.interval ?? 60; for (let i = 0; i < 2; i++) { this.renderDieFace(i, Phaser.Math.Between(1, 6)); } elapsed += phaseInterval; if (elapsed < totalMs) { this.time.delayedCall(phaseInterval, tick); } else { this.renderDieFace(0, finalValues[0]); this.renderDieFace(1, finalValues[1]); // Landing pulse 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.2)); return; } this.diceContainers.forEach((c) => c.setAlpha(1)); this.renderDieFace(0, this.gs.dice[0]); this.renderDieFace(1, this.gs.dice[1]); // Dim used dice const used = [true, true]; const remaining = [...this.gs.movesLeft]; for (let i = 0; i < 2; i++) { const idx = remaining.indexOf(this.gs.dice[i]); if (idx !== -1) { used[i] = false; remaining.splice(idx, 1); } } this.diceContainers.forEach((c, i) => c.setAlpha(used[i] ? 0.3 : 1)); } // ── AI Turn ───────────────────────────────────────────────────────────────── startAITurn() { if (this.gs.phase !== 'roll' || this.gs.currentPlayer !== 'black') return; const opponentName = this.opponents[0]?.name ?? 'Opponent'; this.showTurnBanner(`${opponentName}'s Turn`); this.animating = true; this.updateButtonStates(); this.time.delayedCall(1000, () => { const finalState = rollDice(this.gs); const [d1, d2] = finalState.dice; this.animateDiceRoll([d1, d2], () => { this.gs = finalState; this.updateDiceDisplay(); if (d1 === d2) this.playOpponentEmotion('happy'); if (!hasAnyMove(this.gs)) { this.setStatus(`${opponentName} has no moves — turn passed`); this.time.delayedCall(1500, () => { this.gs = endTurn(this.gs); this.setStatus(''); this.animating = false; this.renderAll(); this.showTurnBanner('Your Turn'); }); return; } const moves = chooseMoves(this.gs); this.executeAIMovesSequentially(moves, 0, () => { this.animating = false; if (this.gs.phase === 'game_over') { this.onGameOver(); } else { this.renderAll(); this.showTurnBanner('Your Turn'); } }); }); }); } executeAIMovesSequentially(moves, index, onAllDone) { if (index >= moves.length || this.gs.phase === 'game_over') { onAllDone(); return; } const move = moves[index]; const fromPos = move.from === 'bar' ? this.barScreenPos('black') : this.checkerScreenPos(move.from, Math.max(0, this.gs.points[move.from].count - 1)); const toPos = move.to === 'off' ? this.bearOffScreenPos('black') : this.checkerScreenPos(move.to, this.stackHeight(move.to)); // Spawn a temporary checker for animation const tempChecker = this.makeChecker('black', fromPos.x, fromPos.y); tempChecker.setDepth(DEPTH.movingChecker); this.animateArc(tempChecker, fromPos, toPos, () => { tempChecker.destroy(); this.gs = applyMove(this.gs, move); this.renderAll(); if (move.hit) this.playOpponentEmotion('happy'); else if (move.to === 'off') this.playOpponentEmotion('happy'); this.time.delayedCall(350, () => { this.executeAIMovesSequentially(moves, index + 1, onAllDone); }); }); } // ── Animations ────────────────────────────────────────────────────────────── animateArc(container, from, to, onComplete) { if (!container) { onComplete(); return; } const midX = (from.x + to.x) / 2; const midY = Math.min(from.y, to.y) - 130; const prog = { t: 0 }; this.tweens.add({ targets: prog, t: 1, duration: 400, ease: 'Cubic.easeInOut', onUpdate: () => { const t = prog.t; const inv = 1 - t; container.x = inv * inv * from.x + 2 * inv * t * midX + t * t * to.x; container.y = inv * inv * from.y + 2 * inv * t * midY + t * t * to.y; }, onComplete: () => { container.x = to.x; container.y = to.y; // Squash-and-stretch on landing this.tweens.add({ targets: container, scaleX: 1.3, scaleY: 0.7, duration: 60, yoyo: true, ease: 'Quad.easeOut', onComplete: () => { playSound(this, SFX.PIECE_CLICK); onComplete(); }}); }, }); } animateSlide(container, to, duration) { this.tweens.add({ targets: container, x: to.x, y: to.y, duration, ease: 'Quad.easeIn', }); } showTurnBanner(text) { const cx = BX + BW / 2; const banner = this.add.text(cx, BY - 80, text, { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex, backgroundColor: '#111923ee', padding: { x: 28, y: 12 }, }).setOrigin(0.5).setDepth(DEPTH.banner); this.tweens.add({ targets: banner, y: BY - 22, duration: 320, ease: 'Back.easeOut', onComplete: () => { this.time.delayedCall(1200, () => { this.tweens.add({ targets: banner, y: BY - 80, alpha: 0, duration: 220, onComplete: () => banner.destroy() }); }); }, }); } // ── UI Updates ─────────────────────────────────────────────────────────────── updateButtonStates() { const canRoll = !this.animating && this.gs.phase === 'roll' && this.gs.currentPlayer === 'white'; this.rollBtn?.setEnabled(canRoll); this.turnText?.setText(this.gs.currentPlayer === 'white' ? 'Your Turn' : 'Opponent\'s Turn'); } updatePipLabels() { const wPip = computePipCount(this.gs, 'white'); const bPip = computePipCount(this.gs, 'black'); this.pipWhiteText?.setText(String(wPip)); this.pipBlackText?.setText(String(bPip)); } setStatus(msg) { this.statusText?.setText(msg); } // ── Win / Game Over ────────────────────────────────────────────────────────── onGameOver() { const winner = this.gs.winner; const isHuman = winner === 'white'; const opponentName = this.opponents[0]?.name ?? 'Opponent'; this.playOpponentEmotion(isHuman ? 'upset' : 'happy'); // Particle burst if (isHuman) { const emitter = this.add.particles(BX + BW / 2, BY + BH / 2, 'bgParticle', { speed: { min: 150, max: 500 }, lifespan: 1400, scale: { start: 1.5, end: 0 }, alpha: { start: 1, end: 0 }, quantity: 5, frequency: 25, tint: [C.gold, 0xffffff, COLORS.accent], angle: { min: 0, max: 360 }, }).setDepth(DEPTH.banner); this.time.delayedCall(2000, () => emitter.destroy()); } this.time.delayedCall(500, () => { const msg = isHuman ? '🎉 You Win!\nBear off all 15 checkers!' : `${opponentName} wins this time.\nBetter luck next game!`; const overlay = this.add.rectangle(BX + BW / 2, BY + BH / 2, 700, 300, 0x0a0e14, 0.9) .setStrokeStyle(3, COLORS.accent) .setDepth(DEPTH.banner); const txt = this.add.text(BX + BW / 2, BY + BH / 2 - 40, msg, { fontFamily: '"Julius Sans One"', fontSize: '32px', color: isHuman ? '#ffd700' : COLORS.textHex, align: 'center', }).setOrigin(0.5).setDepth(DEPTH.banner + 1); new Button(this, BX + BW / 2 - 90, BY + BH / 2 + 80, 'Play Again', () => { overlay.destroy(); txt.destroy(); this.initGame(); }, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1); new Button(this, BX + BW / 2 + 90, BY + BH / 2 + 80, 'Leave', () => { this.scene.start('GameMenu'); }, { variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1); }); } }