fertig-classic-games/public/src/games/backgammon/BackgammonGame.js

914 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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