feat: add Checkers and Chess games with AI opponents and improve Dominion UX
- Implement Checkers and Chess with complete Phaser UI, pure logic modules, and alpha-beta minimax AI opponents featuring a 1-5 skill model. - Extend opponent skill selector to support Checkers and Chess. - Register new games in frontend routing and backend registry. - Improve Dominion game UX: add persistent phase dials and turn arrow indicators, add confirmation prompts for ending actions/turns, and refine card animation timings. - Update Dominion card asset files.
This commit is contained in:
parent
97d748b2f5
commit
71d279c835
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 4.7 MiB |
|
|
@ -0,0 +1,127 @@
|
|||
// Checkers AI — alpha-beta minimax with a Nerts-style 1..5 skill model.
|
||||
//
|
||||
// Skill controls four axes (mirrors NertsAI's delay/miss/quality idea):
|
||||
// • depth — search depth in full turns (deeper = stronger)
|
||||
// • blunder — chance to ignore the best move and play a random legal one
|
||||
// • noise — random value added to root move scores (flattens decisions)
|
||||
// • delay — "thinking" pause (ms range) before the move, for natural pacing
|
||||
// The scene asks for ONE step at a time; multi-jumps come back across ticks
|
||||
// because applyMove keeps the same side to move until the chain ends.
|
||||
|
||||
import {
|
||||
getValidMoves, applyMove, expandTurns, pieceCounts, SIZE,
|
||||
} from './CheckersLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { depth: 1, blunder: 0.45, noise: 90, delay: [900, 1500] },
|
||||
2: { depth: 2, blunder: 0.28, noise: 55, delay: [800, 1300] },
|
||||
3: { depth: 3, blunder: 0.15, noise: 30, delay: [650, 1100] },
|
||||
4: { depth: 5, blunder: 0.05, noise: 12, delay: [500, 900] },
|
||||
5: { depth: 7, blunder: 0.00, noise: 0, delay: [400, 800] },
|
||||
};
|
||||
|
||||
const WIN = 100000;
|
||||
const MAN = 100;
|
||||
const KING = 175;
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
function evaluate(state, aiColor) {
|
||||
if (state.phase === 'game_over') {
|
||||
if (state.winner === aiColor) return WIN;
|
||||
if (state.winner === 'draw') return 0;
|
||||
return -WIN;
|
||||
}
|
||||
let score = 0;
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const p = state.board[r][c];
|
||||
if (!p) continue;
|
||||
let v = p.king ? KING : MAN;
|
||||
if (!p.king) {
|
||||
// Reward advancement toward the crown row.
|
||||
const adv = p.color === 'white' ? (7 - r) : r;
|
||||
v += adv * 3;
|
||||
// Reward holding the back row (blocks opponent promotion).
|
||||
const homeRow = p.color === 'white' ? 7 : 0;
|
||||
if (r === homeRow) v += 8;
|
||||
}
|
||||
// Central files are worth slightly more than the edges.
|
||||
v += (c >= 2 && c <= 5) ? 4 : 0;
|
||||
score += (p.color === aiColor ? v : -v);
|
||||
}
|
||||
}
|
||||
// Small mobility term.
|
||||
const moves = getValidMoves(state).length;
|
||||
score += (state.turn === aiColor ? moves : -moves) * 1.5;
|
||||
return score;
|
||||
}
|
||||
|
||||
function search(state, depth, alpha, beta, aiColor) {
|
||||
if (state.phase === 'game_over' || depth <= 0) return evaluate(state, aiColor);
|
||||
const outcomes = expandTurns(state);
|
||||
if (outcomes.length === 0) return evaluate(state, aiColor);
|
||||
|
||||
if (state.turn === aiColor) {
|
||||
let value = -Infinity;
|
||||
for (const o of outcomes) {
|
||||
value = Math.max(value, search(o.state, depth - 1, alpha, beta, aiColor));
|
||||
alpha = Math.max(alpha, value);
|
||||
if (alpha >= beta) break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
let value = Infinity;
|
||||
for (const o of outcomes) {
|
||||
value = Math.min(value, search(o.state, depth - 1, alpha, beta, aiColor));
|
||||
beta = Math.min(beta, value);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Return one step move for `aiColor`, or null if it cannot move.
|
||||
export function chooseMove(state, aiColor, skill) {
|
||||
const prof = profileFor(skill);
|
||||
const steps = getValidMoves(state);
|
||||
if (steps.length === 0) return null;
|
||||
if (steps.length === 1) return steps[0];
|
||||
|
||||
if (Math.random() < prof.blunder) {
|
||||
return steps[Math.floor(Math.random() * steps.length)];
|
||||
}
|
||||
|
||||
// Score each legal first step by the best end-of-turn position it can reach.
|
||||
const byStep = new Map();
|
||||
for (const o of expandTurns(state)) {
|
||||
const key = stepKey(o.firstStep);
|
||||
let val;
|
||||
if (o.state.turn === aiColor && o.state.phase !== 'game_over') {
|
||||
val = search(o.state, prof.depth, -Infinity, Infinity, aiColor);
|
||||
} else {
|
||||
val = search(o.state, prof.depth - 1, -Infinity, Infinity, aiColor);
|
||||
}
|
||||
if (!byStep.has(key) || val > byStep.get(key).val) {
|
||||
byStep.set(key, { step: o.firstStep, val });
|
||||
}
|
||||
}
|
||||
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const { step, val } of byStep.values()) {
|
||||
const noisy = val + (Math.random() * 2 - 1) * prof.noise;
|
||||
if (noisy > bestScore) { bestScore = noisy; best = step; }
|
||||
}
|
||||
return best ?? steps[0];
|
||||
}
|
||||
|
||||
function stepKey(step) {
|
||||
return `${step.from[0]},${step.from[1]}->${step.to[0]},${step.to[1]}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,532 @@
|
|||
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 { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import {
|
||||
createInitialState, getValidMoves, applyMove, isGameOver, getWinner, pieceCounts, SIZE,
|
||||
} from './CheckersLogic.js';
|
||||
import { chooseMove, nextThinkDelay } from './CheckersAI.js';
|
||||
|
||||
// ── Layout ──────────────────────────────────────────────────────────────────
|
||||
const SQ = 104;
|
||||
const BOARD = SQ * SIZE; // 832
|
||||
const BX = Math.round(GAME_WIDTH / 2 - BOARD / 2); // centered
|
||||
const BY = Math.round(GAME_HEIGHT / 2 - BOARD / 2);
|
||||
const FRAME = 30;
|
||||
const CR = SQ * 0.38; // checker radius
|
||||
|
||||
const DEPTH = { board: 0, piece: 10, overlay: 20, moving: 30, ui: 50, banner: 60 };
|
||||
|
||||
const C = {
|
||||
light: 0xebe6c8,
|
||||
dark: 0x6f9c5a,
|
||||
frame: 0x3a2414,
|
||||
frameLt: 0x6b4423,
|
||||
frameLn: 0x8b5c2a,
|
||||
wRing: 0xfffaf0,
|
||||
wFill: 0xe6d8b8,
|
||||
wEdge: 0xb8a679,
|
||||
bRing: 0x4a4654,
|
||||
bFill: 0x201e26,
|
||||
bEdge: 0x12111a,
|
||||
kingGold: 0xffd54a,
|
||||
selGold: 0xffd700,
|
||||
};
|
||||
|
||||
export default class CheckersGame extends Phaser.Scene {
|
||||
constructor() { super('CheckersGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.gs = null;
|
||||
this.animating = false;
|
||||
this.selected = null; // [r, c] of selected piece
|
||||
this.pieceObjs = []; // [{ r, c, container }]
|
||||
this.overlayObjs = []; // transient highlights / hints / selection ring
|
||||
this.opponentPortrait = null;
|
||||
this.turnText = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildParticleTexture();
|
||||
this.buildPlayfield();
|
||||
this.buildBoard();
|
||||
this.buildUI();
|
||||
this.buildPlayerCards();
|
||||
this.initGame();
|
||||
}
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────────────
|
||||
|
||||
buildParticleTexture() {
|
||||
const g = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('checkersParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildPlayfield() {
|
||||
const pf = this.playfield;
|
||||
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 - 2);
|
||||
} 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 - 2);
|
||||
}
|
||||
}
|
||||
|
||||
buildBoard() {
|
||||
const g = this.add.graphics().setDepth(DEPTH.board);
|
||||
|
||||
// Wood frame
|
||||
g.fillStyle(C.frame, 1);
|
||||
g.fillRoundedRect(BX - FRAME, BY - FRAME, BOARD + FRAME * 2, BOARD + FRAME * 2, 12);
|
||||
g.lineStyle(3, C.frameLt, 1);
|
||||
g.strokeRoundedRect(BX - FRAME + 5, BY - FRAME + 5, BOARD + FRAME * 2 - 10, BOARD + FRAME * 2 - 10, 9);
|
||||
g.lineStyle(1, C.frameLn, 0.6);
|
||||
g.strokeRect(BX - 2, BY - 2, BOARD + 4, BOARD + 4);
|
||||
|
||||
// Squares
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const dark = (r + c) % 2 === 1;
|
||||
g.fillStyle(dark ? C.dark : C.light, 1);
|
||||
g.fillRect(BX + c * SQ, BY + r * SQ, SQ, SQ);
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinate labels (a–h bottom, 8–1 left)
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
this.add.text(BX + c * SQ + SQ / 2, BY + BOARD + 16, String.fromCharCode(97 + c), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
}
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
this.add.text(BX - 16, BY + r * SQ + SQ / 2, String(SIZE - r), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
}
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
const cx = BX + BOARD / 2;
|
||||
|
||||
this.turnText = this.add.text(cx, BY + BOARD + 52, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
|
||||
new Button(this, BX + BOARD + FRAME + 90, BY + 60, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 150, height: 46, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
||||
new Button(this, BX + BOARD + FRAME + 90, BY + 124, 'New', () => this.initGame(), {
|
||||
variant: 'ghost', width: 150, height: 46, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildPlayerCards() {
|
||||
const opp = this.opponents[0];
|
||||
const r = 78;
|
||||
const depth = DEPTH.ui;
|
||||
const avatarX = BX / 2;
|
||||
|
||||
const oppAY = BY + r + 20;
|
||||
this.add.circle(avatarX, oppAY, r + 5, C.frame).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: 230 }, align: 'center',
|
||||
}).setOrigin(0.5, 0).setDepth(depth + 2);
|
||||
this.oppTrayY = oppAY + r + 64;
|
||||
|
||||
const plrAY = BY + BOARD - r - 20;
|
||||
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
|
||||
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Checkers');
|
||||
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px',
|
||||
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
|
||||
}).setOrigin(0.5, 1).setDepth(depth + 2);
|
||||
this.plrTrayY = plrAY - r - 56;
|
||||
|
||||
this.trayObjs = [];
|
||||
}
|
||||
|
||||
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
|
||||
|
||||
// ── Game flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
initGame() {
|
||||
this.clearOverlays();
|
||||
this.clearPieces();
|
||||
this.animating = false;
|
||||
this.selected = null;
|
||||
this.gs = createInitialState();
|
||||
this.renderAll();
|
||||
this.showTurnBanner('Your Turn');
|
||||
}
|
||||
|
||||
renderAll() {
|
||||
this.clearPieces();
|
||||
this.renderPieces();
|
||||
this.renderTrays();
|
||||
this.clearOverlays();
|
||||
this.updateTurnText();
|
||||
if (this.gs.phase === 'playing' && this.gs.turn === 'white'
|
||||
&& !this.animating && !this.gs.mustContinueFrom) {
|
||||
this.showMovableHints();
|
||||
}
|
||||
}
|
||||
|
||||
updateTurnText() {
|
||||
if (!this.turnText) return;
|
||||
if (this.gs.phase === 'game_over') { this.turnText.setText(''); return; }
|
||||
this.turnText.setText(this.gs.turn === 'white' ? 'Your move' : 'Opponent thinking…');
|
||||
}
|
||||
|
||||
// ── Pieces ──────────────────────────────────────────────────────────────────
|
||||
|
||||
sqToWorld(r, c) {
|
||||
return { x: BX + c * SQ + SQ / 2, y: BY + r * SQ + SQ / 2 };
|
||||
}
|
||||
|
||||
clearPieces() {
|
||||
for (const o of this.pieceObjs) o.container.destroy();
|
||||
this.pieceObjs = [];
|
||||
}
|
||||
|
||||
renderPieces() {
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const p = this.gs.board[r][c];
|
||||
if (!p) continue;
|
||||
const { x, y } = this.sqToWorld(r, c);
|
||||
const cont = this.makeDisc(p.color, p.king, x, y);
|
||||
cont.setDepth(DEPTH.piece);
|
||||
cont.setInteractive({
|
||||
useHandCursor: true,
|
||||
hitArea: new Phaser.Geom.Circle(0, 0, CR),
|
||||
hitAreaCallback: Phaser.Geom.Circle.Contains,
|
||||
});
|
||||
cont.on('pointerdown', () => this.onPieceClick(r, c));
|
||||
this.pieceObjs.push({ r, c, container: cont });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
makeDisc(color, king, x, y) {
|
||||
const g = this.add.graphics();
|
||||
const ring = color === 'white' ? C.wRing : C.bRing;
|
||||
const fill = color === 'white' ? C.wFill : C.bFill;
|
||||
const edge = color === 'white' ? C.wEdge : C.bEdge;
|
||||
// Shadow
|
||||
g.fillStyle(0x000000, 0.28);
|
||||
g.fillCircle(3, 5, CR);
|
||||
// Outer ring + edge ridge
|
||||
g.fillStyle(edge, 1);
|
||||
g.fillCircle(0, 0, CR);
|
||||
g.fillStyle(ring, 1);
|
||||
g.fillCircle(0, 0, CR - 3);
|
||||
// Inner face
|
||||
g.fillStyle(fill, 1);
|
||||
g.fillCircle(0, 0, CR - 8);
|
||||
// Concentric ridge groove
|
||||
g.lineStyle(2, edge, 0.5);
|
||||
g.strokeCircle(0, 0, CR - 13);
|
||||
// Top-left sheen
|
||||
g.lineStyle(3, color === 'white' ? 0xffffff : 0x7a7690, 0.5);
|
||||
g.beginPath();
|
||||
g.arc(0, 0, CR - 11, Phaser.Math.DegToRad(200), Phaser.Math.DegToRad(320));
|
||||
g.strokePath();
|
||||
|
||||
const items = [g];
|
||||
if (king) {
|
||||
const ringG = this.add.graphics();
|
||||
ringG.lineStyle(3, C.kingGold, 0.95);
|
||||
ringG.strokeCircle(0, 0, CR - 4);
|
||||
const crown = this.add.graphics();
|
||||
this.drawCrown(crown, C.kingGold);
|
||||
items.push(ringG, crown);
|
||||
}
|
||||
return this.add.container(x, y, items);
|
||||
}
|
||||
|
||||
drawCrown(g, color) {
|
||||
const w = CR * 1.15, h = CR * 0.78;
|
||||
const peakY = -h * 0.5, valleyY = -h * 0.02, baseY = h * 0.5;
|
||||
const pts = [
|
||||
{ x: -w / 2, y: baseY }, { x: -w / 2, y: peakY },
|
||||
{ x: -w / 4, y: valleyY }, { x: 0, y: peakY },
|
||||
{ x: w / 4, y: valleyY }, { x: w / 2, y: peakY },
|
||||
{ x: w / 2, y: baseY },
|
||||
];
|
||||
g.fillStyle(color, 1);
|
||||
g.fillPoints(pts, true);
|
||||
g.lineStyle(1.5, 0x8a6a12, 0.8);
|
||||
g.strokePoints(pts, true);
|
||||
}
|
||||
|
||||
// ── Interaction ───────────────────────────────────────────────────────────
|
||||
|
||||
onPieceClick(r, c) {
|
||||
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'white') return;
|
||||
const piece = this.gs.board[r][c];
|
||||
if (!piece || piece.color !== 'white') return;
|
||||
if (this.gs.mustContinueFrom) {
|
||||
const [mr, mc] = this.gs.mustContinueFrom;
|
||||
if (mr !== r || mc !== c) return;
|
||||
}
|
||||
const moves = getValidMoves(this.gs).filter((m) => m.from[0] === r && m.from[1] === c);
|
||||
if (moves.length === 0) { this.flashNoMove(r, c); return; }
|
||||
this.selectPiece(r, c, moves);
|
||||
}
|
||||
|
||||
selectPiece(r, c, moves) {
|
||||
this.clearOverlays();
|
||||
this.selected = [r, c];
|
||||
const { x, y } = this.sqToWorld(r, c);
|
||||
const ring = this.add.graphics().setDepth(DEPTH.overlay);
|
||||
ring.lineStyle(4, C.selGold, 1);
|
||||
ring.strokeCircle(x, y, CR + 5);
|
||||
this.tweens.add({ targets: ring, alpha: { from: 1, to: 0.35 }, duration: 520, yoyo: true, repeat: -1 });
|
||||
this.overlayObjs.push(ring);
|
||||
this.showDestinations(moves);
|
||||
}
|
||||
|
||||
showDestinations(moves) {
|
||||
const seen = new Set();
|
||||
for (const m of moves) {
|
||||
const key = `${m.to[0]},${m.to[1]}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const { x, y } = this.sqToWorld(m.to[0], m.to[1]);
|
||||
const dot = this.add.graphics().setDepth(DEPTH.overlay);
|
||||
const cap = m.isJump;
|
||||
dot.fillStyle(cap ? C.selGold : COLORS.accent, cap ? 0.9 : 0.8);
|
||||
dot.fillCircle(x, y, cap ? 24 : 18);
|
||||
dot.lineStyle(3, 0xffffff, 0.4);
|
||||
dot.strokeCircle(x, y, cap ? 24 : 18);
|
||||
this.tweens.add({ targets: dot, alpha: { from: 0.9, to: 0.25 }, duration: 600, yoyo: true, repeat: -1 });
|
||||
const zone = this.add.zone(x, y, SQ, SQ)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.overlay);
|
||||
zone.on('pointerdown', () => this.onDestinationClick(m.to[0], m.to[1]));
|
||||
this.overlayObjs.push(dot, zone);
|
||||
}
|
||||
}
|
||||
|
||||
showMovableHints() {
|
||||
const froms = new Set(getValidMoves(this.gs).map((m) => `${m.from[0]},${m.from[1]}`));
|
||||
for (const key of froms) {
|
||||
const [r, c] = key.split(',').map(Number);
|
||||
const { x, y } = this.sqToWorld(r, c);
|
||||
const ring = this.add.graphics().setDepth(DEPTH.overlay - 1);
|
||||
ring.lineStyle(3, C.selGold, 0.5);
|
||||
ring.strokeCircle(x, y, CR + 3);
|
||||
this.tweens.add({ targets: ring, alpha: { from: 0.5, to: 0.12 }, duration: 900, yoyo: true, repeat: -1 });
|
||||
this.overlayObjs.push(ring);
|
||||
}
|
||||
}
|
||||
|
||||
onDestinationClick(r, c) {
|
||||
if (this.animating || !this.selected) return;
|
||||
const [sr, sc] = this.selected;
|
||||
const move = getValidMoves(this.gs).find(
|
||||
(m) => m.from[0] === sr && m.from[1] === sc && m.to[0] === r && m.to[1] === c
|
||||
);
|
||||
if (!move) return;
|
||||
this.clearOverlays();
|
||||
this.executeMove(move, 'white');
|
||||
}
|
||||
|
||||
flashNoMove(r, c) {
|
||||
const obj = this.pieceObjs.find((o) => o.r === r && o.c === c);
|
||||
if (!obj) return;
|
||||
this.tweens.add({ targets: obj.container, alpha: { from: 1, to: 0.3 }, duration: 110, yoyo: true, repeat: 2 });
|
||||
}
|
||||
|
||||
clearOverlays() {
|
||||
for (const o of this.overlayObjs) o.destroy();
|
||||
this.overlayObjs = [];
|
||||
this.selected = null;
|
||||
}
|
||||
|
||||
// ── Move execution + animation ──────────────────────────────────────────────
|
||||
|
||||
executeMove(move, mover) {
|
||||
this.animating = true;
|
||||
this.updateTurnText();
|
||||
const obj = this.pieceObjs.find((o) => o.r === move.from[0] && o.c === move.from[1]);
|
||||
const fromPos = this.sqToWorld(move.from[0], move.from[1]);
|
||||
const toPos = this.sqToWorld(move.to[0], move.to[1]);
|
||||
const container = obj ? obj.container : this.makeDisc(
|
||||
mover, this.gs.board[move.from[0]][move.from[1]].king, fromPos.x, fromPos.y);
|
||||
container.setDepth(DEPTH.moving);
|
||||
|
||||
// Fade out captured discs alongside the hop.
|
||||
for (const [cr, cc] of move.captures) this.animateCapture(cr, cc);
|
||||
|
||||
this.animateArc(container, fromPos, toPos, () => {
|
||||
this.gs = applyMove(this.gs, move);
|
||||
this.renderAll();
|
||||
this.animating = false;
|
||||
|
||||
if (move.captures.length > 0) {
|
||||
this.playOpponentEmotion(mover === 'white' ? 'upset' : 'happy');
|
||||
}
|
||||
|
||||
if (this.gs.phase === 'game_over') { this.onGameOver(); return; }
|
||||
|
||||
if (this.gs.turn === 'white') {
|
||||
if (this.gs.mustContinueFrom) {
|
||||
// Human must keep jumping with the same piece.
|
||||
const [cr, cc] = this.gs.mustContinueFrom;
|
||||
this.selectPiece(cr, cc, getValidMoves(this.gs));
|
||||
}
|
||||
} else if (this.gs.mustContinueFrom) {
|
||||
// AI mid multi-jump — continue the chain quickly.
|
||||
this.time.delayedCall(360, () => this.aiStep());
|
||||
} else {
|
||||
// Fresh AI turn.
|
||||
this.startAITurn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startAITurn() {
|
||||
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.showTurnBanner(`${name}'s Turn`);
|
||||
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiStep(false));
|
||||
}
|
||||
|
||||
aiStep() {
|
||||
if (this.gs.phase !== 'playing' || this.gs.turn !== 'black') return;
|
||||
const skill = this.opponents[0]?.skill ?? 3;
|
||||
const move = chooseMove(this.gs, 'black', skill);
|
||||
if (!move) return;
|
||||
this.executeMove(move, 'black');
|
||||
}
|
||||
|
||||
animateCapture(r, c) {
|
||||
const obj = this.pieceObjs.find((o) => o.r === r && o.c === c);
|
||||
if (!obj) return;
|
||||
obj.container.setDepth(DEPTH.moving - 1);
|
||||
this.tweens.add({
|
||||
targets: obj.container,
|
||||
scaleX: 0, scaleY: 0, alpha: 0,
|
||||
angle: 90,
|
||||
duration: 240, ease: 'Back.easeIn',
|
||||
});
|
||||
}
|
||||
|
||||
animateArc(container, from, to, onComplete) {
|
||||
if (!container) { onComplete(); return; }
|
||||
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: 330, ease: 'Cubic.easeInOut',
|
||||
onUpdate: () => {
|
||||
const t = prog.t, 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;
|
||||
this.tweens.add({
|
||||
targets: container, scaleX: 1.25, scaleY: 0.78, duration: 60, yoyo: true, ease: 'Quad.easeOut',
|
||||
onComplete: () => { playSound(this, SFX.PIECE_CLICK); onComplete(); },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Captured trays ──────────────────────────────────────────────────────────
|
||||
|
||||
renderTrays() {
|
||||
for (const o of this.trayObjs) o.destroy();
|
||||
this.trayObjs = [];
|
||||
const counts = pieceCounts(this.gs);
|
||||
const avatarX = BX / 2;
|
||||
// Opponent (black) captured white discs.
|
||||
this.drawTray(avatarX, this.oppTrayY, 12 - counts.white, 'white');
|
||||
// Player (white) captured black discs.
|
||||
this.drawTray(avatarX, this.plrTrayY, 12 - counts.black, 'black');
|
||||
}
|
||||
|
||||
drawTray(cx, cy, n, color) {
|
||||
const ring = color === 'white' ? C.wRing : C.bRing;
|
||||
const fill = color === 'white' ? C.wFill : C.bFill;
|
||||
const per = 6, mr = 13, gap = 5;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const col = i % per, row = Math.floor(i / per);
|
||||
const x = cx - (per - 1) * (mr + gap) / 2 + col * (mr + gap);
|
||||
const y = cy + row * (mr + gap);
|
||||
const g = this.add.graphics().setDepth(DEPTH.ui);
|
||||
g.fillStyle(0x000000, 0.3); g.fillCircle(x + 1, y + 1, mr);
|
||||
g.fillStyle(ring, 1); g.fillCircle(x, y, mr);
|
||||
g.fillStyle(fill, 1); g.fillCircle(x, y, mr - 3);
|
||||
this.trayObjs.push(g);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Banners / overlays ──────────────────────────────────────────────────────
|
||||
|
||||
showTurnBanner(text) {
|
||||
const cx = BX + BOARD / 2;
|
||||
const banner = this.add.text(cx, BY - 70, 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 - 14, duration: 320, ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
this.time.delayedCall(1100, () => {
|
||||
this.tweens.add({ targets: banner, y: BY - 70, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onGameOver() {
|
||||
const winner = getWinner(this.gs);
|
||||
const isHuman = winner === 'white';
|
||||
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.playOpponentEmotion(isHuman ? 'upset' : 'happy');
|
||||
const cx = BX + BOARD / 2, cy = BY + BOARD / 2;
|
||||
|
||||
if (isHuman) {
|
||||
const emitter = this.add.particles(cx, cy, 'checkersParticle', {
|
||||
speed: { min: 150, max: 500 }, lifespan: 1400,
|
||||
scale: { start: 1.5, end: 0 }, alpha: { start: 1, end: 0 },
|
||||
quantity: 5, frequency: 25, angle: { min: 0, max: 360 },
|
||||
tint: [C.selGold, 0xffffff, COLORS.accent],
|
||||
}).setDepth(DEPTH.banner);
|
||||
this.time.delayedCall(2000, () => emitter.destroy());
|
||||
}
|
||||
|
||||
this.time.delayedCall(450, () => {
|
||||
const msg = isHuman
|
||||
? '🎉 You Win!\nEvery opposing piece captured or trapped.'
|
||||
: `${name} wins this time.\nBetter luck next game!`;
|
||||
const overlay = this.add.rectangle(cx, cy, 720, 300, 0x0a0e14, 0.9)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
|
||||
const txt = this.add.text(cx, cy - 40, msg, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '30px',
|
||||
color: isHuman ? '#ffd700' : COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||
new Button(this, cx - 90, cy + 80, 'Play Again', () => {
|
||||
overlay.destroy(); txt.destroy(); this.initGame();
|
||||
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
new Button(this, cx + 90, cy + 80, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
// Pure game logic for American (English) draughts — no Phaser dependency.
|
||||
//
|
||||
// Board: 8×8 array, board[r][c] = null | { color: 'white'|'black', king: bool }.
|
||||
// Row 0 is the top (Black's home); row 7 is the bottom (White's home).
|
||||
// Only dark squares (where (r + c) is odd) are playable.
|
||||
// White (the human) moves up the board (decreasing row) and crowns at row 0.
|
||||
// Black (the AI) moves down the board (increasing row) and crowns at row 7.
|
||||
// Captures are mandatory; a multi-jump continues with the same piece until no
|
||||
// further jump exists. Reaching the crown row ends the turn even mid-chain.
|
||||
|
||||
export const SIZE = 8;
|
||||
|
||||
const WHITE_DIRS = [[-1, -1], [-1, 1]];
|
||||
const BLACK_DIRS = [[1, -1], [1, 1]];
|
||||
const KING_DIRS = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
|
||||
|
||||
function inBounds(r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
|
||||
export function isDark(r, c) { return (r + c) % 2 === 1; }
|
||||
function other(color) { return color === 'white' ? 'black' : 'white'; }
|
||||
|
||||
export function cloneState(state) {
|
||||
return {
|
||||
board: state.board.map((row) => row.map((cell) => (cell ? { ...cell } : null))),
|
||||
turn: state.turn,
|
||||
mustContinueFrom: state.mustContinueFrom ? [...state.mustContinueFrom] : null,
|
||||
winner: state.winner,
|
||||
phase: state.phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialState() {
|
||||
const board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
if (!isDark(r, c)) continue;
|
||||
if (r <= 2) board[r][c] = { color: 'black', king: false };
|
||||
else if (r >= 5) board[r][c] = { color: 'white', king: false };
|
||||
}
|
||||
}
|
||||
return { board, turn: 'white', mustContinueFrom: null, winner: null, phase: 'playing' };
|
||||
}
|
||||
|
||||
function dirsFor(piece) {
|
||||
if (piece.king) return KING_DIRS;
|
||||
return piece.color === 'white' ? WHITE_DIRS : BLACK_DIRS;
|
||||
}
|
||||
|
||||
// Slides and jumps available to the single piece at [r, c].
|
||||
function stepMovesFor(state, r, c) {
|
||||
const piece = state.board[r][c];
|
||||
const slides = [];
|
||||
const jumps = [];
|
||||
if (!piece) return { slides, jumps };
|
||||
for (const [dr, dc] of dirsFor(piece)) {
|
||||
const r1 = r + dr, c1 = c + dc;
|
||||
if (!inBounds(r1, c1)) continue;
|
||||
const adj = state.board[r1][c1];
|
||||
if (adj === null) {
|
||||
slides.push({ from: [r, c], to: [r1, c1], captures: [], isJump: false });
|
||||
} else if (adj.color !== piece.color) {
|
||||
const r2 = r1 + dr, c2 = c1 + dc;
|
||||
if (inBounds(r2, c2) && state.board[r2][c2] === null) {
|
||||
jumps.push({ from: [r, c], to: [r2, c2], captures: [[r1, c1]], isJump: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { slides, jumps };
|
||||
}
|
||||
|
||||
// Legal moves for the side to move. Captures are forced: if any jump exists,
|
||||
// only jumps are returned. When a multi-jump is in progress, only further
|
||||
// jumps from the locked square are legal.
|
||||
export function getValidMoves(state) {
|
||||
if (state.phase === 'game_over') return [];
|
||||
const player = state.turn;
|
||||
|
||||
if (state.mustContinueFrom) {
|
||||
const [r, c] = state.mustContinueFrom;
|
||||
return stepMovesFor(state, r, c).jumps;
|
||||
}
|
||||
|
||||
const allSlides = [];
|
||||
const allJumps = [];
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const piece = state.board[r][c];
|
||||
if (!piece || piece.color !== player) continue;
|
||||
const { slides, jumps } = stepMovesFor(state, r, c);
|
||||
allSlides.push(...slides);
|
||||
allJumps.push(...jumps);
|
||||
}
|
||||
}
|
||||
return allJumps.length > 0 ? allJumps : allSlides;
|
||||
}
|
||||
|
||||
function countPieces(board, color) {
|
||||
let n = 0;
|
||||
for (const row of board) for (const cell of row) if (cell && cell.color === color) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Decide game over for the position with `state.turn` to move: that side loses
|
||||
// if it has no pieces or no legal move.
|
||||
function resolveEnd(state) {
|
||||
const s = state;
|
||||
if (countPieces(s.board, 'white') === 0) { s.winner = 'black'; s.phase = 'game_over'; return s; }
|
||||
if (countPieces(s.board, 'black') === 0) { s.winner = 'white'; s.phase = 'game_over'; return s; }
|
||||
if (getValidMoves(s).length === 0) { s.winner = other(s.turn); s.phase = 'game_over'; }
|
||||
return s;
|
||||
}
|
||||
|
||||
export function applyMove(state, move) {
|
||||
const s = cloneState(state);
|
||||
const [fr, fc] = move.from;
|
||||
const [tr, tc] = move.to;
|
||||
const piece = s.board[fr][fc];
|
||||
s.board[fr][fc] = null;
|
||||
for (const [cr, cc] of move.captures) s.board[cr][cc] = null;
|
||||
|
||||
let promoted = false;
|
||||
if (!piece.king) {
|
||||
if (piece.color === 'white' && tr === 0) { piece.king = true; promoted = true; }
|
||||
if (piece.color === 'black' && tr === SIZE - 1) { piece.king = true; promoted = true; }
|
||||
}
|
||||
s.board[tr][tc] = piece;
|
||||
|
||||
// Continue a multi-jump with the same piece unless it just crowned.
|
||||
if (move.isJump && !promoted) {
|
||||
const further = stepMovesFor(s, tr, tc).jumps;
|
||||
if (further.length > 0) {
|
||||
s.mustContinueFrom = [tr, tc];
|
||||
return s; // same player keeps moving
|
||||
}
|
||||
}
|
||||
|
||||
s.mustContinueFrom = null;
|
||||
s.turn = other(s.turn);
|
||||
return resolveEnd(s);
|
||||
}
|
||||
|
||||
export function isGameOver(state) { return state.phase === 'game_over'; }
|
||||
export function getWinner(state) { return state.winner; }
|
||||
|
||||
export function pieceCounts(state) {
|
||||
return {
|
||||
white: countPieces(state.board, 'white'),
|
||||
black: countPieces(state.board, 'black'),
|
||||
};
|
||||
}
|
||||
|
||||
// Enumerate complete turns for the side to move. Each entry is the first step
|
||||
// of the turn paired with the resulting end-of-turn state (multi-jumps are
|
||||
// expanded internally). Used by the AI search.
|
||||
export function expandTurns(state) {
|
||||
const out = [];
|
||||
const moves = getValidMoves(state);
|
||||
for (const m of moves) {
|
||||
const ns = applyMove(state, m);
|
||||
if (ns.turn === state.turn && ns.phase !== 'game_over') {
|
||||
for (const sub of expandTurns(ns)) out.push({ firstStep: m, state: sub.state });
|
||||
} else {
|
||||
out.push({ firstStep: m, state: ns });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
// Chess AI — alpha-beta minimax with piece-square tables and a Nerts-style
|
||||
// 1..5 skill model.
|
||||
// • depth — search depth in plies (capped for browser responsiveness)
|
||||
// • blunder — chance to ignore the best move and play a random legal one
|
||||
// • noise — random value added to root move scores (flattens decisions)
|
||||
// • delay — "thinking" pause (ms range) before moving, for natural pacing
|
||||
// Captures are searched first so alpha-beta prunes hard at the higher depths.
|
||||
|
||||
import {
|
||||
getLegalMoves, applyMoveRaw, isKingAttacked, SIZE,
|
||||
} from './ChessLogic.js';
|
||||
|
||||
const SKILL_PROFILES = {
|
||||
1: { depth: 1, blunder: 0.45, noise: 90, delay: [900, 1500] },
|
||||
2: { depth: 2, blunder: 0.30, noise: 55, delay: [800, 1300] },
|
||||
3: { depth: 2, blunder: 0.15, noise: 30, delay: [700, 1100] },
|
||||
4: { depth: 3, blunder: 0.05, noise: 12, delay: [550, 950] },
|
||||
5: { depth: 4, blunder: 0.00, noise: 0, delay: [450, 850] },
|
||||
};
|
||||
|
||||
const VALUE = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 };
|
||||
const MATE = 1000000;
|
||||
|
||||
const PST = {
|
||||
p: [
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[50, 50, 50, 50, 50, 50, 50, 50],
|
||||
[10, 10, 20, 30, 30, 20, 10, 10],
|
||||
[5, 5, 10, 25, 25, 10, 5, 5],
|
||||
[0, 0, 0, 20, 20, 0, 0, 0],
|
||||
[5, -5, -10, 0, 0, -10, -5, 5],
|
||||
[5, 10, 10, -20, -20, 10, 10, 5],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
n: [
|
||||
[-50, -40, -30, -30, -30, -30, -40, -50],
|
||||
[-40, -20, 0, 0, 0, 0, -20, -40],
|
||||
[-30, 0, 10, 15, 15, 10, 0, -30],
|
||||
[-30, 5, 15, 20, 20, 15, 5, -30],
|
||||
[-30, 0, 15, 20, 20, 15, 0, -30],
|
||||
[-30, 5, 10, 15, 15, 10, 5, -30],
|
||||
[-40, -20, 0, 5, 5, 0, -20, -40],
|
||||
[-50, -40, -30, -30, -30, -30, -40, -50],
|
||||
],
|
||||
b: [
|
||||
[-20, -10, -10, -10, -10, -10, -10, -20],
|
||||
[-10, 0, 0, 0, 0, 0, 0, -10],
|
||||
[-10, 0, 5, 10, 10, 5, 0, -10],
|
||||
[-10, 5, 5, 10, 10, 5, 5, -10],
|
||||
[-10, 0, 10, 10, 10, 10, 0, -10],
|
||||
[-10, 10, 10, 10, 10, 10, 10, -10],
|
||||
[-10, 5, 0, 0, 0, 0, 5, -10],
|
||||
[-20, -10, -10, -10, -10, -10, -10, -20],
|
||||
],
|
||||
r: [
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[5, 10, 10, 10, 10, 10, 10, 5],
|
||||
[-5, 0, 0, 0, 0, 0, 0, -5],
|
||||
[-5, 0, 0, 0, 0, 0, 0, -5],
|
||||
[-5, 0, 0, 0, 0, 0, 0, -5],
|
||||
[-5, 0, 0, 0, 0, 0, 0, -5],
|
||||
[-5, 0, 0, 0, 0, 0, 0, -5],
|
||||
[0, 0, 0, 5, 5, 0, 0, 0],
|
||||
],
|
||||
q: [
|
||||
[-20, -10, -10, -5, -5, -10, -10, -20],
|
||||
[-10, 0, 0, 0, 0, 0, 0, -10],
|
||||
[-10, 0, 5, 5, 5, 5, 0, -10],
|
||||
[-5, 0, 5, 5, 5, 5, 0, -5],
|
||||
[0, 0, 5, 5, 5, 5, 0, -5],
|
||||
[-10, 5, 5, 5, 5, 5, 0, -10],
|
||||
[-10, 0, 5, 0, 0, 0, 0, -10],
|
||||
[-20, -10, -10, -5, -5, -10, -10, -20],
|
||||
],
|
||||
k: [
|
||||
[-30, -40, -40, -50, -50, -40, -40, -30],
|
||||
[-30, -40, -40, -50, -50, -40, -40, -30],
|
||||
[-30, -40, -40, -50, -50, -40, -40, -30],
|
||||
[-30, -40, -40, -50, -50, -40, -40, -30],
|
||||
[-20, -30, -30, -40, -40, -30, -30, -20],
|
||||
[-10, -20, -20, -20, -20, -20, -20, -10],
|
||||
[20, 20, 0, 0, 0, 0, 20, 20],
|
||||
[20, 30, 10, 0, 0, 10, 30, 20],
|
||||
],
|
||||
};
|
||||
|
||||
function profileFor(skill) {
|
||||
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||
}
|
||||
|
||||
export function nextThinkDelay(skill) {
|
||||
const [lo, hi] = profileFor(skill).delay;
|
||||
return lo + Math.random() * (hi - lo);
|
||||
}
|
||||
|
||||
// Static evaluation from `aiColor`'s perspective (positive = good for AI).
|
||||
function evaluate(board, aiColor) {
|
||||
let score = 0; // white's perspective
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const p = board[r][c];
|
||||
if (!p) continue;
|
||||
const table = PST[p.type];
|
||||
const pst = p.color === 'white' ? table[r][c] : table[SIZE - 1 - r][c];
|
||||
const v = VALUE[p.type] + pst;
|
||||
score += p.color === 'white' ? v : -v;
|
||||
}
|
||||
}
|
||||
return aiColor === 'white' ? score : -score;
|
||||
}
|
||||
|
||||
// Captures first (most-valuable-victim heuristic) for better pruning.
|
||||
function orderMoves(board, moves) {
|
||||
return moves
|
||||
.map((m) => {
|
||||
let s = 0;
|
||||
if (m.capture) s += 10 * VALUE[board[m.capture[0]][m.capture[1]].type] - VALUE[m.piece];
|
||||
if (m.promotion) s += VALUE[m.promotion];
|
||||
return { m, s };
|
||||
})
|
||||
.sort((a, b) => b.s - a.s)
|
||||
.map((x) => x.m);
|
||||
}
|
||||
|
||||
function search(state, depth, alpha, beta, aiColor) {
|
||||
const moves = getLegalMoves(state);
|
||||
if (moves.length === 0) {
|
||||
if (isKingAttacked(state.board, state.turn)) {
|
||||
// Side to move is checkmated. Prefer faster mates via the depth bonus.
|
||||
return state.turn === aiColor ? -(MATE + depth) : (MATE + depth);
|
||||
}
|
||||
return 0; // stalemate
|
||||
}
|
||||
if (depth <= 0) return evaluate(state.board, aiColor);
|
||||
|
||||
const ordered = orderMoves(state.board, moves);
|
||||
if (state.turn === aiColor) {
|
||||
let value = -Infinity;
|
||||
for (const m of ordered) {
|
||||
value = Math.max(value, search(applyMoveRaw(state, m), depth - 1, alpha, beta, aiColor));
|
||||
alpha = Math.max(alpha, value);
|
||||
if (alpha >= beta) break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
let value = Infinity;
|
||||
for (const m of ordered) {
|
||||
value = Math.min(value, search(applyMoveRaw(state, m), depth - 1, alpha, beta, aiColor));
|
||||
beta = Math.min(beta, value);
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Return the chosen move object for `aiColor`, or null if none.
|
||||
export function chooseMove(state, aiColor, skill) {
|
||||
const prof = profileFor(skill);
|
||||
const moves = getLegalMoves(state);
|
||||
if (moves.length === 0) return null;
|
||||
if (Math.random() < prof.blunder) {
|
||||
return moves[Math.floor(Math.random() * moves.length)];
|
||||
}
|
||||
|
||||
const ordered = orderMoves(state.board, moves);
|
||||
let best = null;
|
||||
let bestScore = -Infinity;
|
||||
for (const m of ordered) {
|
||||
const val = search(applyMoveRaw(state, m), prof.depth - 1, -Infinity, Infinity, aiColor)
|
||||
+ (Math.random() * 2 - 1) * prof.noise;
|
||||
if (val > bestScore) { bestScore = val; best = m; }
|
||||
}
|
||||
return best ?? moves[0];
|
||||
}
|
||||
|
|
@ -0,0 +1,462 @@
|
|||
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 { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import {
|
||||
createInitialState, getLegalMoves, applyMove, isGameOver, findKing, SIZE,
|
||||
} from './ChessLogic.js';
|
||||
import { chooseMove, nextThinkDelay } from './ChessAI.js';
|
||||
import { makePiece } from './ChessPieces.js';
|
||||
|
||||
const SQ = 104;
|
||||
const BOARD = SQ * SIZE;
|
||||
const BX = Math.round(GAME_WIDTH / 2 - BOARD / 2);
|
||||
const BY = Math.round(GAME_HEIGHT / 2 - BOARD / 2);
|
||||
const FRAME = 30;
|
||||
const PSZ = SQ * 0.80;
|
||||
|
||||
const DEPTH = { board: 0, square: 1, piece: 10, overlay: 20, moving: 30, ui: 50, banner: 60 };
|
||||
|
||||
const C = {
|
||||
light: 0xebe6c8,
|
||||
dark: 0x6f9c5a,
|
||||
frame: 0x3a2414,
|
||||
frameLt: 0x6b4423,
|
||||
frameLn: 0x8b5c2a,
|
||||
sel: 0xffd700,
|
||||
move: 0xc8a84b,
|
||||
check: 0xd23b3b,
|
||||
};
|
||||
|
||||
export default class ChessGame extends Phaser.Scene {
|
||||
constructor() { super('ChessGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.gs = null;
|
||||
this.animating = false;
|
||||
this.selected = null;
|
||||
this.selMoves = [];
|
||||
this.pieceObjs = [];
|
||||
this.overlayObjs = [];
|
||||
this.checkObjs = [];
|
||||
this.promoObjs = [];
|
||||
this.opponentPortrait = null;
|
||||
this.turnText = null;
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildParticleTexture();
|
||||
this.buildPlayfield();
|
||||
this.buildBoard();
|
||||
this.buildInput();
|
||||
this.buildUI();
|
||||
this.buildPlayerCards();
|
||||
this.initGame();
|
||||
}
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────────────
|
||||
|
||||
buildParticleTexture() {
|
||||
const g = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xffffff, 1);
|
||||
g.fillCircle(5, 5, 5);
|
||||
g.generateTexture('chessParticle', 10, 10);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
buildPlayfield() {
|
||||
const pf = this.playfield;
|
||||
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 - 2);
|
||||
} 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 - 2);
|
||||
}
|
||||
}
|
||||
|
||||
buildBoard() {
|
||||
const g = this.add.graphics().setDepth(DEPTH.board);
|
||||
g.fillStyle(C.frame, 1);
|
||||
g.fillRoundedRect(BX - FRAME, BY - FRAME, BOARD + FRAME * 2, BOARD + FRAME * 2, 12);
|
||||
g.lineStyle(3, C.frameLt, 1);
|
||||
g.strokeRoundedRect(BX - FRAME + 5, BY - FRAME + 5, BOARD + FRAME * 2 - 10, BOARD + FRAME * 2 - 10, 9);
|
||||
g.lineStyle(1, C.frameLn, 0.6);
|
||||
g.strokeRect(BX - 2, BY - 2, BOARD + 4, BOARD + 4);
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const dark = (r + c) % 2 === 1;
|
||||
g.fillStyle(dark ? C.dark : C.light, 1);
|
||||
g.fillRect(BX + c * SQ, BY + r * SQ, SQ, SQ);
|
||||
}
|
||||
}
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
this.add.text(BX + c * SQ + SQ / 2, BY + BOARD + 16, String.fromCharCode(97 + c), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
}
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
this.add.text(BX - 16, BY + r * SQ + SQ / 2, String(SIZE - r), {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.board);
|
||||
}
|
||||
}
|
||||
|
||||
buildInput() {
|
||||
const zone = this.add.zone(BX + BOARD / 2, BY + BOARD / 2, BOARD, BOARD)
|
||||
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.square);
|
||||
zone.on('pointerdown', (pointer) => {
|
||||
const c = Math.floor((pointer.x - BX) / SQ);
|
||||
const r = Math.floor((pointer.y - BY) / SQ);
|
||||
if (r >= 0 && r < SIZE && c >= 0 && c < SIZE) this.handleClick(r, c);
|
||||
});
|
||||
}
|
||||
|
||||
buildUI() {
|
||||
const cx = BX + BOARD / 2;
|
||||
this.turnText = this.add.text(cx, BY + BOARD + 52, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.ui);
|
||||
new Button(this, BX + BOARD + FRAME + 90, BY + 60, 'Leave', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost', width: 150, height: 46, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
new Button(this, BX + BOARD + FRAME + 90, BY + 124, 'New', () => this.initGame(), {
|
||||
variant: 'ghost', width: 150, height: 46, fontSize: 20,
|
||||
}).setDepth(DEPTH.ui);
|
||||
}
|
||||
|
||||
buildPlayerCards() {
|
||||
const opp = this.opponents[0];
|
||||
const r = 78;
|
||||
const depth = DEPTH.ui;
|
||||
const avatarX = BX / 2;
|
||||
const oppAY = BY + r + 20;
|
||||
this.add.circle(avatarX, oppAY, r + 5, C.frame).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: 230 }, align: 'center',
|
||||
}).setOrigin(0.5, 0).setDepth(depth + 2);
|
||||
|
||||
const plrAY = BY + BOARD - r - 20;
|
||||
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
|
||||
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Chess');
|
||||
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px',
|
||||
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
|
||||
}).setOrigin(0.5, 1).setDepth(depth + 2);
|
||||
}
|
||||
|
||||
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
|
||||
|
||||
// ── Game flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
initGame() {
|
||||
this.clearOverlays();
|
||||
this.clearCheck();
|
||||
this.clearPromo();
|
||||
this.clearPieces();
|
||||
this.animating = false;
|
||||
this.selected = null;
|
||||
this.gs = createInitialState();
|
||||
this.renderAll();
|
||||
this.showTurnBanner('Your Turn — White');
|
||||
}
|
||||
|
||||
renderAll() {
|
||||
this.clearPieces();
|
||||
this.renderPieces();
|
||||
this.clearOverlays();
|
||||
this.renderCheck();
|
||||
this.updateTurnText();
|
||||
}
|
||||
|
||||
updateTurnText() {
|
||||
if (!this.turnText) return;
|
||||
const st = this.gs.status;
|
||||
if (isGameOver(this.gs)) { this.turnText.setText(''); return; }
|
||||
let t = this.gs.turn === 'white' ? 'Your move' : 'Opponent thinking…';
|
||||
if (st === 'check') t += ' — Check!';
|
||||
this.turnText.setText(t);
|
||||
}
|
||||
|
||||
sqToWorld(r, c) {
|
||||
return { x: BX + c * SQ + SQ / 2, y: BY + r * SQ + SQ / 2 };
|
||||
}
|
||||
|
||||
clearPieces() {
|
||||
for (const o of this.pieceObjs) o.container.destroy();
|
||||
this.pieceObjs = [];
|
||||
}
|
||||
|
||||
renderPieces() {
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const p = this.gs.board[r][c];
|
||||
if (!p) continue;
|
||||
const { x, y } = this.sqToWorld(r, c);
|
||||
const cont = makePiece(this, p.type, p.color, x, y, PSZ).setDepth(DEPTH.piece);
|
||||
this.pieceObjs.push({ r, c, container: cont });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Interaction ───────────────────────────────────────────────────────────
|
||||
|
||||
handleClick(r, c) {
|
||||
if (this.animating || this.gs.turn !== 'white' || isGameOver(this.gs)) return;
|
||||
if (this.selected) {
|
||||
const dests = this.selMoves.filter((m) => m.to[0] === r && m.to[1] === c);
|
||||
if (dests.length > 0) {
|
||||
if (dests[0].promotion) this.openPromotion(dests);
|
||||
else { this.clearOverlays(); this.executeMove(dests[0], 'white'); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
const piece = this.gs.board[r][c];
|
||||
if (piece && piece.color === 'white') this.selectSquare(r, c);
|
||||
else this.clearOverlays();
|
||||
}
|
||||
|
||||
selectSquare(r, c) {
|
||||
this.clearOverlays();
|
||||
this.selected = [r, c];
|
||||
this.selMoves = getLegalMoves(this.gs, [r, c]);
|
||||
const { x, y } = this.sqToWorld(r, c);
|
||||
const sq = this.add.rectangle(x, y, SQ, SQ, C.sel, 0.28).setDepth(DEPTH.overlay - 1);
|
||||
this.overlayObjs.push(sq);
|
||||
for (const m of this.selMoves) this.showDestination(m);
|
||||
}
|
||||
|
||||
showDestination(m) {
|
||||
const { x, y } = this.sqToWorld(m.to[0], m.to[1]);
|
||||
const g = this.add.graphics().setDepth(DEPTH.overlay);
|
||||
if (m.capture) {
|
||||
g.lineStyle(5, C.move, 0.9);
|
||||
g.strokeCircle(x, y, SQ * 0.42);
|
||||
} else {
|
||||
g.fillStyle(C.move, 0.8);
|
||||
g.fillCircle(x, y, 16);
|
||||
}
|
||||
this.tweens.add({ targets: g, alpha: { from: 0.9, to: 0.3 }, duration: 620, yoyo: true, repeat: -1 });
|
||||
this.overlayObjs.push(g);
|
||||
}
|
||||
|
||||
clearOverlays() {
|
||||
for (const o of this.overlayObjs) o.destroy();
|
||||
this.overlayObjs = [];
|
||||
this.selected = null;
|
||||
this.selMoves = [];
|
||||
}
|
||||
|
||||
// ── Promotion picker ────────────────────────────────────────────────────────
|
||||
|
||||
openPromotion(dests) {
|
||||
this.clearOverlays();
|
||||
this.animating = true;
|
||||
const opts = ['q', 'r', 'b', 'n'];
|
||||
const cell = 120;
|
||||
const cx = BX + BOARD / 2;
|
||||
const cy = BY + BOARD / 2;
|
||||
const startX = cx - (opts.length - 1) * cell / 2;
|
||||
const panel = this.add.rectangle(cx, cy, opts.length * cell + 30, cell + 30, 0x0a0e14, 0.95)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
|
||||
const title = this.add.text(cx, cy - cell / 2 - 30, 'Promote to', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
|
||||
this.promoObjs.push(panel, title);
|
||||
opts.forEach((opt, i) => {
|
||||
const x = startX + i * cell;
|
||||
const tile = this.add.rectangle(x, cy, cell - 16, cell - 16, COLORS.panel)
|
||||
.setStrokeStyle(2, COLORS.muted).setInteractive({ useHandCursor: true }).setDepth(DEPTH.banner + 1);
|
||||
const piece = makePiece(this, opt, 'white', x, cy, PSZ).setDepth(DEPTH.banner + 2);
|
||||
tile.on('pointerover', () => tile.setStrokeStyle(3, COLORS.accent));
|
||||
tile.on('pointerout', () => tile.setStrokeStyle(2, COLORS.muted));
|
||||
tile.on('pointerdown', () => {
|
||||
const mv = dests.find((d) => d.promotion === opt);
|
||||
this.clearPromo();
|
||||
this.animating = false;
|
||||
this.executeMove(mv, 'white');
|
||||
});
|
||||
this.promoObjs.push(tile, piece);
|
||||
});
|
||||
}
|
||||
|
||||
clearPromo() {
|
||||
for (const o of this.promoObjs) o.destroy();
|
||||
this.promoObjs = [];
|
||||
}
|
||||
|
||||
// ── Check highlight ──────────────────────────────────────────────────────────
|
||||
|
||||
renderCheck() {
|
||||
this.clearCheck();
|
||||
const st = this.gs.status;
|
||||
if (st !== 'check' && st !== 'checkmate') return;
|
||||
const k = findKing(this.gs.board, this.gs.turn);
|
||||
if (!k) return;
|
||||
const { x, y } = this.sqToWorld(k[0], k[1]);
|
||||
const g = this.add.rectangle(x, y, SQ, SQ, C.check, 0.5).setDepth(DEPTH.square + 1);
|
||||
this.tweens.add({ targets: g, alpha: { from: 0.55, to: 0.2 }, duration: 600, yoyo: true, repeat: -1 });
|
||||
this.checkObjs.push(g);
|
||||
}
|
||||
|
||||
clearCheck() {
|
||||
for (const o of this.checkObjs) o.destroy();
|
||||
this.checkObjs = [];
|
||||
}
|
||||
|
||||
// ── Move execution + animation ──────────────────────────────────────────────
|
||||
|
||||
executeMove(move, mover) {
|
||||
this.animating = true;
|
||||
this.updateTurnText();
|
||||
const obj = this.pieceObjs.find((o) => o.r === move.from[0] && o.c === move.from[1]);
|
||||
const fromPos = this.sqToWorld(move.from[0], move.from[1]);
|
||||
const toPos = this.sqToWorld(move.to[0], move.to[1]);
|
||||
const container = obj ? obj.container : makePiece(this, move.piece, mover, fromPos.x, fromPos.y, PSZ);
|
||||
container.setDepth(DEPTH.moving);
|
||||
|
||||
if (move.capture) this.animateCapture(move.capture);
|
||||
if (move.castle) this.animateCastlingRook(move);
|
||||
|
||||
this.animateArc(container, fromPos, toPos, () => {
|
||||
this.gs = applyMove(this.gs, move);
|
||||
this.renderAll();
|
||||
this.animating = false;
|
||||
if (move.capture) this.playOpponentEmotion(mover === 'white' ? 'upset' : 'happy');
|
||||
|
||||
const st = this.gs.status;
|
||||
if (isGameOver(this.gs)) { this.onGameOver(); return; }
|
||||
if (st === 'check') {
|
||||
this.playOpponentEmotion(this.gs.turn === 'black' ? 'upset' : 'happy');
|
||||
this.showTurnBanner('Check!');
|
||||
}
|
||||
if (this.gs.turn === 'black') this.startAITurn(st === 'check');
|
||||
else if (st !== 'check') this.showTurnBanner('Your move');
|
||||
});
|
||||
}
|
||||
|
||||
animateCastlingRook(move) {
|
||||
const row = move.from[0];
|
||||
const fromC = move.castle === 'K' ? 7 : 0;
|
||||
const toC = move.castle === 'K' ? 5 : 3;
|
||||
const rookObj = this.pieceObjs.find((o) => o.r === row && o.c === fromC);
|
||||
if (!rookObj) return;
|
||||
rookObj.container.setDepth(DEPTH.moving - 1);
|
||||
const to = this.sqToWorld(row, toC);
|
||||
this.tweens.add({ targets: rookObj.container, x: to.x, y: to.y, duration: 300, ease: 'Quad.easeInOut' });
|
||||
}
|
||||
|
||||
startAITurn(skipBanner) {
|
||||
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||
if (!skipBanner) this.showTurnBanner(`${name}'s Turn`);
|
||||
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiMove());
|
||||
}
|
||||
|
||||
aiMove() {
|
||||
if (this.gs.turn !== 'black' || isGameOver(this.gs)) return;
|
||||
const skill = this.opponents[0]?.skill ?? 3;
|
||||
const move = chooseMove(this.gs, 'black', skill);
|
||||
if (!move) return;
|
||||
this.executeMove(move, 'black');
|
||||
}
|
||||
|
||||
animateCapture(sq) {
|
||||
const obj = this.pieceObjs.find((o) => o.r === sq[0] && o.c === sq[1]);
|
||||
if (!obj) return;
|
||||
obj.container.setDepth(DEPTH.moving - 1);
|
||||
this.tweens.add({
|
||||
targets: obj.container, scaleX: 0, scaleY: 0, alpha: 0, angle: 60,
|
||||
duration: 230, ease: 'Back.easeIn',
|
||||
});
|
||||
}
|
||||
|
||||
animateArc(container, from, to, onComplete) {
|
||||
if (!container) { onComplete(); return; }
|
||||
const midX = (from.x + to.x) / 2;
|
||||
const midY = Math.min(from.y, to.y) - 60;
|
||||
const prog = { t: 0 };
|
||||
this.tweens.add({
|
||||
targets: prog, t: 1, duration: 320, ease: 'Cubic.easeInOut',
|
||||
onUpdate: () => {
|
||||
const t = prog.t, 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;
|
||||
this.tweens.add({
|
||||
targets: container, scaleX: 1.12, scaleY: 0.9, duration: 60, yoyo: true, ease: 'Quad.easeOut',
|
||||
onComplete: () => { playSound(this, SFX.PIECE_CLICK); onComplete(); },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Banners / overlays ──────────────────────────────────────────────────────
|
||||
|
||||
showTurnBanner(text) {
|
||||
const cx = BX + BOARD / 2;
|
||||
const banner = this.add.text(cx, BY - 70, text, {
|
||||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex,
|
||||
backgroundColor: '#111923ee', padding: { x: 28, y: 12 },
|
||||
}).setOrigin(0.5).setDepth(DEPTH.banner);
|
||||
this.tweens.add({
|
||||
targets: banner, y: BY - 14, duration: 320, ease: 'Back.easeOut',
|
||||
onComplete: () => {
|
||||
this.time.delayedCall(1100, () => {
|
||||
this.tweens.add({ targets: banner, y: BY - 70, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onGameOver() {
|
||||
const st = this.gs.status;
|
||||
const winner = this.gs.winner;
|
||||
const isHuman = winner === 'white';
|
||||
const isDraw = winner === 'draw';
|
||||
const name = this.opponents[0]?.name ?? 'Opponent';
|
||||
this.playOpponentEmotion(isHuman ? 'upset' : isDraw ? 'happy' : 'happy');
|
||||
const cx = BX + BOARD / 2, cy = BY + BOARD / 2;
|
||||
|
||||
if (isHuman) {
|
||||
const emitter = this.add.particles(cx, cy, 'chessParticle', {
|
||||
speed: { min: 150, max: 500 }, lifespan: 1400,
|
||||
scale: { start: 1.5, end: 0 }, alpha: { start: 1, end: 0 },
|
||||
quantity: 5, frequency: 25, angle: { min: 0, max: 360 },
|
||||
tint: [C.sel, 0xffffff, COLORS.accent],
|
||||
}).setDepth(DEPTH.banner);
|
||||
this.time.delayedCall(2000, () => emitter.destroy());
|
||||
}
|
||||
|
||||
this.time.delayedCall(450, () => {
|
||||
let msg;
|
||||
if (st === 'stalemate') msg = 'Stalemate.\nThe game is a draw.';
|
||||
else if (st === 'draw') msg = 'Draw.\nNeither side can force a win.';
|
||||
else if (isHuman) msg = '🎉 Checkmate — You Win!';
|
||||
else msg = `Checkmate.\n${name} wins this game.`;
|
||||
|
||||
const overlay = this.add.rectangle(cx, cy, 720, 300, 0x0a0e14, 0.9)
|
||||
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
|
||||
const txt = this.add.text(cx, cy - 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, cx - 90, cy + 80, 'Play Again', () => {
|
||||
overlay.destroy(); txt.destroy(); this.initGame();
|
||||
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
new Button(this, cx + 90, cy + 80, 'Leave', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
// Pure chess logic — no Phaser dependency.
|
||||
//
|
||||
// Board: 8×8 array, board[r][c] = null | { type, color }.
|
||||
// type ∈ 'p','n','b','r','q','k'; color ∈ 'white','black'.
|
||||
// Row 0 is the top (Black's back rank); row 7 is the bottom (White's back rank).
|
||||
// White (the human) moves up the board (decreasing row); Black (AI) moves down.
|
||||
//
|
||||
// Move shape:
|
||||
// { from:[r,c], to:[r,c], piece, capture:[r,c]|null, promotion:'q'|'r'|'b'|'n'|null,
|
||||
// castle:'K'|'Q'|null, isEnPassant:bool, isDouble:bool }
|
||||
|
||||
export const SIZE = 8;
|
||||
|
||||
const KNIGHT = [[-2, -1], [-2, 1], [-1, -2], [-1, 2], [1, -2], [1, 2], [2, -1], [2, 1]];
|
||||
const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
|
||||
const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
||||
const ALL8 = [...DIAG, ...ORTH];
|
||||
|
||||
function inB(r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
|
||||
export function other(color) { return color === 'white' ? 'black' : 'white'; }
|
||||
|
||||
export function cloneState(state) {
|
||||
return {
|
||||
board: state.board.map((row) => row.map((cell) => (cell ? { ...cell } : null))),
|
||||
turn: state.turn,
|
||||
castling: { ...state.castling },
|
||||
enPassant: state.enPassant ? [...state.enPassant] : null,
|
||||
halfmove: state.halfmove,
|
||||
winner: state.winner,
|
||||
status: state.status,
|
||||
};
|
||||
}
|
||||
|
||||
export function createInitialState() {
|
||||
const back = ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'];
|
||||
const board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
board[0][c] = { type: back[c], color: 'black' };
|
||||
board[1][c] = { type: 'p', color: 'black' };
|
||||
board[6][c] = { type: 'p', color: 'white' };
|
||||
board[7][c] = { type: back[c], color: 'white' };
|
||||
}
|
||||
return {
|
||||
board, turn: 'white',
|
||||
castling: { wK: true, wQ: true, bK: true, bQ: true },
|
||||
enPassant: null, halfmove: 0, winner: null, status: 'playing',
|
||||
};
|
||||
}
|
||||
|
||||
function homeRow(color) { return color === 'white' ? 7 : 0; }
|
||||
|
||||
// ── Attack detection ─────────────────────────────────────────────────────────
|
||||
|
||||
export function isSquareAttacked(board, tr, tc, byColor) {
|
||||
// Pawns
|
||||
const pd = byColor === 'white' ? 1 : -1; // attacker sits one row toward its own side
|
||||
for (const dc of [-1, 1]) {
|
||||
const pr = tr + pd, pc = tc + dc;
|
||||
if (inB(pr, pc)) {
|
||||
const p = board[pr][pc];
|
||||
if (p && p.color === byColor && p.type === 'p') return true;
|
||||
}
|
||||
}
|
||||
// Knights
|
||||
for (const [dr, dc] of KNIGHT) {
|
||||
const r = tr + dr, c = tc + dc;
|
||||
if (inB(r, c)) {
|
||||
const p = board[r][c];
|
||||
if (p && p.color === byColor && p.type === 'n') return true;
|
||||
}
|
||||
}
|
||||
// King
|
||||
for (const [dr, dc] of ALL8) {
|
||||
const r = tr + dr, c = tc + dc;
|
||||
if (inB(r, c)) {
|
||||
const p = board[r][c];
|
||||
if (p && p.color === byColor && p.type === 'k') return true;
|
||||
}
|
||||
}
|
||||
// Diagonal sliders (bishop / queen)
|
||||
for (const [dr, dc] of DIAG) {
|
||||
let r = tr + dr, c = tc + dc;
|
||||
while (inB(r, c)) {
|
||||
const p = board[r][c];
|
||||
if (p) {
|
||||
if (p.color === byColor && (p.type === 'b' || p.type === 'q')) return true;
|
||||
break;
|
||||
}
|
||||
r += dr; c += dc;
|
||||
}
|
||||
}
|
||||
// Orthogonal sliders (rook / queen)
|
||||
for (const [dr, dc] of ORTH) {
|
||||
let r = tr + dr, c = tc + dc;
|
||||
while (inB(r, c)) {
|
||||
const p = board[r][c];
|
||||
if (p) {
|
||||
if (p.color === byColor && (p.type === 'r' || p.type === 'q')) return true;
|
||||
break;
|
||||
}
|
||||
r += dr; c += dc;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findKing(board, color) {
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const p = board[r][c];
|
||||
if (p && p.type === 'k' && p.color === color) return [r, c];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isKingAttacked(board, color) {
|
||||
const k = findKing(board, color);
|
||||
return k ? isSquareAttacked(board, k[0], k[1], other(color)) : false;
|
||||
}
|
||||
|
||||
// ── Pseudo-legal move generation ─────────────────────────────────────────────
|
||||
|
||||
function pushPawn(moves, from, to, promoRow, extra = {}) {
|
||||
if (to[0] === promoRow) {
|
||||
for (const pr of ['q', 'r', 'b', 'n']) {
|
||||
moves.push({ from, to, piece: 'p', capture: extra.capture ?? null, promotion: pr,
|
||||
castle: null, isEnPassant: !!extra.isEnPassant, isDouble: false });
|
||||
}
|
||||
} else {
|
||||
moves.push({ from, to, piece: 'p', capture: extra.capture ?? null, promotion: null,
|
||||
castle: null, isEnPassant: !!extra.isEnPassant, isDouble: !!extra.isDouble });
|
||||
}
|
||||
}
|
||||
|
||||
function generatePseudoMoves(state) {
|
||||
const { board, turn: color, enPassant, castling } = state;
|
||||
const moves = [];
|
||||
const dir = color === 'white' ? -1 : 1;
|
||||
const startRow = color === 'white' ? 6 : 1;
|
||||
const promoRow = color === 'white' ? 0 : 7;
|
||||
|
||||
for (let r = 0; r < SIZE; r++) {
|
||||
for (let c = 0; c < SIZE; c++) {
|
||||
const p = board[r][c];
|
||||
if (!p || p.color !== color) continue;
|
||||
const from = [r, c];
|
||||
|
||||
if (p.type === 'p') {
|
||||
const one = r + dir;
|
||||
if (inB(one, c) && board[one][c] === null) {
|
||||
pushPawn(moves, from, [one, c], promoRow);
|
||||
const two = r + 2 * dir;
|
||||
if (r === startRow && board[two][c] === null) {
|
||||
moves.push({ from, to: [two, c], piece: 'p', capture: null, promotion: null,
|
||||
castle: null, isEnPassant: false, isDouble: true });
|
||||
}
|
||||
}
|
||||
for (const dc of [-1, 1]) {
|
||||
const nr = r + dir, nc = c + dc;
|
||||
if (!inB(nr, nc)) continue;
|
||||
const t = board[nr][nc];
|
||||
if (t && t.color !== color) {
|
||||
pushPawn(moves, from, [nr, nc], promoRow, { capture: [nr, nc] });
|
||||
} else if (enPassant && enPassant[0] === nr && enPassant[1] === nc) {
|
||||
pushPawn(moves, from, [nr, nc], promoRow, { capture: [r, nc], isEnPassant: true });
|
||||
}
|
||||
}
|
||||
} else if (p.type === 'n') {
|
||||
for (const [dr, dc] of KNIGHT) addStep(moves, board, from, r + dr, c + dc, color);
|
||||
} else if (p.type === 'k') {
|
||||
for (const [dr, dc] of ALL8) addStep(moves, board, from, r + dr, c + dc, color);
|
||||
addCastling(moves, state, color);
|
||||
} else {
|
||||
const dirs = p.type === 'b' ? DIAG : p.type === 'r' ? ORTH : ALL8;
|
||||
for (const [dr, dc] of dirs) {
|
||||
let nr = r + dr, nc = c + dc;
|
||||
while (inB(nr, nc)) {
|
||||
const t = board[nr][nc];
|
||||
if (t === null) {
|
||||
moves.push({ from, to: [nr, nc], piece: p.type, capture: null, promotion: null, castle: null, isEnPassant: false, isDouble: false });
|
||||
} else {
|
||||
if (t.color !== color) moves.push({ from, to: [nr, nc], piece: p.type, capture: [nr, nc], promotion: null, castle: null, isEnPassant: false, isDouble: false });
|
||||
break;
|
||||
}
|
||||
nr += dr; nc += dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return moves;
|
||||
}
|
||||
|
||||
function addStep(moves, board, from, nr, nc, color) {
|
||||
if (!inB(nr, nc)) return;
|
||||
const t = board[nr][nc];
|
||||
if (t === null) {
|
||||
moves.push({ from, to: [nr, nc], piece: board[from[0]][from[1]].type, capture: null, promotion: null, castle: null, isEnPassant: false, isDouble: false });
|
||||
} else if (t.color !== color) {
|
||||
moves.push({ from, to: [nr, nc], piece: board[from[0]][from[1]].type, capture: [nr, nc], promotion: null, castle: null, isEnPassant: false, isDouble: false });
|
||||
}
|
||||
}
|
||||
|
||||
function addCastling(moves, state, color) {
|
||||
const { board, castling } = state;
|
||||
const row = homeRow(color);
|
||||
const enemy = other(color);
|
||||
// King must be home and not currently in check.
|
||||
if (isSquareAttacked(board, row, 4, enemy)) return;
|
||||
const kingHome = board[row][4];
|
||||
if (!kingHome || kingHome.type !== 'k' || kingHome.color !== color) return;
|
||||
|
||||
const kRight = color === 'white' ? castling.wK : castling.bK;
|
||||
const qRight = color === 'white' ? castling.wQ : castling.bQ;
|
||||
|
||||
if (kRight && board[row][5] === null && board[row][6] === null
|
||||
&& board[row][7] && board[row][7].type === 'r' && board[row][7].color === color
|
||||
&& !isSquareAttacked(board, row, 5, enemy) && !isSquareAttacked(board, row, 6, enemy)) {
|
||||
moves.push({ from: [row, 4], to: [row, 6], piece: 'k', capture: null, promotion: null, castle: 'K', isEnPassant: false, isDouble: false });
|
||||
}
|
||||
if (qRight && board[row][1] === null && board[row][2] === null && board[row][3] === null
|
||||
&& board[row][0] && board[row][0].type === 'r' && board[row][0].color === color
|
||||
&& !isSquareAttacked(board, row, 3, enemy) && !isSquareAttacked(board, row, 2, enemy)) {
|
||||
moves.push({ from: [row, 4], to: [row, 2], piece: 'k', capture: null, promotion: null, castle: 'Q', isEnPassant: false, isDouble: false });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Applying moves ───────────────────────────────────────────────────────────
|
||||
|
||||
function clearRight(castling, sq) {
|
||||
if (sq[0] === 7 && sq[1] === 0) castling.wQ = false;
|
||||
else if (sq[0] === 7 && sq[1] === 7) castling.wK = false;
|
||||
else if (sq[0] === 0 && sq[1] === 0) castling.bQ = false;
|
||||
else if (sq[0] === 0 && sq[1] === 7) castling.bK = false;
|
||||
}
|
||||
|
||||
// Apply a move without computing resulting status (used by search + applyMove).
|
||||
export function applyMoveRaw(state, move) {
|
||||
const s = cloneState(state);
|
||||
const b = s.board;
|
||||
const color = s.turn;
|
||||
const piece = b[move.from[0]][move.from[1]];
|
||||
s.enPassant = null;
|
||||
|
||||
if (move.capture) b[move.capture[0]][move.capture[1]] = null;
|
||||
b[move.from[0]][move.from[1]] = null;
|
||||
const placed = { type: move.promotion ?? piece.type, color };
|
||||
b[move.to[0]][move.to[1]] = placed;
|
||||
|
||||
if (move.castle === 'K') {
|
||||
const row = move.from[0];
|
||||
b[row][5] = b[row][7]; b[row][7] = null;
|
||||
} else if (move.castle === 'Q') {
|
||||
const row = move.from[0];
|
||||
b[row][3] = b[row][0]; b[row][0] = null;
|
||||
}
|
||||
|
||||
if (move.isDouble) {
|
||||
s.enPassant = [(move.from[0] + move.to[0]) / 2, move.to[1]];
|
||||
}
|
||||
|
||||
// Castling rights
|
||||
if (piece.type === 'k') {
|
||||
if (color === 'white') { s.castling.wK = false; s.castling.wQ = false; }
|
||||
else { s.castling.bK = false; s.castling.bQ = false; }
|
||||
} else if (piece.type === 'r') {
|
||||
clearRight(s.castling, move.from);
|
||||
}
|
||||
if (move.capture) clearRight(s.castling, move.capture);
|
||||
|
||||
s.halfmove = (piece.type === 'p' || move.capture) ? 0 : s.halfmove + 1;
|
||||
s.turn = other(color);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Legal moves for the side to move (optionally only from a given square).
|
||||
export function getLegalMoves(state, from = null) {
|
||||
const pseudo = generatePseudoMoves(state);
|
||||
const color = state.turn;
|
||||
const legal = [];
|
||||
for (const m of pseudo) {
|
||||
const ns = applyMoveRaw(state, m);
|
||||
if (!isKingAttacked(ns.board, color)) legal.push(m);
|
||||
}
|
||||
if (from) return legal.filter((m) => m.from[0] === from[0] && m.from[1] === from[1]);
|
||||
return legal;
|
||||
}
|
||||
|
||||
function insufficientMaterial(board) {
|
||||
const minors = [];
|
||||
for (const row of board) {
|
||||
for (const p of row) {
|
||||
if (!p || p.type === 'k') continue;
|
||||
if (p.type === 'p' || p.type === 'r' || p.type === 'q') return false;
|
||||
minors.push(p.type);
|
||||
}
|
||||
}
|
||||
return minors.length <= 1; // K vs K, or K+minor vs K
|
||||
}
|
||||
|
||||
export function computeStatus(state) {
|
||||
const inCheck = isKingAttacked(state.board, state.turn);
|
||||
const hasMoves = getLegalMoves(state).length > 0;
|
||||
if (!hasMoves) {
|
||||
if (inCheck) return { status: 'checkmate', winner: other(state.turn) };
|
||||
return { status: 'stalemate', winner: 'draw' };
|
||||
}
|
||||
if (insufficientMaterial(state.board)) return { status: 'draw', winner: 'draw' };
|
||||
if (state.halfmove >= 100) return { status: 'draw', winner: 'draw' };
|
||||
return { status: inCheck ? 'check' : 'playing', winner: null };
|
||||
}
|
||||
|
||||
// Apply a move and compute the resulting game status (used by the scene).
|
||||
export function applyMove(state, move) {
|
||||
const ns = applyMoveRaw(state, move);
|
||||
const { status, winner } = computeStatus(ns);
|
||||
ns.status = status;
|
||||
ns.winner = winner;
|
||||
return ns;
|
||||
}
|
||||
|
||||
export function isGameOver(state) {
|
||||
return state.status === 'checkmate' || state.status === 'stalemate' || state.status === 'draw';
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
// Custom vector chess pieces drawn with Phaser graphics — no art assets.
|
||||
// Each piece is a stylized silhouette (fill + dark outline + soft highlight)
|
||||
// over a drop shadow, tinted ivory for white and charcoal for black.
|
||||
//
|
||||
// makePiece(scene, type, color, x, y, size) → Phaser.Container
|
||||
|
||||
const WHITE = { fill: 0xf2ead2, hi: 0xfffdf3, ol: 0x8a7c52 };
|
||||
const BLACK = { fill: 0x2b2833, hi: 0x55506a, ol: 0x05040a };
|
||||
|
||||
function poly(g, pts, pal) {
|
||||
g.fillStyle(pal.fill, 1);
|
||||
g.fillPoints(pts, true);
|
||||
g.lineStyle(2, pal.ol, 1);
|
||||
g.strokePoints(pts, true);
|
||||
}
|
||||
|
||||
function P(s, x, y) { return { x: x * s, y: y * s }; }
|
||||
|
||||
function pedestal(g, s, pal) {
|
||||
g.fillStyle(pal.fill, 1);
|
||||
g.fillRoundedRect(-0.26 * s, 0.34 * s, 0.52 * s, 0.12 * s, 4);
|
||||
g.lineStyle(2, pal.ol, 1);
|
||||
g.strokeRoundedRect(-0.26 * s, 0.34 * s, 0.52 * s, 0.12 * s, 4);
|
||||
g.fillStyle(pal.fill, 1);
|
||||
g.fillRoundedRect(-0.20 * s, 0.28 * s, 0.40 * s, 0.07 * s, 3);
|
||||
g.lineStyle(2, pal.ol, 1);
|
||||
g.strokeRoundedRect(-0.20 * s, 0.28 * s, 0.40 * s, 0.07 * s, 3);
|
||||
}
|
||||
|
||||
function body(g, s, pal, topW, botW) {
|
||||
poly(g, [P(s, -topW, 0.02), P(s, topW, 0.02), P(s, botW, 0.30), P(s, -botW, 0.30)], pal);
|
||||
}
|
||||
|
||||
function sheen(g, s, pal) {
|
||||
g.fillStyle(pal.hi, 0.3);
|
||||
g.fillEllipse(-0.10 * s, 0.02 * s, 0.10 * s, 0.34 * s);
|
||||
}
|
||||
|
||||
const DRAW = {
|
||||
p(g, s, pal) {
|
||||
pedestal(g, s, pal);
|
||||
body(g, s, pal, 0.10, 0.16);
|
||||
g.fillStyle(pal.fill, 1); g.fillCircle(0, -0.12 * s, 0.14 * s);
|
||||
g.lineStyle(2, pal.ol, 1); g.strokeCircle(0, -0.12 * s, 0.14 * s);
|
||||
g.fillStyle(pal.hi, 0.4); g.fillCircle(-0.04 * s, -0.16 * s, 0.05 * s);
|
||||
},
|
||||
|
||||
r(g, s, pal) {
|
||||
pedestal(g, s, pal);
|
||||
body(g, s, pal, 0.13, 0.18);
|
||||
poly(g, [
|
||||
P(s, -0.20, -0.02), P(s, -0.20, -0.22), P(s, -0.13, -0.22), P(s, -0.13, -0.13),
|
||||
P(s, -0.05, -0.13), P(s, -0.05, -0.22), P(s, 0.05, -0.22), P(s, 0.05, -0.13),
|
||||
P(s, 0.13, -0.13), P(s, 0.13, -0.22), P(s, 0.20, -0.22), P(s, 0.20, -0.02),
|
||||
], pal);
|
||||
sheen(g, s, pal);
|
||||
},
|
||||
|
||||
n(g, s, pal) {
|
||||
pedestal(g, s, pal);
|
||||
poly(g, [
|
||||
P(s, -0.18, 0.30), P(s, -0.21, 0.08), P(s, -0.10, -0.08), P(s, -0.17, -0.20),
|
||||
P(s, -0.06, -0.34), P(s, 0.06, -0.40), P(s, 0.11, -0.30), P(s, 0.21, -0.18),
|
||||
P(s, 0.23, -0.05), P(s, 0.10, -0.03), P(s, 0.05, 0.05), P(s, 0.12, 0.16),
|
||||
P(s, 0.18, 0.30),
|
||||
], pal);
|
||||
g.fillStyle(pal.ol, 1); g.fillCircle(0.04 * s, -0.22 * s, 0.025 * s); // eye
|
||||
g.lineStyle(2, pal.ol, 0.7);
|
||||
g.lineBetween(-0.04 * s, -0.30 * s, -0.10 * s, -0.16 * s); // mane
|
||||
},
|
||||
|
||||
b(g, s, pal) {
|
||||
pedestal(g, s, pal);
|
||||
body(g, s, pal, 0.09, 0.16);
|
||||
g.fillStyle(pal.fill, 1); g.fillEllipse(0, -0.14 * s, 0.24 * s, 0.34 * s);
|
||||
g.lineStyle(2, pal.ol, 1); g.strokeEllipse(0, -0.14 * s, 0.24 * s, 0.34 * s);
|
||||
g.lineStyle(2, pal.ol, 1); g.lineBetween(0.03 * s, -0.30 * s, -0.05 * s, -0.16 * s); // slit
|
||||
g.fillStyle(pal.fill, 1); g.fillCircle(0, -0.34 * s, 0.05 * s);
|
||||
g.lineStyle(2, pal.ol, 1); g.strokeCircle(0, -0.34 * s, 0.05 * s);
|
||||
sheen(g, s, pal);
|
||||
},
|
||||
|
||||
q(g, s, pal) {
|
||||
pedestal(g, s, pal);
|
||||
body(g, s, pal, 0.11, 0.18);
|
||||
// crown band
|
||||
g.fillStyle(pal.fill, 1); g.fillRoundedRect(-0.19 * s, -0.18 * s, 0.38 * s, 0.10 * s, 4);
|
||||
g.lineStyle(2, pal.ol, 1); g.strokeRoundedRect(-0.19 * s, -0.18 * s, 0.38 * s, 0.10 * s, 4);
|
||||
// five spikes with balls
|
||||
const xs = [-0.17, -0.085, 0, 0.085, 0.17];
|
||||
for (const x of xs) {
|
||||
poly(g, [P(s, x - 0.045, -0.18), P(s, x + 0.045, -0.18), P(s, x, -0.36)], pal);
|
||||
g.fillStyle(pal.fill, 1); g.fillCircle(x * s, -0.37 * s, 0.045 * s);
|
||||
g.lineStyle(2, pal.ol, 1); g.strokeCircle(x * s, -0.37 * s, 0.045 * s);
|
||||
}
|
||||
sheen(g, s, pal);
|
||||
},
|
||||
|
||||
k(g, s, pal) {
|
||||
pedestal(g, s, pal);
|
||||
body(g, s, pal, 0.11, 0.18);
|
||||
// crown band
|
||||
g.fillStyle(pal.fill, 1); g.fillRoundedRect(-0.18 * s, -0.20 * s, 0.36 * s, 0.10 * s, 4);
|
||||
g.lineStyle(2, pal.ol, 1); g.strokeRoundedRect(-0.18 * s, -0.20 * s, 0.36 * s, 0.10 * s, 4);
|
||||
// shoulder bumps
|
||||
poly(g, [P(s, -0.18, -0.10), P(s, -0.10, -0.20), P(s, -0.02, -0.10)], pal);
|
||||
poly(g, [P(s, 0.02, -0.10), P(s, 0.10, -0.20), P(s, 0.18, -0.10)], pal);
|
||||
// cross
|
||||
g.fillStyle(pal.fill, 1);
|
||||
g.fillRoundedRect(-0.035 * s, -0.46 * s, 0.07 * s, 0.26 * s, 3);
|
||||
g.fillRoundedRect(-0.11 * s, -0.40 * s, 0.22 * s, 0.06 * s, 3);
|
||||
g.lineStyle(2, pal.ol, 1);
|
||||
g.strokeRoundedRect(-0.035 * s, -0.46 * s, 0.07 * s, 0.26 * s, 3);
|
||||
g.strokeRoundedRect(-0.11 * s, -0.40 * s, 0.22 * s, 0.06 * s, 3);
|
||||
sheen(g, s, pal);
|
||||
},
|
||||
};
|
||||
|
||||
export function makePiece(scene, type, color, x, y, size) {
|
||||
const pal = color === 'white' ? WHITE : BLACK;
|
||||
const shadow = scene.add.graphics();
|
||||
shadow.fillStyle(0x000000, 0.28);
|
||||
shadow.fillEllipse(3, 0.42 * size + 3, 0.56 * size, 0.15 * size);
|
||||
const g = scene.add.graphics();
|
||||
(DRAW[type] ?? DRAW.p)(g, size, pal);
|
||||
return scene.add.container(x, y, [shadow, g]);
|
||||
}
|
||||
|
|
@ -79,12 +79,19 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this._handOrder = null;
|
||||
this.oppHandSprites = {};
|
||||
this.oppInPlaySprites = {};
|
||||
this.phaseDials = [];
|
||||
this.turnArrow = null;
|
||||
this._arrowSeat = null;
|
||||
this._boughtThisTurn = false;
|
||||
this._suppressTurnUi = false;
|
||||
}
|
||||
|
||||
create() {
|
||||
new MusicPlayer(this, this.cache.json.get('music').tracks);
|
||||
this.buildBackground();
|
||||
this.buildPortraits();
|
||||
this.buildTurnArrow();
|
||||
this.buildPhaseDials();
|
||||
this.buildHoverPopup();
|
||||
this.buildButtons();
|
||||
this.animLayer = this.add.container(0, 0).setDepth(D.hand + 50);
|
||||
|
|
@ -103,6 +110,8 @@ export default class DominionGame extends Phaser.Scene {
|
|||
|
||||
this.events.once('shutdown', () => {
|
||||
this.portraits.forEach((pt) => pt?.destroy?.());
|
||||
this.turnArrow?.destroy();
|
||||
this.phaseDials.forEach((d) => d?.destroy?.());
|
||||
});
|
||||
|
||||
const initialState = createInitialState({
|
||||
|
|
@ -180,6 +189,218 @@ export default class DominionGame extends Phaser.Scene {
|
|||
return this.opponents[seat - 1]?.skill ?? 3;
|
||||
}
|
||||
|
||||
// ── Turn arrow + phase dials (persistent overlay) ─────────────────────────────
|
||||
|
||||
// Yellow turn arrow, mirroring the Settlers of Catan indicator.
|
||||
buildTurnArrow() {
|
||||
const g = this.add.graphics().setDepth(D.portrait + 5);
|
||||
g.fillStyle(0xffdd00, 1);
|
||||
g.fillTriangle(-12, -15, -12, 15, 12, 0);
|
||||
g.setPosition(-9999, -9999);
|
||||
this.turnArrow = g;
|
||||
this._arrowSeat = null;
|
||||
this.tweens.add({
|
||||
targets: g, scaleX: 1.4, scaleY: 1.4, duration: 700,
|
||||
yoyo: true, repeat: -1, ease: 'Sine.InOut',
|
||||
});
|
||||
}
|
||||
|
||||
// Portrait anchor the arrow points at, by seat.
|
||||
arrowSeatPos(seat) {
|
||||
if (seat === 0) return { x: 92, y: 928, r: 56 };
|
||||
const s = this.oppSlot(seat - 1);
|
||||
return { x: s.x, y: s.y, r: s.r };
|
||||
}
|
||||
|
||||
buildPhaseDials() {
|
||||
this.phaseDials[0] = this.makePhaseDial(168, 748, 60, 60);
|
||||
this.opponents.forEach((opp, i) => {
|
||||
const s = this.oppSlot(i);
|
||||
const r = 26;
|
||||
this.phaseDials[i + 1] = this.makePhaseDial(s.x + s.r + 14 + r, s.y, r);
|
||||
});
|
||||
}
|
||||
|
||||
// A 3-wedge ring (Action/Buy/Clean Up) that spins so the active wedge locks
|
||||
// under a fixed top pointer. Returns a controller with setActive/setPhase.
|
||||
makePhaseDial(x, y, outerR, labelDY = 0) {
|
||||
const scene = this;
|
||||
const PHASE = { action: 0, buy: 1, cleanup: 2 };
|
||||
const colors = [0xd0563b, 0xd4a017, 0x4a90d9];
|
||||
const TWO_PI_3 = (Math.PI * 2) / 3;
|
||||
const HALF = Math.PI / 3;
|
||||
const TOP = -Math.PI / 2;
|
||||
|
||||
const container = this.add.container(x, y).setDepth(D.portrait);
|
||||
const ring = this.add.container(0, 0);
|
||||
container.add(ring);
|
||||
|
||||
const a0 = [], a1 = [], mid = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const c = TOP + i * TWO_PI_3;
|
||||
a0[i] = c - HALF; a1[i] = c + HALF; mid[i] = c;
|
||||
}
|
||||
|
||||
const wedges = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = this.add.graphics();
|
||||
ring.add(w);
|
||||
wedges.push(w);
|
||||
}
|
||||
const paintWedges = (activeIdx) => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = wedges[i];
|
||||
const on = i === activeIdx;
|
||||
w.clear();
|
||||
w.fillStyle(colors[i], on ? 0.95 : 0.26);
|
||||
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.fillPath();
|
||||
w.lineStyle(on ? Math.max(2, outerR * 0.05) : 1.5, on ? colors[i] : COLORS.accent, on ? 1 : 0.55);
|
||||
w.beginPath(); w.moveTo(0, 0); w.arc(0, 0, outerR, a0[i], a1[i], false); w.closePath(); w.strokePath();
|
||||
}
|
||||
};
|
||||
paintWedges(-1);
|
||||
|
||||
const ic = outerR * 0.22;
|
||||
const midR = outerR * 0.66;
|
||||
const drawSword = (g) => {
|
||||
g.fillStyle(0xf2ead8, 1);
|
||||
g.fillTriangle(-0.16 * ic, -0.5 * ic, 0.16 * ic, -0.5 * ic, 0, -1.15 * ic);
|
||||
g.fillRect(-0.14 * ic, -0.5 * ic, 0.28 * ic, 0.85 * ic);
|
||||
g.fillStyle(0xc8a84b, 1);
|
||||
g.fillRect(-0.5 * ic, 0.3 * ic, ic, 0.16 * ic);
|
||||
g.fillRect(-0.12 * ic, 0.46 * ic, 0.24 * ic, 0.5 * ic);
|
||||
};
|
||||
const drawCoin = (g) => {
|
||||
g.fillStyle(0xd4a017, 1); g.fillCircle(0, 0, 0.95 * ic);
|
||||
g.lineStyle(Math.max(1, 0.14 * ic), 0x6e5410, 1); g.strokeCircle(0, 0, 0.95 * ic);
|
||||
g.lineStyle(Math.max(1, 0.14 * ic), 0xfff3c4, 0.9); g.strokeCircle(0, 0, 0.5 * ic);
|
||||
};
|
||||
const drawRefresh = (g) => {
|
||||
g.lineStyle(Math.max(1.5, 0.2 * ic), 0xf2ead8, 1);
|
||||
g.beginPath(); g.arc(0, 0, 0.85 * ic, Phaser.Math.DegToRad(-50), Phaser.Math.DegToRad(200), false); g.strokePath();
|
||||
const end = Phaser.Math.DegToRad(200);
|
||||
const ex = Math.cos(end) * 0.85 * ic, ey = Math.sin(end) * 0.85 * ic;
|
||||
g.fillStyle(0xf2ead8, 1);
|
||||
g.fillTriangle(ex - 0.45 * ic, ey - 0.1 * ic, ex + 0.1 * ic, ey - 0.5 * ic, ex + 0.15 * ic, ey + 0.35 * ic);
|
||||
};
|
||||
const drawers = [drawSword, drawCoin, drawRefresh];
|
||||
const icons = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const g = this.add.graphics();
|
||||
drawers[i](g);
|
||||
g.setPosition(Math.cos(mid[i]) * midR, Math.sin(mid[i]) * midR);
|
||||
ring.add(g);
|
||||
icons.push(g);
|
||||
}
|
||||
|
||||
const hub = this.add.graphics();
|
||||
hub.fillStyle(0x141008, 0.88); hub.fillCircle(0, 0, outerR * 0.42);
|
||||
hub.lineStyle(Math.max(1.5, outerR * 0.03), COLORS.accent, 0.7); hub.strokeCircle(0, 0, outerR * 0.42);
|
||||
container.add(hub);
|
||||
|
||||
const pointer = this.add.graphics();
|
||||
const pw = outerR * 0.16, ph = outerR * 0.3, ty = -outerR;
|
||||
pointer.fillStyle(COLORS.gold, 1);
|
||||
pointer.fillTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
|
||||
pointer.lineStyle(1.5, COLORS.textDark, 0.6);
|
||||
pointer.strokeTriangle(-pw, ty - ph, pw, ty - ph, 0, ty + ph * 0.5);
|
||||
container.add(pointer);
|
||||
|
||||
const names = ['Action Phase', 'Buy Phase', 'Clean-up Phase'];
|
||||
const nameHex = ['#d0563b', '#d4a017', '#4a90d9'];
|
||||
const labelSize = Math.max(12, Math.round(outerR * 0.36));
|
||||
const label = this.add.text(0, -(outerR + labelSize) - labelDY, '', {
|
||||
fontFamily: 'Righteous', fontSize: `${labelSize}px`, color: nameHex[0],
|
||||
}).setOrigin(0.5).setVisible(false);
|
||||
label.setShadow(0, 2, '#000000', 4, false, true);
|
||||
container.add(label);
|
||||
const applyLabel = (idx) => { label.setText(names[idx]); label.setColor(nameHex[idx]); };
|
||||
|
||||
container.setAlpha(0.38);
|
||||
|
||||
return {
|
||||
container,
|
||||
_active: false,
|
||||
_phaseIdx: 0,
|
||||
_pulse: null,
|
||||
_rot: null,
|
||||
setActive(on) {
|
||||
if (this._active === on) return;
|
||||
this._active = on;
|
||||
scene.tweens.add({ targets: container, alpha: on ? 1 : 0.38, duration: 300, ease: 'Sine.Out' });
|
||||
if (on) {
|
||||
paintWedges(this._phaseIdx);
|
||||
applyLabel(this._phaseIdx);
|
||||
label.setVisible(true);
|
||||
this._pulse?.remove();
|
||||
this._pulse = scene.tweens.add({
|
||||
targets: container, scaleX: 1.06, scaleY: 1.06,
|
||||
duration: 900, yoyo: true, repeat: -1, ease: 'Sine.InOut',
|
||||
});
|
||||
} else {
|
||||
this._pulse?.remove(); this._pulse = null;
|
||||
this._rot?.remove(); this._rot = null;
|
||||
scene.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 200 });
|
||||
ring.rotation = 0;
|
||||
icons.forEach((g) => { g.rotation = 0; });
|
||||
this._phaseIdx = 0;
|
||||
paintWedges(-1);
|
||||
label.setVisible(false);
|
||||
}
|
||||
},
|
||||
setPhase(name) {
|
||||
const idx = PHASE[name] ?? 0;
|
||||
if (idx === this._phaseIdx) return;
|
||||
this._phaseIdx = idx;
|
||||
paintWedges(idx);
|
||||
applyLabel(idx);
|
||||
this._rot?.remove();
|
||||
this._rot = scene.tweens.add({
|
||||
targets: ring, rotation: -idx * TWO_PI_3, duration: 500, ease: 'Cubic.Out',
|
||||
onUpdate: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
|
||||
onComplete: () => { icons.forEach((g) => { g.rotation = -ring.rotation; }); },
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
this._pulse?.remove(); this._rot?.remove();
|
||||
container.destroy(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Reflects whose turn it is (arrow) and the active phase (dials) into the overlay.
|
||||
updateTurnUi() {
|
||||
if (this._suppressTurnUi) {
|
||||
this.phaseDials[0]?.setActive(true);
|
||||
this.phaseDials[0]?.setPhase('cleanup');
|
||||
return;
|
||||
}
|
||||
const gs = this.gs;
|
||||
if (!gs || !this.turnArrow) return;
|
||||
|
||||
if (this.gameOver) {
|
||||
this.turnArrow.setVisible(false);
|
||||
} else {
|
||||
this.turnArrow.setVisible(true);
|
||||
const seat = gs.turn;
|
||||
const pos = this.arrowSeatPos(seat);
|
||||
if (pos && this._arrowSeat !== seat) {
|
||||
this._arrowSeat = seat;
|
||||
const tx = pos.x - pos.r - 18, ty = pos.y;
|
||||
if (this.turnArrow.x < 0) this.turnArrow.setPosition(tx, ty);
|
||||
else this.tweens.add({ targets: this.turnArrow, x: tx, y: ty, duration: 600, ease: 'Cubic.Out' });
|
||||
}
|
||||
}
|
||||
|
||||
for (let seat = 0; seat < this.playerCount; seat++) {
|
||||
const dial = this.phaseDials[seat];
|
||||
if (!dial) continue;
|
||||
const active = !this.gameOver && gs.turn === seat;
|
||||
dial.setActive(active);
|
||||
if (active) dial.setPhase(gs.phase === 'buy' ? 'buy' : 'action');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render (dynamic layer) ───────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
|
|
@ -199,6 +420,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.renderCounts();
|
||||
this.renderHud();
|
||||
this.updateControls();
|
||||
this.updateTurnUi();
|
||||
}
|
||||
|
||||
renderSupply() {
|
||||
|
|
@ -686,6 +908,10 @@ export default class DominionGame extends Phaser.Scene {
|
|||
const gainEvent = newLog.find(e => e.kind === 'gain' && e.seat === 0 && e.dest !== 'hand');
|
||||
|
||||
if (allDiscarded.length > 0) {
|
||||
if (newLog.some(e => e.kind === 'turnEnd' && e.seat === 0)) {
|
||||
this.phaseDials[0]?.setPhase('cleanup');
|
||||
this._suppressTurnUi = true;
|
||||
}
|
||||
const draws = (drawnCards.length > 0 && deckChanged) ? drawnCards : [];
|
||||
this._animDiscardThenDraw(allDiscarded, draws, s);
|
||||
return;
|
||||
|
|
@ -840,17 +1066,35 @@ export default class DominionGame extends Phaser.Scene {
|
|||
|
||||
humanBuy(id) {
|
||||
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
|
||||
this._boughtThisTurn = true;
|
||||
playSound(this, SFX.CARD_SHOW);
|
||||
this.setState(buyCard(this.gs, id));
|
||||
}
|
||||
|
||||
humanEndAction() {
|
||||
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
|
||||
const hand = this.gs.players[0].hand;
|
||||
const hasActionCard = hand.some((c) => isType(c.id, 'action'));
|
||||
const hasActionsLeft = this.gs.players[0].actions > 0;
|
||||
if (hasActionCard && hasActionsLeft) {
|
||||
this.showConfirm('You have unplayed action cards. End Action Phase anyway?', () => {
|
||||
this._boughtThisTurn = false;
|
||||
this.setState(endActionPhase(this.gs));
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._boughtThisTurn = false;
|
||||
this.setState(endActionPhase(this.gs));
|
||||
}
|
||||
|
||||
humanEndTurn() {
|
||||
if (this.gs.pending || this.gs.turn !== 0 || this._animating) return;
|
||||
if (!this._boughtThisTurn) {
|
||||
this.showConfirm('You have not purchased anything this turn. End turn anyway?', () => {
|
||||
this.setState(endTurn(this.gs));
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setState(endTurn(this.gs));
|
||||
}
|
||||
|
||||
|
|
@ -994,6 +1238,25 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.promptButton(CX + 120, 'No', () => cb(false), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
// "Are you sure?" modal — blocks interaction with underlying buttons.
|
||||
showConfirm(message, onConfirm) {
|
||||
this.clearPrompt();
|
||||
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)
|
||||
.setDepth(D.modal).setInteractive();
|
||||
this.promptObjs.push(overlay);
|
||||
this.promptObjs.push(this.add.text(CX, GAME_HEIGHT / 2 - 40, message, {
|
||||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex, align: 'center',
|
||||
wordWrap: { width: 900 },
|
||||
}).setOrigin(0.5).setDepth(D.modal + 1));
|
||||
this.promptObjs.push(new Button(this, CX - 130, GAME_HEIGHT / 2 + 50, 'Confirm', () => {
|
||||
this.clearPrompt();
|
||||
onConfirm();
|
||||
}, { bg: COLORS.accent, bgHover: COLORS.gold, textColor: COLORS.textDarkHex, textHoverColor: COLORS.textDarkHex, width: 200, height: 46, fontSize: 20 }).setDepth(D.modal + 1));
|
||||
this.promptObjs.push(new Button(this, CX + 130, GAME_HEIGHT / 2 + 50, 'Cancel', () => {
|
||||
this.clearPrompt();
|
||||
}, { variant: 'ghost', width: 200, height: 46, fontSize: 20 }).setDepth(D.modal + 1));
|
||||
}
|
||||
|
||||
// Modal list of cards to pick one (harbinger discard, bandit/bureaucrat options).
|
||||
promptPickList(cards, banner, allowSkip, cb) {
|
||||
const overlay = this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.6)
|
||||
|
|
@ -1090,6 +1353,8 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.clearPrompt();
|
||||
this.hideHover();
|
||||
this.updateControls();
|
||||
this.turnArrow?.setVisible(false);
|
||||
this.phaseDials.forEach((d) => d?.setActive(false));
|
||||
|
||||
const scores = finalScores(this.gs);
|
||||
const winners = new Set(this.gs.winnerSeats);
|
||||
|
|
@ -1261,6 +1526,7 @@ export default class DominionGame extends Phaser.Scene {
|
|||
_animOppCleanup(seat, cleanupCards, newCards, newState) {
|
||||
this._animating = true;
|
||||
const slot = this.oppSlot(seat - 1);
|
||||
this.phaseDials[seat]?.setPhase('cleanup');
|
||||
|
||||
// Capture sources before any render wipes the sprite arrays
|
||||
const sources = cleanupCards.map(card => {
|
||||
|
|
@ -1306,12 +1572,23 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.clearPrompt();
|
||||
this.render();
|
||||
const slot = this.oppSlot(seat - 1);
|
||||
|
||||
const cardName = getCard(cardId).name;
|
||||
const label = this.add.text(slot.x - slot.r - 74, slot.y, `Purchased:\n${cardName}`, {
|
||||
fontFamily: 'Righteous', fontSize: '32px', color: '#FFD700',
|
||||
align: 'right', stroke: '#000000', strokeThickness: 4,
|
||||
}).setOrigin(1, 0.5).setDepth(D.hud + 5).setAlpha(1);
|
||||
|
||||
const src = { iid: -1, id: cardId, x: srcX, y: srcY };
|
||||
this._animDiscardCard(src, slot.x, slot.y, () => {
|
||||
this.tweens.add({
|
||||
targets: label, alpha: 0, duration: 600, delay: 400, ease: 'Sine.easeIn',
|
||||
onComplete: () => label.destroy(),
|
||||
});
|
||||
this._animating = false;
|
||||
if (this._pendingAnimState) { const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s); }
|
||||
else this.scheduleAdvance(10);
|
||||
});
|
||||
}, { flyDuration: 1000, foldDuration: 500 });
|
||||
}
|
||||
|
||||
_chainHumanGain(gainEvt, newState) {
|
||||
|
|
@ -1368,6 +1645,8 @@ export default class DominionGame extends Phaser.Scene {
|
|||
if (drawnCards.length > 0) {
|
||||
this._animDrawCards(drawnCards, newState);
|
||||
} else {
|
||||
this._suppressTurnUi = false;
|
||||
this.updateTurnUi();
|
||||
this._animating = false;
|
||||
if (this._pendingAnimState) {
|
||||
const s = this._pendingAnimState; this._pendingAnimState = null; this.setState(s);
|
||||
|
|
@ -1385,27 +1664,24 @@ export default class DominionGame extends Phaser.Scene {
|
|||
animateNext();
|
||||
}
|
||||
|
||||
_animDiscardCard(src, tx, ty, onComplete) {
|
||||
_animDiscardCard(src, tx, ty, onComplete, { flyDuration = 500, foldDuration = 250 } = {}) {
|
||||
const def = getCard(src.id);
|
||||
const fuCard = this.buildCardFace(HAND_W, HAND_H, def);
|
||||
fuCard.setPosition(src.x, src.y);
|
||||
this.animLayer.add(fuCard);
|
||||
|
||||
// Phase 1 (0–500ms): fly face-up to target
|
||||
this.tweens.add({
|
||||
targets: fuCard, x: tx, y: ty, duration: 500, ease: 'Cubic.easeIn',
|
||||
targets: fuCard, x: tx, y: ty, duration: flyDuration, ease: 'Cubic.easeIn',
|
||||
onComplete: () => {
|
||||
// Phase 2 (500–750ms): fold to edge
|
||||
this.tweens.add({
|
||||
targets: fuCard, scaleX: 0, duration: 250, ease: 'Sine.easeIn',
|
||||
targets: fuCard, scaleX: 0, duration: foldDuration, ease: 'Sine.easeIn',
|
||||
onComplete: () => {
|
||||
fuCard.destroy();
|
||||
// Phase 3 (750–1000ms): unfold face-down
|
||||
const fdCard = this.buildCardFace(HAND_W, HAND_H, null, { faceDown: true });
|
||||
fdCard.setPosition(tx, ty).setScale(0, 1);
|
||||
this.animLayer.add(fdCard);
|
||||
this.tweens.add({
|
||||
targets: fdCard, scaleX: 1, duration: 250, ease: 'Sine.easeOut',
|
||||
targets: fdCard, scaleX: 1, duration: foldDuration, ease: 'Sine.easeOut',
|
||||
onComplete: () => { fdCard.destroy(); onComplete(); },
|
||||
});
|
||||
},
|
||||
|
|
@ -1614,6 +1890,8 @@ export default class DominionGame extends Phaser.Scene {
|
|||
this.handSprites.forEach(s => s.face.setAlpha(1));
|
||||
this._animatingIids.clear();
|
||||
this._animating = false;
|
||||
this._suppressTurnUi = false;
|
||||
this.updateTurnUi();
|
||||
|
||||
if (this._pendingAnimState) {
|
||||
const s = this._pendingAnimState;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import NertsGame from './games/nerts/NertsGame.js';
|
|||
import BingoGame from './games/bingo/BingoGame.js';
|
||||
import BaccaratGame from './games/baccarat/BaccaratGame.js';
|
||||
import DominionGame from './games/dominion/DominionGame.js';
|
||||
import CheckersGame from './games/checkers/CheckersGame.js';
|
||||
import ChessGame from './games/chess/ChessGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -71,6 +73,8 @@ const config = {
|
|||
BingoGame,
|
||||
BaccaratGame,
|
||||
DominionGame,
|
||||
CheckersGame,
|
||||
ChessGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -355,9 +355,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
info.appendChild(name);
|
||||
info.appendChild(bio);
|
||||
|
||||
// Skill control (Nerts only): pips always show the level; the +/- buttons
|
||||
// appear only when this opponent is selected.
|
||||
if (this.gameDef.slug === 'nerts') {
|
||||
// Skill control: pips always show the level; the +/- buttons appear only
|
||||
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||
if (['nerts', 'checkers', 'chess'].includes(this.gameDef.slug)) {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -42,3 +42,5 @@ registerGame({ slug: 'nerts', name: 'Nerts', category: 'cards', cardGame: true,
|
|||
registerGame({ slug: 'bingo', name: 'Bingo', category: 'casino', minPlayers: 2, maxPlayers: 11, minOpponents: 1, maxOpponents: 10 });
|
||||
registerGame({ slug: 'baccarat', name: 'Baccarat', category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 7, minOpponents: 1, maxOpponents: 6 });
|
||||
registerGame({ slug: 'dominion', name: 'Dominion', category: 'cards', cardGame: true, minPlayers: 3, maxPlayers: 4, minOpponents: 2, maxOpponents: 3 });
|
||||
registerGame({ slug: 'checkers', name: 'Checkers', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'chess', name: 'Chess', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue