import { Application, Container, Graphics, Ticker } from 'pixi.js' import { CameraController } from './CameraController.ts' import { GridRenderer } from './GridRenderer.ts' import { BoardStore } from './BoardStore.ts' import { SymbolRenderer } from './SymbolRenderer.ts' import { audioManager } from './effects/AudioManager.ts' import { ParticleSystem } from './effects/ParticleSystem.ts' import { PlacementEffect } from './effects/PlacementEffect.ts' import { WinEffect } from './effects/WinEffect.ts' import { BackgroundAtmosphere } from './effects/BackgroundAtmosphere.ts' import { CELL_SIZE, isBoardCoordInBounds } from 'shared/board.ts' import { theme, onThemeChange } from '../theme.ts' import { debug } from '../debug.ts' import type { Cell, RoomState, RoomScores, GameRules, SerializedBitBoard } from 'shared/types.ts' import { GameManager } from 'shared/GameManager.ts' export class GameEngine { app!: Application worldContainer!: Container camera!: CameraController grid!: GridRenderer board!: BoardStore symbols!: SymbolRenderer effectLayer!: Container particleLayer!: Container audio = audioManager particleSys!: ParticleSystem screenLayer!: Container private placementEffect!: PlacementEffect private winEffect!: WinEffect private bgAtmo: BackgroundAtmosphere | null = null private containerEl: HTMLElement | null = null private resizeObserver: ResizeObserver | null = null private unsubTheme: (() => void) | null = null private particleTickerCb: (() => void) | null = null private bgAtmoTickerCb: (() => void) | null = null destroyed = false private hoverGx: number | null = null private hoverGy: number | null = null private hoverGraphics!: Graphics playerIndex = -1 currentPlayer = 0 status: RoomState['status'] = 'waiting' players: string[] = [] roomId = '' winnerPlayerId = '' scores: RoomScores = {} opponentOnline = true playerSymbols: [string, string] = ['X', 'O'] localMode = false localPlayerTypes: ('human' | 'bot')[] = [] localInputEnabled = true boardSize = 15 manager: GameManager | null = null gameRules: GameRules = { swap2: false, noOverlines: false } onSwapChoice: (() => void) | null = null onSwapApplied: ((swapped: boolean) => void) | null = null private previousTurnPlayer = 0 sendMessage: ((type: string, payload: unknown) => void) | null = null onStateChanged: (() => void) | null = null onError: ((message: string) => void) | null = null async init(containerEl: HTMLElement): Promise { debug('GameEngine', 'init start', { w: containerEl.clientWidth, h: containerEl.clientHeight }) this.containerEl = containerEl const w = containerEl.clientWidth const h = containerEl.clientHeight this.app = new Application() const initStart = performance.now() await this.app.init({ width: w, height: h, backgroundColor: theme.bg, antialias: true, resolution: window.devicePixelRatio || 1, autoDensity: true, }) debug('GameEngine', `app.init took ${(performance.now() - initStart).toFixed(1)}ms`) if (this.destroyed) { debug('GameEngine', 'destroyed after app.init, aborting'); return } containerEl.appendChild(this.app.canvas as HTMLCanvasElement) this.worldContainer = new Container() this.app.stage.addChild(this.worldContainer) this.screenLayer = new Container() this.screenLayer.eventMode = 'none' this.app.stage.addChild(this.screenLayer) this.bgAtmo = new BackgroundAtmosphere(this.app) this.app.stage.addChildAt(this.bgAtmo.container, 0) if (this.destroyed) { debug('GameEngine', 'destroyed after bgAtmo, aborting'); return } this.camera = new CameraController(this.worldContainer, w, h) this.camera.setInitialZoom(this.boardSize) this.camera.enable(this.app.canvas as HTMLCanvasElement) this.camera.onChange = () => this.redraw() this.camera.onCellClick = (gx, gy) => this.handleCellClick(gx, gy) this.camera.onCellHover = (gx, gy) => this.handleCellHover(gx, gy) debug('GameEngine', 'camera initialized') if (this.destroyed) { debug('GameEngine', 'destroyed after camera, aborting'); return } this.grid = new GridRenderer() this.worldContainer.addChild(this.grid.container) this.hoverGraphics = new Graphics() this.worldContainer.addChild(this.hoverGraphics) this.effectLayer = new Container() this.worldContainer.addChild(this.effectLayer) this.board = new BoardStore(this.boardSize, this.playerSymbols) this.symbols = new SymbolRenderer() this.worldContainer.addChild(this.symbols.container) this.particleLayer = new Container() this.worldContainer.addChild(this.particleLayer) this.particleSys = new ParticleSystem(this.particleLayer) this.placementEffect = new PlacementEffect(this.effectLayer, this.particleSys) this.winEffect = new WinEffect(this.effectLayer, this.particleSys, this.camera, w, h, this.screenLayer) this.particleTickerCb = () => this.particleSys.update(Ticker.shared.deltaMS / 1000) Ticker.shared.add(this.particleTickerCb) const bgAtmoTicker = () => this.bgAtmo?.update(Ticker.shared.deltaMS / 1000) Ticker.shared.add(bgAtmoTicker) this.bgAtmoTickerCb = bgAtmoTicker this.unsubTheme = onThemeChange(() => { this.app.renderer.background.color = theme.bg this.hoverGraphics.clear() this.symbols.clear() this.bgAtmo?.refresh() this.redraw() }) this.redraw() this.resizeObserver = new ResizeObserver(() => this.handleResize()) this.resizeObserver.observe(containerEl) debug('GameEngine', 'init complete') } destroy(): void { debug('GameEngine', 'destroy') this.destroyed = true if (this.camera) { this.camera.onChange = null this.camera.onCellClick = null this.camera.onCellHover = null this.camera.disable() } this.unsubTheme?.() this.resizeObserver?.disconnect() this.bgAtmo?.destroy() this.symbols?.clear() this.particleSys?.clear() if (this.particleTickerCb) { Ticker.shared.remove(this.particleTickerCb) this.particleTickerCb = null } if (this.bgAtmoTickerCb) { Ticker.shared.remove(this.bgAtmoTickerCb) this.bgAtmoTickerCb = null } if (this.app?.renderer?.canvas?.parentElement) { this.app.renderer.canvas.parentElement.removeChild(this.app.renderer.canvas) } } private isInBounds(gx: number, gy: number): boolean { return isBoardCoordInBounds(gx, gy, this.boardSize) } private handleCellHover(gx: number | null, gy: number | null): void { const isMyTurn = this.localMode ? true : this.playerIndex === this.currentPlayer; const show = gx !== null && gy !== null && this.status === 'active' && isMyTurn && !this.board.getCell(gx, gy) && this.isInBounds(gx, gy) if (!show) { if (this.hoverGx !== null) { this.hoverGx = null this.hoverGy = null this.hoverGraphics.clear() } return } this.hoverGx = gx this.hoverGy = gy this.drawHover() this.audio?.playHover() } private drawHover(): void { if (this.hoverGx === null || this.hoverGy === null) return this.hoverGraphics.clear() const lx = this.hoverGx * CELL_SIZE + CELL_SIZE / 2 const ly = this.hoverGy * CELL_SIZE + CELL_SIZE / 2 const r = CELL_SIZE * 0.5 const playerColors = this.symbols.customColors.length >= 2 ? this.symbols.customColors : [theme.player1, theme.player2] const color = playerColors[this.currentPlayer] ?? 0xffffff this.hoverGraphics.circle(lx, ly, r) this.hoverGraphics.fill({ color, alpha: theme.hoverFillAlpha }) this.hoverGraphics.circle(lx, ly, r) this.hoverGraphics.stroke({ width: 1, color, alpha: theme.hoverStrokeAlpha }) } private handleCellClick(gx: number, gy: number): void { debug('GameEngine', 'cellClick', { gx, gy, status: this.status, localMode: this.localMode, currentPlayer: this.currentPlayer, playerIndex: this.playerIndex }) if (this.status !== 'active') { debug('GameEngine', 'cellClick ignored: status not active'); return } if (this.board.getCell(gx, gy)) { debug('GameEngine', 'cellClick ignored: cell occupied'); return } if (!this.isInBounds(gx, gy)) { debug('GameEngine', 'cellClick ignored: out of bounds'); return } if (this.localMode && !this.localInputEnabled) { debug('GameEngine', 'cellClick ignored: local input disabled'); return } if (this.localMode && this.manager) { this.placeLocalMove(gx, gy) } else if (!this.localMode) { this.onError?.('Only local play is available in this Space') return } } placeLocalMove(gx: number, gy: number): boolean { debug('GameEngine', 'placeLocalMove', { gx, gy, currentPlayer: this.currentPlayer }) if (!this.localMode || !this.manager) { debug('GameEngine', 'placeLocalMove: not local or no manager'); return false } if (this.status !== 'active') { debug('GameEngine', 'placeLocalMove: status not active'); return false } if (this.board.getCell(gx, gy)) { debug('GameEngine', 'placeLocalMove: cell occupied'); return false } if (!this.isInBounds(gx, gy)) { debug('GameEngine', 'placeLocalMove: out of bounds'); return false } const result = this.manager.placeMove(gx, gy) debug('GameEngine', 'placeLocalMove result:', result) if (!result.success) { debug('GameEngine', 'placeLocalMove: manager rejected'); return false } const cell = this.manager.getBoard().get(`${gx}:${gy}`) if (!cell) return false this.board.setCell(gx, gy, cell) this.redraw() this.playPlacementEffect(gx, gy) this.audio?.play('placement') this.previousTurnPlayer = this.currentPlayer if (result.kind === 'swap2_choice') { this.currentPlayer = -1 this.onStateChanged?.() this.onSwapChoice?.() } else if (result.kind === 'win') { this.currentPlayer = -1 this.onStateChanged?.() setTimeout(() => this.applyWin(String(result.winner), result.winCells), 100) } else if (result.kind === 'draw') { this.currentPlayer = -1 this.onStateChanged?.() setTimeout(() => this.applyDraw(), 100) } else { this.currentPlayer = this.manager.getCurrentPlayer() this.onStateChanged?.() } return true } setCells(cells: SerializedBitBoard | Record): void { this.board.deserialize(this.filterInBoundsCells(cells), this.playerSymbols) this.symbols.clear() this.redraw() this.ensureBoardVisible() } addCell(x: number, y: number, cell: Cell): void { if (!this.isInBounds(x, y)) return const isNew = !this.board.getCell(x, y) this.board.setCell(x, y, cell) this.redraw() if (isNew) { this.playPlacementEffect(x, y) this.audio?.play('placement') } this.ensureBoardVisible() } removeCell(x: number, y: number): void { this.board.removeCell(x, y) this.redraw() } private playPlacementEffect(gx: number, gy: number): void { const key = `${gx}:${gy}` const container = this.symbols.getSymbol(key) const cell = this.board.getCell(gx, gy) if (container && cell) { const playerColors = this.symbols.customColors.length >= 2 ? this.symbols.customColors : [theme.player1, theme.player2] const color = playerColors[Number(cell.playerId) % 2] ?? theme.effectSpark this.placementEffect.play(gx, gy, container, color) } } private ensureBoardVisible(): void { const coords: [number, number][] = [] for (const [key] of this.board.cells) { const [gx, gy] = key.split(':').map(Number) coords.push([gx, gy]) } this.camera.ensureCellsInView(coords) } private clearHover(): void { this.hoverGx = null this.hoverGy = null this.hoverGraphics.clear() } applyRoomState(s: RoomState): void { debug('GameEngine', 'applyRoomState', { status: s.status, playerIndex: s.playerIndex, currentPlayer: s.currentPlayer, boardSize: s.boardSize, players: s.players }) this.clearHover() this.winEffect.clear() if (s.boardSize) { this.boardSize = s.boardSize this.board.setBoardSize(s.boardSize) } this.playerIndex = s.playerIndex this.currentPlayer = s.currentPlayer this.status = s.status this.players = s.players this.roomId = s.roomId this.scores = s.scores ?? {} if (s.symbols) { this.playerSymbols = s.symbols this.board.symbols = s.symbols } this.setCells(s.board) if (s.status === 'active') { this.audio?.startMusic() } else { this.audio?.stopMusic() } this.onStateChanged?.() } applyMove(currentPlayer: number): void { debug('GameEngine', 'applyMove', { currentPlayer }) this.currentPlayer = currentPlayer this.onStateChanged?.() } rejectMove(x: number, y: number, _message: string): void { debug('GameEngine', 'rejectMove', { x, y, message: _message }) this.removeCell(x, y) this.currentPlayer = this.previousTurnPlayer this.onStateChanged?.() } applyWin(playerId: string, cells: { x: number; y: number }[] = []): void { debug('GameEngine', 'applyWin', { playerId, cells, localMode: this.localMode, localPlayerTypes: this.localPlayerTypes }) this.status = 'win' this.winnerPlayerId = playerId this.onStateChanged?.() this.audio?.stopMusic() const isPvE = this.localMode && this.localPlayerTypes.some(t => t === 'bot') if (isPvE) { const humanPlayerId = String(this.localPlayerTypes.indexOf('human')) if (playerId === humanPlayerId) { this.audio?.playVictoryMusic() this.audio?.play('win') } else { this.audio?.playDefeatMusic() } } else { this.audio?.playVictoryMusic() this.audio?.play('win') } this.winEffect.play(cells) } applyDraw(): void { debug('GameEngine', 'applyDraw') this.status = 'draw' this.currentPlayer = -1 this.onStateChanged?.() this.audio?.stopMusic() } applyRematch(s: RoomState): void { debug('GameEngine', 'applyRematch') this.applyRoomState(s) } localRematch(): void { this.clearHover() this.winEffect.clear() this.manager?.reset() this.board.clear() this.symbols.clear() this.status = 'active' this.currentPlayer = 0 this.winnerPlayerId = '' this.redraw() this.audio?.stopAll() this.audio?.startMusic() this.onStateChanged?.() } playerLeft(): void { this.status = 'waiting' this.onStateChanged?.() this.audio?.stopMusic() } setOpponentOnline(online: boolean): void { this.opponentOnline = online this.onStateChanged?.() } setPlayerSymbol(playerIndex: number, symbol: string): void { this.playerSymbols[playerIndex] = symbol this.redraw() this.onStateChanged?.() } requestSwap(): void { if (this.localMode && this.manager) { this.manager.swapSides() this.applySwapFromManager() } else { this.onError?.('Only local play is available in this Space') } } applySwapFromManager(): void { debug('GameEngine', 'applySwapFromManager') if (!this.manager) return const names = this.manager.getPlayerNames() const symbols = this.manager.getPlayerSymbols() const swapped = this.playerSymbols[0] !== symbols[0] || this.playerSymbols[1] !== symbols[1] debug('GameEngine', 'swap result', { swapped, symbolsBefore: this.playerSymbols, symbolsAfter: symbols }) this.players = [names[0], names[1]] this.playerSymbols = [symbols[0], symbols[1]] this.board.symbols = [symbols[0], symbols[1]] this.currentPlayer = this.manager.getCurrentPlayer() this.board.deserialize(this.manager.getBoardSnapshot(), this.playerSymbols) this.symbols.clear() this.updateCameraOffset() this.camera?.apply() this.redraw() this.onStateChanged?.() this.onSwapApplied?.(swapped) } toggleMute(): boolean { return this.audio?.toggleMute() ?? false } get isMuted(): boolean { return this.audio?.muted ?? true } private handleResize(): void { if (this.destroyed || !this.containerEl) return const w = this.containerEl.clientWidth const h = this.containerEl.clientHeight this.app.renderer.resize(w, h) this.updateCameraOffset() this.camera.resize(w, h) this.bgAtmo?.resize(w, h) this.redraw() } updateCameraOffset(): void { if (!this.camera) return const isP1Bot = this.localMode && this.localPlayerTypes[0] === 'bot' const isP2Bot = this.localMode && this.localPlayerTypes[1] === 'bot' const hasBot = (isP1Bot || isP2Bot) && !(isP1Bot && isP2Bot) if (hasBot && this.camera.vw >= 768) { this.camera.offsetX = (isP2Bot ? -1 : 1) * 125 } else { this.camera.offsetX = 0 } } updatePlayerConfig(names: string[], symbols: string[], colors: number[], drawTowers?: boolean[]): void { this.players = names this.playerSymbols = [symbols[0], symbols[1]] this.board.symbols = [symbols[0], symbols[1]] this.symbols.customColors = colors if (drawTowers) { this.symbols.drawTowers = drawTowers } if (this.manager) { this.manager.setPlayerName(0, names[0]) this.manager.setPlayerName(1, names[1]) this.manager.setPlayerSymbol(0, symbols[0]) this.manager.setPlayerSymbol(1, symbols[1]) } this.symbols.clear() this.updateCameraOffset() this.camera?.apply() this.redraw() } redraw(): void { this.grid.draw(this.boardSize) const allCells = Array.from(this.board.cells.entries()) this.symbols.update(allCells, -Infinity, -Infinity, Infinity, Infinity) } private filterInBoundsCells(cells: SerializedBitBoard | Record): SerializedBitBoard | Record { if ('words' in cells) return cells return Object.fromEntries(Object.entries(cells).filter(([key]) => { const [gx, gy] = key.split(':').map(Number) return this.isInBounds(gx, gy) })) } }