463 lines
17 KiB
JavaScript
463 lines
17 KiB
JavaScript
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
import { Button } from '../../ui/Button.js';
|
|
import { auth } from '../../services/auth.js';
|
|
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
|
|
import { 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, 'ChessGame');
|
|
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);
|
|
});
|
|
}
|
|
}
|