caro5 / client /src /game /GameEngine.ts
Pedro de Carvalho
Update ui
b9b8e2c
Raw
History Blame Contribute Delete
18 kB
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)
}))
}
}