import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import * as THREE from 'three'
import {
  addBirdMesh,
  addBoarMesh,
  cancelCombatRenderLoads,
  clearCombatRenderCaches,
  addClaudeCraftBossModel,
  addCombatUnitMesh,
  addCyberDragonMesh,
  animateCombatWeapon,
  getEnemyModelYawOffset,
  syncRangedCombatProjectiles,
  updateCombatUnitModel,
  updateBossModelAnimation,
} from '../action3dCombatRender'
import {
  ACTION_ARENA_SCALE,
  ACTION_PLAYER_KEYS,
  ACTION_RENDER_QUALITY,
  clearCombatActions,
  createActionCombatCamera,
  createActionCombatCameraController,
  createHealBurstEffect,
  getArcTelegraph,
  getCircleTelegraph,
  getLineTelegraph,
  getOrCreateStunEffect,
  pruneObjectMap,
  readCombatInput,
  resizeActionCombatCamera,
  setObjectOpacity,
  syncArcTelegraphGeometry,
  setupActionSceneEnvironment,
  syncLineTelegraphGeometry,
  toActionWorld,
  updateActionCombatCamera,
} from '../action3dSceneKit'
import {
  createBulldromeState,
  getAllEnemies,
  getTargetableUnits,
  updateBulldromeState,
  type ActionDungeonId,
  type BulldromeState,
  type EnemyKind,
  type SpellSlot,
} from '../actionBoss/actionCombatSimulation'
import type { ActionDifficulty, ActionRunMode } from '../actionMode'
import type { ArenaClassId } from '../actionClassKits'
import type { PlayerClass } from '../claudeCraftTypes'
import type { TalentModifiers } from '../claudeCraftTalents'

export type ThreeActionSceneHandle = {
  castSpell: (slot: SpellSlot) => void
  selectTarget: (targetId: string) => void
}

type ThreeActionSceneProps = {
  difficulty: ActionDifficulty
  dungeonId: ActionDungeonId
  runMode: ActionRunMode
  onStateChange: (state: BulldromeState) => void
  onRenderUnavailable?: () => void
  paused?: boolean
  playerClassId?: PlayerClass
  playerTalentModifiers?: TalentModifiers
}

type UnitMesh = THREE.Group & {
  userData: {
    defeatVisualTimer?: number
    deathAnimationStarted?: boolean
    kind?: EnemyKind
    lastX?: number
    lastY?: number
    role?: string
  }
}

const ENEMY_DEATH_VISUAL_SECONDS = 2.4

export const ThreeActionScene = forwardRef<ThreeActionSceneHandle, ThreeActionSceneProps>(function ThreeActionScene({
  difficulty,
  dungeonId,
  onRenderUnavailable,
  paused = false,
  playerClassId = 'priest',
  playerTalentModifiers,
  runMode,
  onStateChange,
}, ref) {
  const mountRef = useRef<HTMLDivElement | null>(null)
  const stateRef = useRef(createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers))
  const queuedTargetRef = useRef<string | null>(null)
  const queuedSpellRef = useRef<SpellSlot | null>(null)
  const pausedRef = useRef(paused)
  const actionQueueRef = useRef({ reset: false })
  const keysRef = useRef(new Set<string>())
  const cameraControllerRef = useRef(createActionCombatCameraController())

  useImperativeHandle(ref, () => ({
    castSpell: (slot) => {
      queuedSpellRef.current = slot
    },
    selectTarget: (targetId) => {
      queuedTargetRef.current = targetId
    },
  }))

  useEffect(() => {
    pausedRef.current = paused
    if (paused) {
      keysRef.current.clear()
      queuedTargetRef.current = null
      queuedSpellRef.current = null
      clearCombatActions(actionQueueRef.current)
    }
  }, [paused])

  useEffect(() => {
    stateRef.current = createBulldromeState(difficulty, dungeonId, runMode, playerClassId, playerTalentModifiers)
    onStateChange(stateRef.current)
  }, [difficulty, dungeonId, onStateChange, playerClassId, playerTalentModifiers, runMode])

  useEffect(() => {
    const mount = mountRef.current
    if (!mount) return

    const scene = new THREE.Scene()

    let renderer: THREE.WebGLRenderer
    try {
      renderer = new THREE.WebGLRenderer({ antialias: ACTION_RENDER_QUALITY.antialias, powerPreference: 'high-performance' })
    } catch {
      onRenderUnavailable?.()
      return
    }
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ACTION_RENDER_QUALITY.maxPixelRatio))
    renderer.shadowMap.enabled = ACTION_RENDER_QUALITY.shadows
    renderer.shadowMap.type = THREE.PCFSoftShadowMap
    mount.appendChild(renderer.domElement)

    const camera = createActionCombatCamera({ far: 120 })
    const initialArena = stateRef.current.arena
    setupActionSceneEnvironment(scene, {
      worldHeight: (initialArena.height - initialArena.padding * 2) / ACTION_ARENA_SCALE,
      worldWidth: (initialArena.width - initialArena.padding * 2) / ACTION_ARENA_SCALE,
    })
    void dungeonId

    const units = new Map<string, UnitMesh>()
    const fireballs = new Map<string, THREE.Mesh>()
    const fireSpots = new Map<string, THREE.Mesh>()
    const spinningBlades = new Map<string, THREE.Group>()
    const telegraphs = new Map<string, THREE.Mesh>()
    const arcTelegraphs = new Map<string, THREE.Mesh>()
    const slamTelegraphs = new Map<string, THREE.Mesh>()
    const partyEffects = new Map<string, THREE.Group>()
    const healEffects = new Map<string, THREE.Group>()
    const stunEffects = new Map<string, THREE.Group>()

    const clock = new THREE.Clock()
    let frameId = 0
    let hudTimer = 0

    const resize = () => {
      const rect = mount.getBoundingClientRect()
      const width = Math.max(1, rect.width)
      const height = Math.max(1, rect.height)
      renderer.setSize(width, height, false)
      resizeActionCombatCamera(camera, width, height)
    }
    const observer = new ResizeObserver(resize)
    observer.observe(mount)
    resize()

    const animate = () => {
      const delta = Math.min(clock.getDelta(), 0.05)
      if (pausedRef.current) {
        renderer.render(scene, camera)
        frameId = requestAnimationFrame(animate)
        return
      }
      const input = readCombatInput(keysRef.current, cameraControllerRef.current.yaw, queuedTargetRef.current, queuedSpellRef.current, actionQueueRef.current, {
        fallbackAim: { x: 0, y: -1 },
      })
      queuedTargetRef.current = null
      queuedSpellRef.current = null
      clearCombatActions(actionQueueRef.current)

      stateRef.current = updateBulldromeState(stateRef.current, input, delta)
      syncWorld(scene, stateRef.current, units, fireballs, fireSpots, spinningBlades, telegraphs, arcTelegraphs, slamTelegraphs, partyEffects, healEffects, stunEffects, delta, playerClassId)
      updateActionCombatCamera(camera, cameraControllerRef.current.yaw, stateRef.current.player, (x, y) => toActionWorld(x, y, stateRef.current.arena))
      renderer.render(scene, camera)

      hudTimer -= delta
      if (hudTimer <= 0 || stateRef.current.result !== 'playing' || stateRef.current.lastHit) {
        onStateChange(stateRef.current)
        hudTimer = 0.08
      }

      frameId = requestAnimationFrame(animate)
    }
    animate()

    const handleKeyDown = (event: KeyboardEvent) => {
      if (pausedRef.current) return
      const key = event.key.toLowerCase()
      if (ACTION_PLAYER_KEYS.has(key)) event.preventDefault()
      keysRef.current.add(key)
      if (!event.repeat && key === 'r') actionQueueRef.current.reset = true
    }
    const handleKeyUp = (event: KeyboardEvent) => {
      keysRef.current.delete(event.key.toLowerCase())
    }
    const unbindCameraDrag = cameraControllerRef.current.bindDrag(renderer.domElement, {
      isPaused: () => pausedRef.current,
    })

    window.addEventListener('keydown', handleKeyDown)
    window.addEventListener('keyup', handleKeyUp)

    return () => {
      cancelAnimationFrame(frameId)
      observer.disconnect()
      window.removeEventListener('keydown', handleKeyDown)
      window.removeEventListener('keyup', handleKeyUp)
      unbindCameraDrag()
      cancelCombatRenderLoads(scene)
      scene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.dispose()
          const material = object.material
          if (Array.isArray(material)) material.forEach((item) => item.dispose())
          else material.dispose()
        }
      })
      renderer.dispose()
      renderer.domElement.remove()
      clearCombatRenderCaches()
    }
  }, [difficulty, dungeonId, onRenderUnavailable, onStateChange, playerClassId, playerTalentModifiers, runMode])

  return (
    <div className="three-action-root">
      <div className="three-action-canvas" ref={mountRef} />
      <div className="three-action-overlay">
        <div className="three-action-hint">WASD move | drag camera</div>
      </div>
    </div>
  )
})

export default ThreeActionScene

function syncWorld(
  scene: THREE.Scene,
  state: BulldromeState,
  units: Map<string, UnitMesh>,
  fireballs: Map<string, THREE.Mesh>,
  fireSpots: Map<string, THREE.Mesh>,
  spinningBlades: Map<string, THREE.Group>,
  telegraphs: Map<string, THREE.Mesh>,
  arcTelegraphs: Map<string, THREE.Mesh>,
  slamTelegraphs: Map<string, THREE.Mesh>,
  partyEffects: Map<string, THREE.Group>,
  healEffects: Map<string, THREE.Group>,
  stunEffects: Map<string, THREE.Group>,
  deltaSeconds: number,
  playerClassId: PlayerClass,
) {
  const toWorld = (x: number, y: number) => toActionWorld(x, y, state.arena)
  const primaryEnemy = getAllEnemies(state).find((enemy) => enemy.hp > 0)
  const activeUnits = new Set<string>()
  for (const unit of getTargetableUnits(state)) {
    activeUnits.add(unit.id)
    const mesh = getOrCreateUnit(scene, units, unit.id, unit.id === 'player' ? 'player' : unit.role, unit.id, playerClassId)
    placeUnit(mesh, unit.x, unit.y, toWorld)
    mesh.visible = unit.hp > 0
    mesh.scale.setScalar(1)
    if (unit.id === 'player') {
      const wasMoving = hasUnitMoved(mesh, unit.x, unit.y)
      mesh.rotation.y = Math.atan2(state.player.facingX, state.player.facingY)
      const weapon = mesh.getObjectByName('weapon')
      if (weapon) weapon.rotation.x = -0.18
      updateCombatUnitModel(mesh, deltaSeconds, {
        action: state.player.currentCast ? 'spell' : wasMoving ? 'run' : 'idle',
        role: 'player',
      })
    } else {
      if (primaryEnemy) mesh.rotation.y = getFacingYaw(unit, primaryEnemy)
      const wasMoving = hasUnitMoved(mesh, unit.x, unit.y)
      const inMelee = Boolean(
        primaryEnemy
          && (unit.role === 'tank' || unit.role === 'melee')
          && Math.hypot(unit.x - primaryEnemy.x, unit.y - primaryEnemy.y) <= primaryEnemy.radius + (unit.role === 'tank' ? 82 : 96),
      )
      const inRangedCombat = Boolean(primaryEnemy && unit.role === 'ranged')
      updateCombatUnitModel(mesh, deltaSeconds, {
        action: inMelee || inRangedCombat ? 'attack' : wasMoving ? 'run' : 'idle',
        role: unit.role,
      })
      animatePartyWeapon(mesh, unit.id, unit.role, state, primaryEnemy)
    }
  }
  for (const enemy of getAllEnemies(state)) {
    activeUnits.add(enemy.id)
    const mesh = getOrCreateUnit(scene, units, enemy.id, enemy.kind)
    placeUnit(mesh, enemy.x, enemy.y, toWorld)
    const deathVisible = updateEnemyDeathVisibility(mesh, enemy.hp, deltaSeconds)
    mesh.visible = enemy.hp > 0 || deathVisible
    updateBossModelAnimation(
      mesh,
      deltaSeconds,
      enemy.hp <= 0
        ? 'death'
        : enemy.phase === 'windup' || enemy.phase === 'slamWindup' || enemy.phase === 'mauling'
          ? 'attack'
          : enemy.phase === 'tracking' || enemy.phase === 'charging' || enemy.phase === 'circling'
            ? 'walk'
            : 'idle',
    )
    const facing = Math.atan2(enemy.chargeVector.x, enemy.chargeVector.y)
    const spin = enemy.kind === 'cyber-dragon' && enemy.phase === 'circling'
      ? (state.elapsed * Math.PI * 2.8) % (Math.PI * 2)
      : 0
    mesh.rotation.y = facing + getEnemyModelYawOffset(enemy.kind) + spin
  }
  pruneObjectMap(units, activeUnits)

  syncHazards(scene, state.fireballs, fireballs, 0xff7a37, 0.22, toWorld)
  syncHazards(scene, state.fireSpots, fireSpots, 0xe3402f, 0.08, toWorld)
  syncSpinningBlades(scene, state.spinningBlades, spinningBlades, toWorld)
  syncPartyEffects(scene, state, partyEffects, toWorld)
  syncHealBursts(scene, state, healEffects, deltaSeconds, toWorld)
  syncStunEffects(scene, state, stunEffects, toWorld)

  const activeTelegraphs = new Set<string>()
  for (const enemy of getAllEnemies(state)) {
    if (enemy.telegraph.active) {
      activeTelegraphs.add(enemy.id)
      const mesh = getLineTelegraph(scene, telegraphs, enemy.id)
      syncLineTelegraphGeometry(mesh, enemy.telegraph.start, enemy.telegraph.end, enemy.telegraph.width, toWorld)
    }
  }
  pruneObjectMap(telegraphs, activeTelegraphs)

  const activeArcs = new Set<string>()
  for (const enemy of getAllEnemies(state)) {
    if (enemy.arcTelegraph.active) {
      activeArcs.add(enemy.id)
      const mesh = getArcTelegraph(scene, arcTelegraphs, enemy.id)
      syncArcTelegraphGeometry(
        mesh,
        enemy.arcTelegraph.center,
        enemy.arcTelegraph.radius,
        enemy.arcTelegraph.startAngle,
        enemy.arcTelegraph.endAngle,
        enemy.arcTelegraph.width,
        toWorld,
      )
    }
  }
  pruneObjectMap(arcTelegraphs, activeArcs)

  const activeSlams = new Set<string>()
  for (const enemy of getAllEnemies(state)) {
    if (enemy.slamTelegraph.active) {
      activeSlams.add(enemy.id)
      const mesh = getCircleTelegraph(scene, slamTelegraphs, enemy.id)
      const point = toWorld(enemy.x, enemy.y)
      mesh.position.set(point.x, 0.04, point.z)
      mesh.scale.setScalar(enemy.slamTelegraph.radius / ACTION_ARENA_SCALE)
    }
  }
  pruneObjectMap(slamTelegraphs, activeSlams)
}

function updateEnemyDeathVisibility(mesh: UnitMesh, hp: number, deltaSeconds: number) {
  if (hp > 0) {
    mesh.userData.defeatVisualTimer = undefined
    mesh.userData.deathAnimationStarted = false
    return false
  }

  if (!mesh.userData.deathAnimationStarted) {
    mesh.userData.deathAnimationStarted = true
    mesh.userData.defeatVisualTimer = ENEMY_DEATH_VISUAL_SECONDS
    return true
  }

  const nextTimer = Math.max(0, (mesh.userData.defeatVisualTimer ?? 0) - deltaSeconds)
  mesh.userData.defeatVisualTimer = nextTimer
  return nextTimer > 0
}

function getFacingYaw(from: { x: number; y: number }, to: { x: number; y: number }) {
  return Math.atan2(to.x - from.x, to.y - from.y)
}

function hasUnitMoved(mesh: UnitMesh, x: number, y: number) {
  const previousX = mesh.userData.lastX ?? x
  const previousY = mesh.userData.lastY ?? y
  mesh.userData.lastX = x
  mesh.userData.lastY = y
  return Math.hypot(x - previousX, y - previousY) > 0.5
}

function getOrCreateUnit(scene: THREE.Scene, units: Map<string, UnitMesh>, id: string, kind: string, unitId = id, playerClassId: PlayerClass = 'priest') {
  const existing = units.get(id)
  if (existing) return existing

  const group = new THREE.Group() as UnitMesh
  group.userData.kind = kind as EnemyKind
  if (kind === 'player') addCombatUnitMesh(group, { classId: toRenderableArenaClass(playerClassId), id: unitId, role: 'player' })
  else if (kind === 'tank' || kind === 'melee' || kind === 'ranged' || kind === 'healer') addCombatUnitMesh(group, { id: unitId, role: kind })
  else if (kind === 'bulldrome') {
    const fallback = new THREE.Group()
    fallback.name = 'proceduralFallback'
    fallback.rotation.y = Math.PI
    addBoarMesh(fallback, { kind: 'bulldrome' })
    group.add(fallback)
    addClaudeCraftBossModel(group, 'bulldrome')
  }
  else if (kind === 'bullfango') addBoarMesh(group, { kind: 'bullfango' })
  else if (kind === 'yian-kut-ku' || kind === 'bird') addBirdMesh(group, kind)
  else if (kind === 'cyber-dragon') {
    const fallback = new THREE.Group()
    fallback.name = 'proceduralFallback'
    addCyberDragonMesh(fallback)
    group.add(fallback)
    addClaudeCraftBossModel(group, 'cyber-dragon')
  }
  else if (kind === 'claudecraft-mob') addCombatUnitMesh(group, { id: unitId, role: 'melee' })
  else if (kind === 'morthen-the-gravecaller') addClaudeCraftBossModel(group, 'grand-necromancer-velkhar')
  else if (kind === 'vael-the-mistcaller') addClaudeCraftBossModel(group, 'vael-the-mistcaller')
  else if (kind === 'korzul-the-gravewyrm') addClaudeCraftBossModel(group, 'korzul-the-gravewyrm')
  else addCombatUnitMesh(group, { id: unitId, role: 'ranged' })
  scene.add(group)
  units.set(id, group)
  return group
}

function toRenderableArenaClass(classId: PlayerClass): ArenaClassId {
  if (classId === 'paladin' || classId === 'shaman' || classId === 'druid' || classId === 'priest') return classId
  if (classId === 'warrior' || classId === 'mage' || classId === 'rogue') return classId
  if (classId === 'hunter') return 'ranger'
  return 'priest'
}

function placeUnit(mesh: THREE.Object3D, x: number, y: number, toWorld: (x: number, y: number) => { x: number, z: number }) {
  const point = toWorld(x, y)
  mesh.position.set(point.x, 0, point.z)
}

function syncHazards(
  scene: THREE.Scene,
  hazards: Array<{ id: string; x: number; y: number; radius: number }>,
  meshes: Map<string, THREE.Mesh>,
  color: number,
  height: number,
  toWorld: (x: number, y: number) => { x: number, z: number },
) {
  const active = new Set<string>()
  for (const hazard of hazards) {
    active.add(hazard.id)
    let mesh = meshes.get(hazard.id)
    if (!mesh) {
      mesh = new THREE.Mesh(
        new THREE.SphereGeometry(Math.max(0.08, hazard.radius / ACTION_ARENA_SCALE), 12, 8),
        new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.74 }),
      )
      scene.add(mesh)
      meshes.set(hazard.id, mesh)
    }
    const point = toWorld(hazard.x, hazard.y)
    mesh.position.set(point.x, height, point.z)
    mesh.scale.setScalar(Math.max(1, hazard.radius / 16))
  }
  pruneObjectMap(meshes, active)
}

function syncSpinningBlades(
  scene: THREE.Scene,
  blades: Array<{ id: string; x: number; y: number; radius: number; rotation: number }>,
  meshes: Map<string, THREE.Group>,
  toWorld: (x: number, y: number) => { x: number, z: number },
) {
  const active = new Set<string>()
  for (const blade of blades) {
    active.add(blade.id)
    let group = meshes.get(blade.id)
    if (!group) {
      group = new THREE.Group()
      const indicator = new THREE.Group()
      indicator.name = 'spinningBladeIndicator'
      indicator.position.y = -0.245
      const indicatorFill = new THREE.Mesh(
        new THREE.CircleGeometry(0.62, 40),
        new THREE.MeshBasicMaterial({ color: 0xff2020, transparent: true, opacity: 0.26, side: THREE.DoubleSide }),
      )
      indicatorFill.rotation.x = -Math.PI / 2
      indicator.add(indicatorFill)
      const indicatorRing = new THREE.Mesh(
        new THREE.TorusGeometry(0.62, 0.018, 8, 48),
        new THREE.MeshBasicMaterial({ color: 0xff3030, transparent: true, opacity: 0.82 }),
      )
      indicatorRing.rotation.x = Math.PI / 2
      indicator.add(indicatorRing)
      group.add(indicator)
      const core = new THREE.Mesh(
        new THREE.CylinderGeometry(0.18, 0.18, 0.12, 16),
        new THREE.MeshStandardMaterial({ color: 0xd9f7ff, metalness: 0.9, roughness: 0.18, emissive: 0x2bc3db, emissiveIntensity: 0.35 }),
      )
      core.rotation.x = Math.PI / 2
      group.add(core)
      const bladeMaterial = new THREE.MeshStandardMaterial({ color: 0x8ef7ff, metalness: 0.85, roughness: 0.22 })
      for (let i = 0; i < 4; i += 1) {
        const arm = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.08, 1.15), bladeMaterial)
        arm.rotation.y = (Math.PI / 2) * i
        arm.castShadow = true
        group.add(arm)
      }
      scene.add(group)
      meshes.set(blade.id, group)
    }
    const point = toWorld(blade.x, blade.y)
    group.position.set(point.x, 0.28, point.z)
    group.rotation.y = blade.rotation
    group.scale.setScalar(Math.max(0.65, blade.radius / 24))
  }
  pruneObjectMap(meshes, active)
}

function syncPartyEffects(
  scene: THREE.Scene,
  state: BulldromeState,
  effects: Map<string, THREE.Group>,
  toWorld: (x: number, y: number) => { x: number, z: number },
) {
  const target = getAllEnemies(state).find((enemy) => enemy.hp > 0)
  syncRangedCombatProjectiles(scene, effects, {
    elapsed: state.elapsed,
    teamKey: 'party',
    toWorld,
    target: target ?? null,
    staffCaster: state.party.find((member) => member.id === 'ranged-1' && member.hp > 0),
    archer: state.party.find((member) => member.id === 'ranged-2' && member.hp > 0),
  })
}

function animatePartyWeapon(
  mesh: THREE.Object3D,
  unitId: string,
  role: string,
  state: BulldromeState,
  target: { x: number; y: number; radius: number } | undefined,
) {
  if (!target || (role !== 'tank' && role !== 'melee')) return
  const unit = state.party.find((member) => member.id === unitId)
  if (!unit || unit.hp <= 0) return

  const targetDistance = Math.hypot(unit.x - target.x, unit.y - target.y)
  animateCombatWeapon(mesh, role, {
    elapsed: state.elapsed,
    inMelee: targetDistance <= target.radius + (role === 'tank' ? 82 : 96),
  })
}

function syncHealBursts(
  scene: THREE.Scene,
  state: BulldromeState,
  effects: Map<string, THREE.Group>,
  deltaSeconds: number,
  toWorld: (x: number, y: number) => { x: number, z: number },
) {
  for (const event of state.healEvents) {
    if (effects.has(event.id)) continue
    const burst = createHealBurstEffect()
    burst.userData.targetId = event.targetId
    burst.userData.age = 0
    burst.userData.duration = 0.85
    scene.add(burst)
    effects.set(event.id, burst)
  }

  const active = new Set<string>()
  for (const [id, burst] of effects) {
    const age = Number(burst.userData.age ?? 0) + deltaSeconds
    const duration = Number(burst.userData.duration ?? 0.85)
    burst.userData.age = age
    if (age >= duration) continue

    const target = getTargetableUnits(state).find((unit) => unit.id === burst.userData.targetId)
    if (!target) continue

    active.add(id)
    const point = toWorld(target.x, target.y)
    const progress = age / duration
    const alpha = Math.max(0, 1 - progress)
    burst.position.set(point.x, 0.08, point.z)
    burst.scale.setScalar(0.65 + progress * 1.25)
    burst.rotation.y += deltaSeconds * 3
    setObjectOpacity(burst, alpha)
  }

  pruneObjectMap(effects, active)
}

function syncStunEffects(
  scene: THREE.Scene,
  state: BulldromeState,
  effects: Map<string, THREE.Group>,
  toWorld: (x: number, y: number) => { x: number, z: number },
) {
  const active = new Set<string>()
  for (const unit of getTargetableUnits(state)) {
    if (unit.hp <= 0 || unit.stunTimer <= 0) continue

    active.add(unit.id)
    const effect = getOrCreateStunEffect(scene, effects, unit.id)
    const point = toWorld(unit.x, unit.y)
    effect.position.set(point.x, 1.48, point.z)
    effect.rotation.y = state.elapsed * 5.2
    effect.visible = true
  }
  pruneObjectMap(effects, active)
}
