Spaces:
Runtime error
Runtime error
| 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<void> { | |
| 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<string, Cell>): 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<string, Cell>): SerializedBitBoard | Record<string, Cell> { | |
| 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) | |
| })) | |
| } | |
| } | |