fertig-classic-games/src/games/chess/ChessGame.js

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