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