Files
Arcanum_TD/src/game/LevelScene.ts
T
Mareli 7a62067af1 Initial commit: Arcanum TD — medieval fantasy tower defense
Vite + React + PixiJS + TypeScript. Features:
- 4-level campaign (King's Road → Obsidian Keep)
- Isometric 2.5D grid with ley-line mechanics
- ECS architecture (entities, components, systems)
- 4 tower types, hero spellcaster, 10+ enemy types
- Lich King boss with 3-phase AI
- Meta-progression: essence, rune unlocks
- Full UI redesign with fantasy design system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 12:31:49 +03:00

628 lines
23 KiB
TypeScript

import { Container, Graphics } from 'pixi.js'
import { GridMap, TILE_SIZE, ISO_HALF_W, ISO_HALF_H } from './map/GridMap'
import { findPath, terminatePathWorker } from './map/Pathfinding'
import { MapRenderer } from './rendering/MapRenderer'
import { Camera } from './rendering/camera'
import { layers, getSize } from './rendering/PixiRoot'
import type { LevelDef } from '@/data/levels'
import { EntityManager } from './core/EntityManager'
import { movementSystem } from './systems/MovementSystem'
import { targetingSystem } from './systems/TargetingSystem'
import { attackSystem } from './systems/AttackSystem'
import { projectileSystem } from './systems/ProjectileSystem'
import { renderSystem } from './systems/RenderSystem'
import { deathSystem } from './systems/DeathSystem'
import { statusEffectSystem } from './systems/StatusEffectSystem'
import { heroSystem, heroAddXp, getHeroComp } from './systems/HeroSystem'
import { necromancerSystem } from './systems/NecromancerSystem'
import { lichKingSystem } from './systems/LichKingSystem'
import { createWaveState, startWave, waveSystem, type WaveState } from './systems/WaveSystem'
import { createArcherTower, ARCHER_COST } from './entities/towers/archer'
import { createPyromancer, PYROMANCER_COST } from './entities/towers/pyromancer'
import { createCryomancer, CRYO_COST } from './entities/towers/cryomancer'
import { createStormcaller, STORM_COST } from './entities/towers/stormcaller'
import { createArchmage } from './entities/hero/archmage'
import { eventBus } from './core/EventBus'
import { useGameStore } from '@/state/gameStore'
import { setWorldContainer, getWorldContainer } from './rendering/WorldContext'
import { TOWER_DEFS } from '@/data/towerDefs'
import type { TowerComp, AttackComp, TargetingComp, HealthComp } from './components'
import type { StatusEffectsComp } from './components/StatusEffect'
import { applyStatus } from './components/StatusEffect'
import { resolveDamage } from './combat/DamageResolver'
import { refreshHp } from './entities/enemies/updateHpBar'
import type { HeroComp } from './components/Hero'
export type PlacementMode = 'none' | 'archer' | 'pyromancer' | 'cryomancer' | 'stormcaller'
const _TOWER_GHOST_COLORS: Record<string, number> = {
archer: 0xccaa44,
pyromancer: 0xff6622,
cryomancer: 0x44ccee,
stormcaller: 0xaa88ff,
}
const LEY_LINE_DAMAGE_MULT = 1.20
const LEY_LINE_RANGE_MULT = 1.12
export interface TowerInfo {
towerId: string
nameRu: string
icon: string
tier: number
maxTier: number
damage: number
cooldown: number
range: number
totalCost: number
upgradeCost: number | null
sellValue: number
leyLineBuff: boolean
}
export class LevelScene {
readonly map: GridMap
readonly entities = new EntityManager()
private renderer: MapRenderer
private camera: Camera
private worldRoot: Container
private hoverGfx = new Graphics()
private selectionGfx = new Graphics()
private _destroyed = false
private _path: { x: number; y: number }[] = []
private _screenPath: { x: number; y: number }[] = []
private _detachCamera: (() => void) | null = null
private _waveState: WaveState = createWaveState()
private _placementMode: PlacementMode = 'none'
private _spellMode: string | null = null
private _maxWaves = 0
constructor(def: LevelDef) {
this.map = new GridMap(def.cols, def.rows)
this.map.loadFromLayout(def.layout)
this._maxWaves = useGameStore.getState().maxWaves
this.worldRoot = new Container()
this.worldRoot.sortableChildren = true
layers!.terrain.addChild(this.worldRoot)
setWorldContainer(this.worldRoot)
this._drawBackground()
this.renderer = new MapRenderer()
this.worldRoot.addChild(this.renderer.container)
this.worldRoot.addChild(this.hoverGfx)
this.worldRoot.addChild(this.selectionGfx)
const raw = getSize()
const width = raw.width > 0 ? raw.width : window.innerWidth
const height = raw.height > 0 ? raw.height : window.innerHeight
const mapW = this.map.isoMapWidth()
const mapH = this.map.isoMapHeight()
this.camera = new Camera(this.worldRoot, {
minX: -mapW, maxX: width,
minY: -mapH, maxY: height,
minZoom: 0.5, maxZoom: 2.5,
})
this.worldRoot.x = Math.max(ISO_HALF_W, (width - mapW) / 2)
this.worldRoot.y = Math.max(ISO_HALF_H, (height - mapH) / 2) + ISO_HALF_H
this.camera.x = this.worldRoot.x
this.camera.y = this.worldRoot.y
this.renderer.load(this.map)
this._calcPath(def.spawn, def.nexus)
this._setupEventListeners()
this._spawnHero(def)
}
update(dt: number): void {
if (this._destroyed) return
const store = useGameStore.getState()
this.camera.update(dt)
heroSystem(this.entities, dt)
if (store.phase === 'combat') {
statusEffectSystem(this.entities, dt)
movementSystem(this.entities, dt)
necromancerSystem(this.entities, dt)
lichKingSystem(this.entities, this._screenPath, dt)
targetingSystem(this.entities)
attackSystem(this.entities, dt)
projectileSystem(this.entities, dt)
renderSystem(this.entities)
deathSystem(this.entities, this.map)
waveSystem(this._waveState, this.entities, this.map, this._screenPath, dt)
} else if (store.phase === 'build' && this._waveState.countdown > 0) {
this._waveState.countdown -= dt
if (this._waveState.countdown <= 0 && store.wave < this._maxWaves) {
this._waveState.countdown = 0
this.startWave()
}
}
}
startWave(): void {
const store = useGameStore.getState()
if (store.wave >= this._maxWaves) return
store.setSelectedTowerTile(null)
const nextWave = store.wave + 1
store.setWave(nextWave)
store.setPhase('combat')
startWave(this._waveState, nextWave)
}
setPlacementMode(mode: PlacementMode): void {
this._placementMode = mode
this._spellMode = null
if (mode !== 'none') {
useGameStore.getState().setSelectedTowerTile(null)
this.selectionGfx.clear()
}
}
setSpellMode(spellId: string | null): void {
this._spellMode = spellId
if (spellId !== null) {
this._placementMode = 'none'
useGameStore.getState().setSelectedTowerTile(null)
this.selectionGfx.clear()
}
}
getSpellMode(): string | null { return this._spellMode }
getWaveState(): WaveState { return this._waveState }
getHeroComp(): HeroComp | null { return getHeroComp(this.entities) }
getTowerInfo(tx: number, ty: number): TowerInfo | null {
const tile = this.map.get(tx, ty)
if (!tile?.towerEntityId) return null
const entity = this.entities.get(tile.towerEntityId)
if (!entity) return null
const tc = entity.towerComp as TowerComp
const atk = entity.attack as AttackComp
const tgt = entity.targeting as TargetingComp
const def = TOWER_DEFS[tc.towerId]
return {
towerId: tc.towerId,
nameRu: def?.nameRu ?? tc.towerId,
icon: def?.icon ?? '🏰',
tier: tc.tier,
maxTier: def?.tiers.length ?? 1,
damage: atk?.damage ?? 0,
cooldown: atk?.cooldown ?? 0,
range: tgt?.range ?? 0,
totalCost: tc.totalCost,
upgradeCost: def && tc.tier < def.tiers.length ? def.tiers[tc.tier].cost : null,
sellValue: Math.floor(tc.totalCost * 0.6),
leyLineBuff: tc.leyLineBuff,
}
}
upgradeTower(tx: number, ty: number): boolean {
const tile = this.map.get(tx, ty)
if (!tile?.towerEntityId) return false
const entity = this.entities.get(tile.towerEntityId)
if (!entity) return false
const tc = entity.towerComp as TowerComp
const def = TOWER_DEFS[tc.towerId]
if (!def || tc.tier >= def.tiers.length) return false
const nextTier = def.tiers[tc.tier]
if (!useGameStore.getState().spendGold(nextTier.cost)) return false
const atk = entity.attack as AttackComp
const tgt = entity.targeting as TargetingComp
if (atk) { atk.damage = nextTier.damage; atk.cooldown = nextTier.cooldown }
if (tgt) { tgt.range = nextTier.range }
tc.tier++; tc.totalCost += nextTier.cost
if (tc.leyLineBuff) {
if (atk) atk.damage = Math.round(atk.damage * LEY_LINE_DAMAGE_MULT)
if (tgt) tgt.range = Math.round(tgt.range * LEY_LINE_RANGE_MULT)
}
return true
}
sellTower(tx: number, ty: number): void {
const tile = this.map.get(tx, ty)
if (!tile?.towerEntityId) return
const entity = this.entities.get(tile.towerEntityId)
if (!entity) return
const tc = entity.towerComp as TowerComp
useGameStore.getState().addGold(Math.floor(tc.totalCost * 0.6))
useGameStore.getState().setSelectedTowerTile(null)
entity.tags.add('dead')
this.selectionGfx.clear()
}
castSpell(spellId: string, worldX: number, worldY: number): boolean {
const heroComp = getHeroComp(this.entities)
if (!heroComp) return false
const spell = heroComp.spells.find((s) => s.id === spellId)
if (!spell || spell.timer > 0) return false
if (!useGameStore.getState().spendMana(spell.manaCost)) return false
spell.timer = spell.cooldown
switch (spellId) {
case 'fireball': this._castFireball(worldX, worldY, spell.damage, spell.radius); break
case 'blizzard': this._castBlizzard(worldX, worldY, spell.radius); break
case 'blink': this._castBlink(worldX, worldY); break
case 'timewarp': this._castTimeWarp(); break
}
this._spellMode = null
return true
}
private _castFireball(cx: number, cy: number, damage: number, radius: number): void {
for (const enemy of this.entities.withTag('enemy')) {
if (enemy.tags.has('dead')) continue
const et = enemy.transform as { x: number; y: number } | undefined
if (!et) continue
if ((et.x - cx) ** 2 + (et.y - cy) ** 2 > radius * radius) continue
const hp = enemy.health as HealthComp
if (hp) {
const dmg = resolveDamage(damage, 'magic', hp)
hp.current -= dmg
refreshHp(enemy)
if (hp.current <= 0) {
enemy.tags.add('dead')
eventBus.emit('enemy:died', { id: enemy.id, gold: (enemy.loot as { gold: number } | undefined)?.gold ?? 0, essence: 0 })
}
}
const se = enemy.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'burn', duration: 4, strength: 6 })
}
this._fxCircle(cx, cy, radius, 0xff4400, 0xff8800)
}
private _castBlizzard(cx: number, cy: number, radius: number): void {
for (const enemy of this.entities.withTag('enemy')) {
if (enemy.tags.has('dead')) continue
const et = enemy.transform as { x: number; y: number } | undefined
if (!et) continue
if ((et.x - cx) ** 2 + (et.y - cy) ** 2 > radius * radius) continue
const se = enemy.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'slow', duration: 4, strength: 0.7 })
}
this._fxCircle(cx, cy, radius, 0x6ecbd5, 0xaaeeff)
}
private _castBlink(wx: number, wy: number): void {
const heroes = this.entities.withTag('hero')
if (heroes.length === 0) return
const hero = heroes[0]
const t = hero.transform as { x: number; y: number }
const h = hero.hero as HeroComp
const oldX = t.x; const oldY = t.y
t.x = wx; t.y = wy
h.targetX = null; h.targetY = null
this._fxBlink(oldX, oldY, wx, wy)
}
private _castTimeWarp(): void {
for (const enemy of this.entities.withTag('enemy')) {
if (enemy.tags.has('dead')) continue
const se = enemy.statusEffects as StatusEffectsComp | undefined
if (se) applyStatus(se, { type: 'slow', duration: 5, strength: 0.65 })
}
this._fxTimeWarp()
}
private _fxCircle(cx: number, cy: number, r: number, fill: number, stroke: number): void {
let world: Container | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
gfx.circle(cx, cy, r)
gfx.fill({ color: fill, alpha: 0.3 })
gfx.circle(cx, cy, r)
gfx.stroke({ color: stroke, width: 3, alpha: 0.8 })
world.addChild(gfx)
let life = 0.5
const tick = () => {
life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.5)
gfx.scale.set(1 + (0.5 - life) * 0.4)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
private _fxBlink(x1: number, y1: number, x2: number, y2: number): void {
let world: Container | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
gfx.circle(x1, y1, 20)
gfx.fill({ color: 0x9966ff, alpha: 0.6 })
gfx.circle(x2, y2, 20)
gfx.fill({ color: 0xcc99ff, alpha: 0.8 })
world.addChild(gfx)
let life = 0.4
const tick = () => {
life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.4)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
private _fxTimeWarp(): void {
let world: Container | null = null
try { world = getWorldContainer() } catch { return }
const gfx = new Graphics()
// Full-screen tint ring
for (let i = 0; i < 4; i++) {
gfx.circle(0, 0, 200 + i * 120)
gfx.stroke({ color: 0x00ccff, width: 3 - i * 0.5, alpha: 0.4 - i * 0.08 })
}
world.addChild(gfx)
let life = 0.6
const tick = () => {
life -= 1 / 60; gfx.alpha = Math.max(0, life / 0.6)
if (life <= 0) gfx.destroy(); else requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}
attachInput(canvas: HTMLElement): void {
this._detachCamera = this.camera.attachPointerEvents(canvas)
canvas.addEventListener('pointermove', this._onPointerMove)
canvas.addEventListener('pointerdown', this._onPointerDown)
canvas.addEventListener('contextmenu', (e) => e.preventDefault())
}
detachInput(canvas: HTMLElement): void {
this._detachCamera?.()
canvas.removeEventListener('pointermove', this._onPointerMove)
canvas.removeEventListener('pointerdown', this._onPointerDown)
}
private _onPointerMove = (e: PointerEvent): void => {
if (this._destroyed) return
const canvas = e.currentTarget as HTMLElement
const rect = canvas.getBoundingClientRect()
const world = this.camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top)
const { x: tx, y: ty } = this.map.screenToTile(world.x, world.y)
this._drawHover(tx, ty)
}
private _onPointerDown = (e: PointerEvent): void => {
if (this._destroyed) return
if (e.button === 2) {
// Right click: cancel modes
this._placementMode = 'none'
this._spellMode = null
return
}
if (e.button !== 0) return
const canvas = e.currentTarget as HTMLElement
const rect = canvas.getBoundingClientRect()
const world = this.camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top)
const wx = world.x; const wy = world.y
const { x: tx, y: ty } = this.map.screenToTile(wx, wy)
if (this._placementMode !== 'none') {
this._placeTower(tx, ty, this._placementMode)
return
}
if (this._spellMode !== null) {
const spell = getHeroComp(this.entities)?.spells.find((s) => s.id === this._spellMode)
if (spell?.targetMode === 'point') {
this.castSpell(this._spellMode, wx, wy)
}
return
}
// Tower selection or hero movement
const tile = this.map.get(tx, ty)
if (tile?.towerEntityId) {
useGameStore.getState().setSelectedTowerTile({ x: tx, y: ty })
this._drawSelection(tx, ty)
} else {
useGameStore.getState().setSelectedTowerTile(null)
this.selectionGfx.clear()
// Move hero to clicked world position
this._moveHero(wx, wy)
}
}
private _moveHero(wx: number, wy: number): void {
const heroes = this.entities.withTag('hero')
if (heroes.length === 0) return
const h = heroes[0].hero as HeroComp
if (h) { h.targetX = wx; h.targetY = wy }
}
private _hasAdjacentLeyLine(tx: number, ty: number): boolean {
const dirs = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,-1],[-1,1],[1,1]]
for (const [dx, dy] of dirs) {
if (this.map.get(tx + dx, ty + dy)?.type === 'ley_line') return true
}
return false
}
private _applyLeyLineBuff(entity: ReturnType<typeof this.entities.create>): void {
const atk = entity.attack as AttackComp
const tgt = entity.targeting as TargetingComp
if (atk) atk.damage = Math.round(atk.damage * LEY_LINE_DAMAGE_MULT)
if (tgt) tgt.range = Math.round(tgt.range * LEY_LINE_RANGE_MULT)
const tc = entity.towerComp as TowerComp
if (tc) tc.leyLineBuff = true
// Blue glow dot under tower
const r = entity.render as import('./components').RenderComp | undefined
if (r?.container) {
const gfx = new Graphics()
gfx.circle(0, 0, ISO_HALF_W * 0.85)
gfx.stroke({ color: 0x44bbff, width: 2.5, alpha: 0.7 })
gfx.circle(0, 0, 5)
gfx.fill({ color: 0x44bbff, alpha: 0.8 })
r.container.addChildAt(gfx, 0)
}
}
private _placeTower(tx: number, ty: number, mode: PlacementMode): void {
if (!this.map.isBuildable(tx, ty)) return
const store = useGameStore.getState()
const costs: Record<PlacementMode, number> = {
none: 0, archer: ARCHER_COST, pyromancer: PYROMANCER_COST,
cryomancer: CRYO_COST, stormcaller: STORM_COST,
}
if (!store.spendGold(costs[mode])) return
const tile = this.map.get(tx, ty)!
const entity = this.entities.create()
const { x: wxp, y: wyp } = this.map.tileCenter(tx, ty)
switch (mode) {
case 'archer': createArcherTower(entity, tx, ty, wxp, wyp); break
case 'pyromancer': createPyromancer(entity, tx, ty, wxp, wyp); break
case 'cryomancer': createCryomancer(entity, tx, ty, wxp, wyp); break
case 'stormcaller': createStormcaller(entity, tx, ty, wxp, wyp); break
}
if (this._hasAdjacentLeyLine(tx, ty) || tile.type === 'ley_line') {
this._applyLeyLineBuff(entity)
} else {
const tc = entity.towerComp as TowerComp
if (tc) tc.leyLineBuff = false
}
tile.towerEntityId = entity.id
this._placementMode = 'none'
}
private _drawHover(tx: number, ty: number): void {
this.hoverGfx.clear()
if (!this.map.get(tx, ty)) return
const { x: cx, y: cy } = this.map.tileCenter(tx, ty)
const hw = ISO_HALF_W, hh = ISO_HALF_H
const inMode = this._placementMode !== 'none' || this._spellMode !== null
const canBuild = this._placementMode !== 'none' && this.map.isBuildable(tx, ty)
const color = canBuild ? 0x44ff88 : inMode ? 0xff4444 : 0xffffff
// Diamond outline
this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.hoverGfx.stroke({ color, width: 2, alpha: 0.7 })
this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.hoverGfx.fill({ color, alpha: 0.1 })
if (canBuild && this._placementMode !== 'none') {
const def = TOWER_DEFS[this._placementMode]
const range = def?.tiers[0].range ?? 120
const hasLey = this._hasAdjacentLeyLine(tx, ty)
const boostedRange = hasLey ? Math.round(range * 1.12) : range
const rangeColor = _TOWER_GHOST_COLORS[this._placementMode] ?? 0xffd700
this.hoverGfx.circle(cx, cy, boostedRange)
this.hoverGfx.fill({ color: rangeColor, alpha: 0.06 })
this.hoverGfx.circle(cx, cy, boostedRange)
this.hoverGfx.stroke({ color: rangeColor, width: 1.5, alpha: 0.4 })
// Ghost tower diamond
this.hoverGfx.poly([cx, cy - hh * 0.7, cx + hw * 0.7, cy, cx, cy + hh * 0.7, cx - hw * 0.7, cy])
this.hoverGfx.fill({ color: rangeColor, alpha: 0.25 })
this.hoverGfx.poly([cx, cy - hh * 0.7, cx + hw * 0.7, cy, cx, cy + hh * 0.7, cx - hw * 0.7, cy])
this.hoverGfx.stroke({ color: rangeColor, width: 2, alpha: 0.7 })
if (hasLey) {
this.hoverGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.hoverGfx.stroke({ color: 0x44bbff, width: 2.5, alpha: 0.9 })
}
}
}
private _drawSelection(tx: number, ty: number): void {
this.selectionGfx.clear()
const { x: cx, y: cy } = this.map.tileCenter(tx, ty)
const hw = ISO_HALF_W, hh = ISO_HALF_H
this.selectionGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.selectionGfx.stroke({ color: 0xffd700, width: 2.5, alpha: 0.95 })
this.selectionGfx.poly([cx, cy - hh, cx + hw, cy, cx, cy + hh, cx - hw, cy])
this.selectionGfx.fill({ color: 0xffd700, alpha: 0.12 })
}
private _drawBackground(): void {
const raw = getSize()
const width = raw.width > 0 ? raw.width : window.innerWidth
const height = raw.height > 0 ? raw.height : window.innerHeight
const bg = new Graphics()
bg.rect(-200, -200, width + 400, height + 400)
bg.fill({ color: 0x0a1a0a })
const step = TILE_SIZE * 4
for (let x = 0; x < width + 400; x += step) {
bg.moveTo(x - 200, -200); bg.lineTo(x - 200, height + 200)
bg.stroke({ color: 0x1a2a1a, width: 1, alpha: 0.3 })
}
for (let y = 0; y < height + 400; y += step) {
bg.moveTo(-200, y - 200); bg.lineTo(width + 200, y - 200)
bg.stroke({ color: 0x1a2a1a, width: 1, alpha: 0.3 })
}
layers!.bg.addChild(bg)
}
private _spawnHero(def: LevelDef): void {
const startTileX = Math.min(def.cols - 2, 10)
const startTileY = 1
const { x: sx, y: sy } = this.map.tileCenter(startTileX, startTileY)
const entity = this.entities.create()
createArchmage(entity, sx, sy)
}
private async _calcPath(
spawn: { x: number; y: number },
nexus: { x: number; y: number },
): Promise<void> {
const path = await findPath(this.map, spawn, nexus)
if (path) {
this._path = path
this._screenPath = path.map((p) => this.map.tileCenter(p.x, p.y))
this.renderer.showPath(path)
}
}
private _setupEventListeners(): void {
eventBus.on<{ id: number; gold: number; essence: number }>('enemy:died', (data) => {
if (this._destroyed) return
useGameStore.getState().addGold(data.gold)
heroAddXp(this.entities, 5)
})
eventBus.on<{ id: number; damage: number }>('enemy:reached_end', () => {
if (this._destroyed) return
const store = useGameStore.getState()
store.damageNexus(1)
this.camera.shake(10, 0.4)
if (store.nexusHp <= 0) { store.setWon(false); store.setScreen('gameover') }
})
eventBus.on<{ amount: number }>('lich:nexus_damage', (data) => {
if (this._destroyed) return
const store = useGameStore.getState()
store.damageNexus(data.amount)
this.camera.shake(8, 0.5)
if (store.nexusHp <= 0) { store.setWon(false); store.setScreen('gameover') }
})
eventBus.on<{ waveIndex: number }>('wave:end', (ev) => {
if (this._destroyed) return
const store = useGameStore.getState()
store.setPhase('build')
store.addMana(20)
heroAddXp(this.entities, 50)
if (ev.waveIndex >= this._maxWaves) { store.setWon(true); store.setScreen('gameover') }
})
}
getPath(): { x: number; y: number }[] { return this._path }
getScreenPath(): { x: number; y: number }[] { return this._screenPath }
destroy(): void {
this._destroyed = true
setWorldContainer(null)
this._detachCamera?.()
this.renderer.destroy()
this.worldRoot.destroy({ children: true })
this.entities.clear()
terminatePathWorker()
}
}