914 lines
32 KiB
JavaScript
914 lines
32 KiB
JavaScript
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);
|
||
});
|
||
}
|
||
}
|