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 = { 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): 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 = { 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 { 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() } }