7a62067af1
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>
628 lines
23 KiB
TypeScript
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()
|
|
}
|
|
}
|