// Core game: scene, arena building, play simulation, mode switching.
import * as THREE from 'three';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js';
import { FX } from './fx';
import { LevelData, Cell, key, cloneLevel, LS_KEY } from './level';
import {
  BotRig, TreeRig, makeSpiderBot, makeTurretRig, makeGigabot, makeTreeOfLife,
  makeBombMesh, makeRocketMesh, makeScrapMesh, makeOrbMesh, disposeGroup,
} from './bots';

export type Mode = 'play' | 'editor';
export type Phase = 'playing' | 'cleared' | 'dead';

const PLAYER_HP = 3;
const ENEMY_HP = 3;
const TURRET_HP = 2;
const PLAYER_SPEED = 4.4;
const ENEMY_SPEED = 1.9;
const BOT_RADIUS = 0.32;
const LASER_RANGE = 28;
const BOMB_FUSE = 1.2;
const BOMB_RADIUS = 2.3;
const BOMB_COOLDOWN = 1.5;
const ROCKET_SPEED = 7.5;
const ROCKET_LIFE = 2.0;
const ROCKET_RADIUS = 3.1;
const ROCKET_COOLDOWN = 1.0;
const LINK_RANGE = 6.5; // enemy↔enemy floor lasers
const ED_ZOOM_MIN = 7;
// abilities
const TESLA_COOLDOWN = 4;
const TESLA_RANGE = 8;
const TESLA_CHAIN_RANGE = 5.5;
const TESLA_MAX_HITS = 4;
const BLADE_COOLDOWN = 2.5;
const BLADE_RADIUS = 1.8;
const ORB_COOLDOWN = 10;
const ORB_LIFE = 8;
const ORB_RANGE = 6.5;
const ORB_FIRE_CD = 0.7;
const DASH_COOLDOWN = 3;
const DASH_TIME = 0.16;
const DASH_SPEED = 17;
// gigabot tier
const GIGA_HP = 10;
const GIGA_SPEED = 1.1;
const GIGA_RADIUS = 0.58;
const GIGA_VOLLEY_CD = 4.5;
const GIGA_SLAM_CD = 3;
const GIGA_SLAM_RADIUS = 2.6;
const ENEMY_ROCKET_SPEED = 4.6;
// scrap pickups
const SCRAP_MAGNET_RANGE = 2.0;
const SCRAP_PICKUP_RANGE = 0.42;
const SCRAP_MAX = 70;

const COL = {
  playerCap: 0x44ccff,
  playerBody: 0x39414f,
  playerRing: 0x33ddff,
  enemyCap: 0xff7733,
  enemyBody: 0x44343a,
  laser: 0xffe066,
  enemyLaser: 0xffc233,
  tesla: 0xaaddff,
  orb: 0x66f0ff,
  scrap: 0xffa030,
  tree: 0x46ffa0,
};

// HUD hotbar slot indices (cooldown display)
export const SLOT = { laser: 0, bomb: 1, rocket: 2, tesla: 3, blade: 4, orb: 5, dash: 6 } as const;

interface PlayerState {
  rig: BotRig;
  pos: THREE.Vector3;
  hp: number;
  fireCd: number;
  bombCd: number;
  rocketCd: number;
  teslaCd: number;
  bladeCd: number;
  orbCd: number;
  dashCd: number;
  dashT: number;
  dashDir: THREE.Vector3;
  invulnT: number;
  flash: number;
  bobT: number;
  light: THREE.PointLight;
}
interface RocketState {
  group: THREE.Group;
  pos: THREE.Vector3;
  dir: THREE.Vector3;
  t: number;
  hostile: boolean; // fired by a gigabot — hurts the player
  speed: number;
  radius: number;
}
interface EnemyState {
  rig: BotRig;
  pos: THREE.Vector3;
  hp: number;
  fireCd: number;
  flash: number;
  wanderA: number;
  wanderT: number;
  bobT: number;
  tier: 'normal' | 'giga';
  slamCd: number;
  r: number; // body radius for hits/collision
}
interface ScrapState {
  mesh: THREE.Mesh;
  pos: THREE.Vector3;
  vel: THREE.Vector3;
  t: number;
  spin: number;
}
interface OrbState {
  group: THREE.Group;
  pos: THREE.Vector3;
  t: number;
  fireCd: number;
}
interface TurretState {
  rig: BotRig;
  pos: THREE.Vector3;
  hp: number;
  fireCd: number;
  flash: number;
}
interface BombState {
  group: THREE.Group;
  capMat: THREE.MeshBasicMaterial;
  pos: THREE.Vector3;
  t: number;
}

export interface HudLike {
  setHP(hp: number, max: number): void;
  /** Cooldown fill for one hotbar slot (see SLOT), frac 0..1. */
  setCd(slot: number, frac: number): void;
  setScrap(n: number): void;
  setMessage(title: string | null, sub?: string): void;
  setMode(mode: Mode): void;
  setPaused(p: boolean): void;
}

export class Game {
  scene = new THREE.Scene();
  camera: THREE.PerspectiveCamera;
  renderer: THREE.WebGLRenderer;
  level: LevelData;
  mode: Mode = 'play';
  phase: Phase = 'playing';
  paused = false;
  fx: FX;
  hud: HudLike | null = null;
  onModeChange: ((m: Mode) => void) | null = null;
  /** Hovered editor cell (or null when off-grid). */
  hoverCell: Cell | null = null;

  player: PlayerState | null = null;
  enemies: EnemyState[] = [];
  turrets: TurretState[] = [];
  bombs: BombState[] = [];
  rockets: RocketState[] = [];
  scraps: ScrapState[] = [];
  orbs: OrbState[] = [];
  scrapCount = 0;
  treeUnlocked = false;

  /** How many gigabots are alive (e2e/debug). */
  get gigaCount(): number {
    return this.enemies.reduce((n, e) => n + (e.tier === 'giga' ? 1 : 0), 0);
  }

  // editor camera (panned target + zoom distance); public so e2e can poke them
  edTarget = new THREE.Vector3();
  edDist = 20;

  private composer: EffectComposer;
  private wallSet = new Set<string>();
  private wallMeshes = new Map<string, THREE.Group>();
  private accentMeshes = new Map<string, THREE.Mesh>();
  private floorMesh: THREE.InstancedMesh | null = null;
  private gridLines: THREE.LineSegments | null = null;
  private ghost: THREE.Mesh;
  private editorMarkers: THREE.Group[] = [];
  private wallGeo = new THREE.BoxGeometry(0.98, 0.92, 0.98);
  // trim is a thin sliver poking out of the wall sides near the top — a glowing band
  private wallTrimGeo = new THREE.BoxGeometry(1.04, 0.03, 1.04);
  private wallMat = new THREE.MeshStandardMaterial({ color: 0x2a2f3e, roughness: 0.8, metalness: 0.2 });
  private wallTrimMat = new THREE.MeshBasicMaterial({ color: 0xc24418, toneMapped: false });
  private accentGeo = new THREE.PlaneGeometry(0.86, 0.86);
  private accentMat = new THREE.MeshBasicMaterial({
    color: 0xc22413,
    toneMapped: false,
    transparent: true,
    opacity: 0.65,
    depthWrite: false,
  });

  private keys = new Set<string>();
  private ndc = new THREE.Vector2();
  private firing = false;
  private aim = new THREE.Vector3(1, 0, 0);
  private raycaster = new THREE.Raycaster();
  private floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
  private camTarget = new THREE.Vector3();
  private clock = new THREE.Clock();
  private time = 0;
  private linkHurtCd = 0;
  private linkSparkT = 0;
  private treeRig: TreeRig | null = null;
  private sporeT = 0;
  private tempMsgT = 0; // auto-clear timer for transient center messages
  private pendingBooms: { pos: THREE.Vector3; t: number; radius: number; power: number }[] = [];
  private tmpV = new THREE.Vector3();
  private tmpV2 = new THREE.Vector3();
  private tmpV3 = new THREE.Vector3();

  constructor(private container: HTMLElement, level: LevelData) {
    this.level = level;
    this.renderer = new THREE.WebGLRenderer({ antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    this.renderer.setSize(window.innerWidth, window.innerHeight);
    this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
    this.renderer.toneMappingExposure = 1.15;
    container.appendChild(this.renderer.domElement);

    this.scene.background = new THREE.Color(0x07080d);
    this.scene.fog = new THREE.Fog(0x07080d, 24, 60);

    this.camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 200);

    // lights
    this.scene.add(new THREE.AmbientLight(0x4a5470, 0.85));
    const hemi = new THREE.HemisphereLight(0x44557a, 0x1c0d0a, 0.7);
    this.scene.add(hemi);
    const dir = new THREE.DirectionalLight(0xbfd4ff, 0.85);
    dir.position.set(-6, 12, 4);
    this.scene.add(dir);

    this.fx = new FX(this.scene);

    // post pipeline: bloom -> grade (chromatic aberration + color grade + vignette) -> output
    this.composer = new EffectComposer(this.renderer);
    this.composer.addPass(new RenderPass(this.scene, this.camera));
    const bloom = new UnrealBloomPass(
      new THREE.Vector2(window.innerWidth, window.innerHeight),
      0.85, 0.5, 0.6
    );
    this.composer.addPass(bloom);
    this.composer.addPass(new ShaderPass({
      uniforms: { tDiffuse: { value: null } },
      vertexShader: /* glsl */ `
        varying vec2 vUv;
        void main() {
          vUv = uv;
          gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }`,
      fragmentShader: /* glsl */ `
        uniform sampler2D tDiffuse;
        varying vec2 vUv;
        void main() {
          vec2 off = vUv - 0.5;
          float d2 = dot(off, off);
          // chromatic aberration, stronger toward the edges
          float ca = 0.014 * d2;
          vec3 col;
          col.r = texture2D(tDiffuse, vUv + off * ca).r;
          col.g = texture2D(tDiffuse, vUv).g;
          col.b = texture2D(tDiffuse, vUv - off * ca).b;
          // grade: navy shadows, hot orange highlights
          float lum = dot(col, vec3(0.299, 0.587, 0.114));
          vec3 shadows = col * vec3(0.86, 0.95, 1.22);
          vec3 highs = col * vec3(1.13, 1.0, 0.87);
          col = mix(shadows, highs, smoothstep(0.16, 0.7, lum));
          // subtle in-render vignette (the DOM one carries most of it)
          col *= 1.0 - smoothstep(0.18, 0.6, d2) * 0.34;
          gl_FragColor = vec4(col, 1.0);
        }`,
    }));
    this.composer.addPass(new OutputPass());

    // editor ghost cursor
    this.ghost = new THREE.Mesh(
      new THREE.BoxGeometry(1, 0.1, 1),
      new THREE.MeshBasicMaterial({
        color: 0x33ddff,
        transparent: true,
        opacity: 0.28,
        depthWrite: false,
        toneMapped: false,
      })
    );
    this.ghost.visible = false;
    this.scene.add(this.ghost);

    this.rebuildStatic();
    this.fitEditorCamera();
    this.resetPlay();
    this.bindInput();

    window.addEventListener('resize', () => {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.composer.setSize(window.innerWidth, window.innerHeight);
    });
  }

  // ---------- coordinates ----------
  cellToWorld(c: Cell, out = new THREE.Vector3()): THREE.Vector3 {
    const [w, h] = this.level.size;
    return out.set(c[0] - w / 2 + 0.5, 0, c[1] - h / 2 + 0.5);
  }
  worldToCell(x: number, z: number): Cell | null {
    const [w, h] = this.level.size;
    const cx = Math.floor(x + w / 2);
    const cz = Math.floor(z + h / 2);
    if (cx < 0 || cz < 0 || cx >= w || cz >= h) return null;
    return [cx, cz];
  }

  // ---------- static arena ----------
  rebuildStatic(): void {
    const [w, h] = this.level.size;

    // floor
    if (this.floorMesh) {
      this.scene.remove(this.floorMesh);
      this.floorMesh.geometry.dispose();
      (this.floorMesh.material as THREE.Material).dispose();
    }
    const tileGeo = new THREE.BoxGeometry(0.94, 0.1, 0.94);
    const tileMat = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.85, metalness: 0.1 });
    const floor = new THREE.InstancedMesh(tileGeo, tileMat, w * h);
    const m = new THREE.Matrix4();
    const c = new THREE.Color();
    let idx = 0;
    for (let z = 0; z < h; z++) {
      for (let x = 0; x < w; x++) {
        m.setPosition(x - w / 2 + 0.5, -0.05, z - h / 2 + 0.5);
        floor.setMatrixAt(idx, m);
        const r1 = this.hash(x, z);
        const r2 = this.hash(x + 91, z + 17);
        if (r1 > 0.86) c.setHSL(0.015, 0.55, 0.05 + r2 * 0.035); // occasional rusty tile
        else c.setHSL(0.62 + r2 * 0.04, 0.3, 0.045 + r1 * 0.045);
        floor.setColorAt(idx, c);
        idx++;
      }
    }
    floor.instanceMatrix.needsUpdate = true;
    if (floor.instanceColor) floor.instanceColor.needsUpdate = true;
    this.scene.add(floor);
    this.floorMesh = floor;

    // walls
    const wanted = new Set(this.level.walls.map(([x, z]) => key(x, z)));
    for (const [k, g] of this.wallMeshes) {
      if (!wanted.has(k)) {
        this.scene.remove(g);
        this.wallMeshes.delete(k);
      }
    }
    for (const [x, z] of this.level.walls) {
      const k = key(x, z);
      let g = this.wallMeshes.get(k);
      if (!g) {
        g = new THREE.Group();
        const box = new THREE.Mesh(this.wallGeo, this.wallMat);
        box.position.y = 0.46;
        g.add(box);
        const trim = new THREE.Mesh(this.wallTrimGeo, this.wallTrimMat);
        trim.position.y = 0.875;
        g.add(trim);
        this.scene.add(g);
        this.wallMeshes.set(k, g);
      }
      this.cellToWorld([x, z], g.position);
    }
    this.wallSet = wanted;

    // accent tiles
    const wantedA = new Set(this.level.accents.map(([x, z]) => key(x, z)));
    for (const [k, mesh] of this.accentMeshes) {
      if (!wantedA.has(k)) {
        this.scene.remove(mesh);
        this.accentMeshes.delete(k);
      }
    }
    for (const [x, z] of this.level.accents) {
      const k = key(x, z);
      let mesh = this.accentMeshes.get(k);
      if (!mesh) {
        mesh = new THREE.Mesh(this.accentGeo, this.accentMat.clone());
        mesh.rotation.x = -Math.PI / 2;
        this.scene.add(mesh);
        this.accentMeshes.set(k, mesh);
      }
      this.cellToWorld([x, z], mesh.position);
      mesh.position.y = 0.012;
      (mesh.material as THREE.MeshBasicMaterial).userData = { phase: this.hash(x, z) * 6.28 };
    }

    // grid overlay
    if (this.gridLines) {
      this.scene.remove(this.gridLines);
      this.gridLines.geometry.dispose();
      (this.gridLines.material as THREE.Material).dispose();
    }
    const verts: number[] = [];
    for (let x = 0; x <= w; x++) verts.push(x - w / 2, 0.065, -h / 2, x - w / 2, 0.065, h / 2);
    for (let z = 0; z <= h; z++) verts.push(-w / 2, 0.065, z - h / 2, w / 2, 0.065, z - h / 2);
    const gGeo = new THREE.BufferGeometry();
    gGeo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
    this.gridLines = new THREE.LineSegments(
      gGeo,
      new THREE.LineBasicMaterial({ color: 0x2a6a8a, transparent: true, opacity: 0.4 })
    );
    this.gridLines.visible = this.mode === 'editor';
    this.scene.add(this.gridLines);
  }

  private hash(x: number, z: number): number {
    const s = Math.sin(x * 127.1 + z * 311.7) * 43758.5453;
    return s - Math.floor(s);
  }

  // ---------- entities ----------
  private clearEntities(): void {
    if (this.player) {
      this.scene.remove(this.player.rig.group);
      disposeGroup(this.player.rig.group);
      this.player = null;
    }
    for (const e of this.enemies) {
      this.scene.remove(e.rig.group);
      disposeGroup(e.rig.group);
    }
    this.enemies = [];
    for (const t of this.turrets) {
      this.scene.remove(t.rig.group);
      disposeGroup(t.rig.group);
    }
    this.turrets = [];
    for (const b of this.bombs) {
      this.scene.remove(b.group);
      disposeGroup(b.group);
    }
    this.bombs = [];
    for (const r of this.rockets) {
      this.scene.remove(r.group);
      disposeGroup(r.group);
    }
    this.rockets = [];
    for (const s of this.scraps) {
      this.scene.remove(s.mesh);
      disposeGroup(s.mesh);
    }
    this.scraps = [];
    for (const o of this.orbs) {
      this.scene.remove(o.group);
      disposeGroup(o.group);
    }
    this.orbs = [];
    if (this.treeRig) {
      this.scene.remove(this.treeRig.group);
      disposeGroup(this.treeRig.group);
      this.treeRig = null;
    }
    this.pendingBooms = [];
  }

  private clearMarkers(): void {
    for (const g of this.editorMarkers) {
      this.scene.remove(g);
      disposeGroup(g);
    }
    this.editorMarkers = [];
  }

  resetPlay(): void {
    this.clearEntities();
    this.clearMarkers();
    const rig = makeSpiderBot(COL.playerCap, COL.playerBody, COL.playerRing);
    const pos = this.cellToWorld(this.level.playerSpawn);
    rig.group.position.copy(pos);
    this.scene.add(rig.group);
    const light = new THREE.PointLight(0x44ccff, 4, 5, 1.6);
    light.position.y = 1.1;
    rig.group.add(light);
    this.player = {
      rig, pos, hp: PLAYER_HP, fireCd: 0, bombCd: 0, rocketCd: 0,
      teslaCd: 0, bladeCd: 0, orbCd: 0, dashCd: 0, dashT: 0,
      dashDir: new THREE.Vector3(1, 0, 0), invulnT: 0,
      flash: 0, bobT: 0, light,
    };

    for (const c of this.level.enemies) {
      const erig = makeSpiderBot(COL.enemyCap, COL.enemyBody);
      const epos = this.cellToWorld(c);
      erig.group.position.copy(epos);
      this.scene.add(erig.group);
      this.enemies.push({
        rig: erig, pos: epos, hp: ENEMY_HP,
        fireCd: 0.8 + Math.random(), flash: 0,
        wanderA: Math.random() * Math.PI * 2, wanderT: 0, bobT: 0,
        tier: 'normal', slamCd: 0, r: 0.34,
      });
    }
    for (const c of this.level.gigabots) {
      const grig = makeGigabot();
      const gpos = this.cellToWorld(c);
      grig.group.position.copy(gpos);
      this.scene.add(grig.group);
      this.enemies.push({
        rig: grig, pos: gpos, hp: GIGA_HP,
        fireCd: 1.5 + Math.random(), flash: 0,
        wanderA: Math.random() * Math.PI * 2, wanderT: 0, bobT: 0,
        tier: 'giga', slamCd: 0, r: GIGA_RADIUS,
      });
    }
    for (const c of this.level.turrets) {
      const trig = makeTurretRig(0xffaa22);
      const tpos = this.cellToWorld(c);
      trig.group.position.copy(tpos);
      this.scene.add(trig.group);
      this.turrets.push({ rig: trig, pos: tpos, hp: TURRET_HP, fireCd: 1, flash: 0 });
    }
    if (this.level.tree) {
      this.treeRig = makeTreeOfLife();
      this.cellToWorld(this.level.tree, this.treeRig.group.position);
      this.scene.add(this.treeRig.group);
    }
    this.treeUnlocked = false;
    this.scrapCount = 0;
    this.phase = 'playing';
    this.hud?.setHP(PLAYER_HP, PLAYER_HP);
    this.hud?.setScrap(0);
    this.hud?.setMessage(null);
  }

  buildMarkers(): void {
    this.clearEntities();
    this.clearMarkers();
    const add = (rig: BotRig, c: Cell): void => {
      this.cellToWorld(c, rig.group.position);
      this.scene.add(rig.group);
      this.editorMarkers.push(rig.group);
    };
    add(makeSpiderBot(COL.playerCap, COL.playerBody, COL.playerRing), this.level.playerSpawn);
    for (const c of this.level.enemies) add(makeSpiderBot(COL.enemyCap, COL.enemyBody), c);
    for (const c of this.level.gigabots) add(makeGigabot(), c);
    for (const c of this.level.turrets) add(makeTurretRig(0xffaa22), c);
    if (this.level.tree) {
      const tree = makeTreeOfLife();
      this.cellToWorld(this.level.tree, tree.group.position);
      this.scene.add(tree.group);
      this.editorMarkers.push(tree.group);
    }
  }

  /** Re-sync world after an editor change to level data. */
  refreshLevel(): void {
    this.rebuildStatic();
    if (this.mode === 'editor') this.buildMarkers();
    else this.resetPlay();
  }

  loadLevel(l: LevelData): void {
    this.level = l;
    this.refreshLevel();
    if (this.mode === 'editor') this.fitEditorCamera();
  }

  // ---------- modes ----------
  setMode(m: Mode): void {
    if (m === this.mode) return;
    this.mode = m;
    this.paused = false;
    this.hud?.setPaused(false);
    if (m === 'editor') {
      this.buildMarkers();
      if (this.gridLines) this.gridLines.visible = true;
      this.hud?.setMessage(null);
      this.fitEditorCamera();
    } else {
      if (this.gridLines) this.gridLines.visible = false;
      this.ghost.visible = false;
      this.resetPlay();
    }
    this.hud?.setMode(m);
    this.onModeChange?.(m);
  }
  toggleMode(): void {
    this.setMode(this.mode === 'play' ? 'editor' : 'play');
  }

  // ---------- editor camera ----------
  /** Max zoom-out distance for the current arena size. */
  private edDistMax(): number {
    const [w, h] = this.level.size;
    return Math.max(w, h) * 1.2 + 8;
  }
  /** Frame the whole arena (called on entering the editor / loading a level). */
  fitEditorCamera(): void {
    // nudge scene right so the palette doesn't cover it
    this.edTarget.set(-1.8, 0, 0);
    this.edDist = Math.max(ED_ZOOM_MIN, this.edDistMax() * 0.82);
  }
  /** Pan by a screen-pixel delta (the shared editor drives this). */
  panEditor(dxPx: number, dyPx: number): void {
    // world units per screen pixel at the current zoom
    const scale = (2 * this.edDist * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2))) / window.innerHeight;
    this.edTarget.x -= dxPx * scale;
    this.edTarget.z -= dyPx * scale;
    const [w, h] = this.level.size;
    this.edTarget.x = THREE.MathUtils.clamp(this.edTarget.x, -w / 2 - 2, w / 2 + 2);
    this.edTarget.z = THREE.MathUtils.clamp(this.edTarget.z, -h / 2 - 2, h / 2 + 2);
  }
  zoomEditor(deltaY: number): void {
    // clamp the factor too: one huge wheel delta must not flip the sign
    const f = THREE.MathUtils.clamp(1 + deltaY * 0.0011, 0.55, 1.6);
    this.edDist = THREE.MathUtils.clamp(this.edDist * f, ED_ZOOM_MIN, this.edDistMax());
  }
  togglePause(): void {
    if (this.mode !== 'play') return;
    this.paused = !this.paused;
    this.hud?.setPaused(this.paused);
  }

  // ---------- input ----------
  private bindInput(): void {
    window.addEventListener('keydown', (e) => {
      const t = e.target as HTMLElement;
      if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
      if (e.code === 'Space') e.preventDefault();
      this.keys.add(e.code);
      if (e.repeat) return;
      if (e.code === 'KeyE') this.toggleMode();
      else if (e.code === 'KeyP') this.togglePause();
      else if (e.code === 'KeyR' && this.mode === 'play') this.resetPlay();
      else if (e.code === 'Space' && this.mode === 'play') this.dropBomb();
      else if (e.code === 'KeyF' && this.mode === 'play') this.fireRocket();
      else if (e.code === 'KeyQ' && this.mode === 'play') this.fireTesla();
      else if (e.code === 'KeyV' && this.mode === 'play') this.bladeSpin();
      else if (e.code === 'KeyC' && this.mode === 'play') this.deployOrb();
      else if ((e.code === 'ShiftLeft' || e.code === 'ShiftRight') && this.mode === 'play') this.dash();
    });
    window.addEventListener('keyup', (e) => this.keys.delete(e.code));
    window.addEventListener('blur', () => {
      this.keys.clear();
      this.firing = false;
    });

    const el = this.renderer.domElement;
    el.addEventListener('contextmenu', (e) => e.preventDefault());
    el.addEventListener('pointermove', (e) => {
      this.updateNdc(e.clientX, e.clientY);
    });
    el.addEventListener('pointerdown', (e) => {
      this.updateNdc(e.clientX, e.clientY);
      if (this.mode === 'play') {
        if (e.button === 0) this.firing = true;
        else if (e.button === 2) this.dropBomb();
      }
      // editor-mode gestures (place/erase/pan/zoom) live in the shared editor
    });
    window.addEventListener('pointerup', () => {
      this.firing = false;
    });
  }

  updateNdc(clientX: number, clientY: number): void {
    this.ndc.set((clientX / window.innerWidth) * 2 - 1, -(clientY / window.innerHeight) * 2 + 1);
  }

  /** Raycast current pointer NDC onto the floor plane. */
  pointerOnFloor(out = new THREE.Vector3()): THREE.Vector3 | null {
    this.raycaster.setFromCamera(this.ndc, this.camera);
    const hit = this.raycaster.ray.intersectPlane(this.floorPlane, out);
    return hit ? out : null;
  }
  /** Cell under the pointer, or null. */
  pointerCell(): Cell | null {
    const p = this.pointerOnFloor(this.tmpV3);
    return p ? this.worldToCell(p.x, p.z) : null;
  }

  // ---------- combat helpers ----------
  private collideMove(p: THREE.Vector3, dx: number, dz: number, r: number): void {
    const [w, h] = this.level.size;
    const blocked = (nx: number, nz: number): boolean => {
      if (nx < -w / 2 + r || nx > w / 2 - r || nz < -h / 2 + r || nz > h / 2 - r) return true;
      const minX = Math.floor(nx - r + w / 2);
      const maxX = Math.floor(nx + r + w / 2);
      const minZ = Math.floor(nz - r + h / 2);
      const maxZ = Math.floor(nz + r + h / 2);
      for (let cx = minX; cx <= maxX; cx++) {
        for (let cz = minZ; cz <= maxZ; cz++) {
          if (!this.wallSet.has(key(cx, cz))) continue;
          const wx = cx - w / 2 + 0.5;
          const wz = cz - h / 2 + 0.5;
          const ddx = Math.max(Math.abs(nx - wx) - 0.5, 0);
          const ddz = Math.max(Math.abs(nz - wz) - 0.5, 0);
          if (ddx * ddx + ddz * ddz < r * r) return true;
        }
      }
      return false;
    };
    if (dx !== 0 && !blocked(p.x + dx, p.z)) p.x += dx;
    if (dz !== 0 && !blocked(p.x, p.z + dz)) p.z += dz;
  }

  private castLaser(
    from: THREE.Vector3,
    dir: THREE.Vector3,
    targets: { pos: THREE.Vector3; r: number; ref: unknown }[]
  ): { point: THREE.Vector3; hit: unknown } {
    const [w, h] = this.level.size;
    const step = 0.07;
    const p = from.clone();
    const d = this.tmpV.copy(dir).setY(0).normalize().multiplyScalar(step);
    for (let t = 0; t < LASER_RANGE; t += step) {
      p.add(d);
      const cx = Math.floor(p.x + w / 2);
      const cz = Math.floor(p.z + h / 2);
      if (cx < 0 || cz < 0 || cx >= w || cz >= h || this.wallSet.has(key(cx, cz))) {
        return { point: p, hit: null };
      }
      for (const tg of targets) {
        const dx = p.x - tg.pos.x;
        const dz = p.z - tg.pos.z;
        if (dx * dx + dz * dz < tg.r * tg.r) return { point: p, hit: tg.ref };
      }
    }
    return { point: p, hit: null };
  }

  private fireLaser(
    from: THREE.Vector3,
    toward: THREE.Vector3,
    color: number,
    targets: { pos: THREE.Vector3; r: number; ref: unknown }[]
  ): unknown {
    const dir = this.tmpV2.copy(toward).sub(from).setY(0);
    if (dir.lengthSq() < 0.0001) dir.set(1, 0, 0);
    dir.normalize();
    const muzzle = from.clone().addScaledVector(dir, 0.34);
    muzzle.y = 0.45;
    const res = this.castLaser(muzzle, dir, targets);
    res.point.y = 0.45;
    this.fx.beam(muzzle, res.point, color);
    this.fx.muzzle(muzzle, dir, color);
    this.fx.sparks(res.point, color, res.hit ? 10 : 4, res.hit ? 3.5 : 2);
    return res.hit;
  }

  private dropBomb(): void {
    if (!this.player || this.phase !== 'playing' || this.paused) return;
    if (this.player.bombCd > 0) return;
    this.player.bombCd = BOMB_COOLDOWN;
    const { group, capMat } = makeBombMesh();
    const pos = this.player.pos.clone();
    group.position.copy(pos);
    this.scene.add(group);
    this.bombs.push({ group, capMat, pos, t: BOMB_FUSE });
  }

  private fireRocket(): void {
    if (!this.player || this.phase !== 'playing' || this.paused) return;
    if (this.player.rocketCd > 0) return;
    const aimPt = this.pointerOnFloor(this.tmpV3);
    if (!aimPt) return;
    this.player.rocketCd = ROCKET_COOLDOWN;
    const dir = aimPt.clone().sub(this.player.pos).setY(0);
    if (dir.lengthSq() < 0.0001) dir.set(1, 0, 0);
    dir.normalize();
    const group = makeRocketMesh();
    const pos = this.player.pos.clone().addScaledVector(dir, 0.45);
    pos.y = 0.5;
    group.position.copy(pos);
    group.lookAt(this.tmpV.copy(pos).add(dir));
    this.scene.add(group);
    this.rockets.push({ group, pos, dir, t: ROCKET_LIFE, hostile: false, speed: ROCKET_SPEED, radius: ROCKET_RADIUS });
    this.fx.addShake(0.08);
    this.fx.sparks(pos, 0xffaa55, 6, 2.5);
  }

  /** Gigabot rocket volley: 3 slow hostile rockets fanned toward the player. */
  private gigaVolley(g: EnemyState, target: THREE.Vector3): void {
    const base = this.tmpV2.copy(target).sub(g.pos).setY(0);
    if (base.lengthSq() < 0.001) base.set(1, 0, 0);
    base.normalize();
    const baseA = Math.atan2(base.z, base.x);
    for (let i = -1; i <= 1; i++) {
      const a = baseA + i * 0.22;
      const dir = new THREE.Vector3(Math.cos(a), 0, Math.sin(a));
      const group = makeRocketMesh();
      const pos = g.pos.clone().addScaledVector(dir, 0.8);
      pos.y = 0.6;
      group.position.copy(pos);
      group.lookAt(this.tmpV.copy(pos).add(dir));
      this.scene.add(group);
      this.rockets.push({ group, pos, dir, t: 3.2, hostile: true, speed: ENEMY_ROCKET_SPEED, radius: 1.7 });
    }
    this.fx.flash(g.pos, 0xff6a30, 14);
    this.fx.sparks(g.pos.clone().setY(0.8), 0xff6a30, 8, 3);
  }

  /** Q: electric arc that chains through nearby enemies (the frame-001 signature). */
  private fireTesla(): void {
    const player = this.player;
    if (!player || this.phase !== 'playing' || this.paused) return;
    if (player.teslaCd > 0) return;
    type Target = { pos: THREE.Vector3; e?: EnemyState; t?: TurretState };
    const pool: Target[] = [
      ...this.enemies.map((e) => ({ pos: e.pos, e })),
      ...this.turrets.map((t) => ({ pos: t.pos, t })),
    ];
    const visited = new Set<Target>();
    let src = player.pos;
    let hits = 0;
    for (let hop = 0; hop < TESLA_MAX_HITS; hop++) {
      const range = hop === 0 ? TESLA_RANGE : TESLA_CHAIN_RANGE;
      let best: Target | null = null;
      let bestD = range * range;
      for (const tg of pool) {
        if (visited.has(tg)) continue;
        const d = src.distanceToSquared(tg.pos);
        if (d < bestD && this.clearPath(src, tg.pos)) {
          best = tg;
          bestD = d;
        }
      }
      if (!best) break;
      visited.add(best);
      hits++;
      const a = src.clone().setY(0.5);
      const b = best.pos.clone().setY(0.5);
      this.fx.lightning(a, b, COL.tesla);
      this.fx.sparks(b, COL.tesla, 12, 4);
      this.fx.flash(b, COL.tesla, 10);
      if (best.e) this.damageEnemy(best.e, hop === 0 ? 2 : 1);
      else if (best.t) this.damageTurret(best.t, hop === 0 ? 2 : 1);
      src = best.pos;
    }
    if (hits === 0) {
      // fizzle: arc to nothing, short cooldown so the press isn't eaten
      const aimPt = this.pointerOnFloor(this.tmpV3);
      const dir = aimPt ? this.tmpV.copy(aimPt).sub(player.pos).setY(0).normalize() : this.tmpV.set(1, 0, 0);
      const end = player.pos.clone().addScaledVector(dir, 1.6).setY(0.5);
      this.fx.lightning(player.pos.clone().setY(0.5), end, COL.tesla, 0.12);
      player.teslaCd = 0.6;
    } else {
      player.teslaCd = TESLA_COOLDOWN;
      this.fx.addShake(0.12);
    }
  }

  /** V: close-range blade spin — AoE around the player. */
  private bladeSpin(): void {
    const player = this.player;
    if (!player || this.phase !== 'playing' || this.paused) return;
    if (player.bladeCd > 0) return;
    player.bladeCd = BLADE_COOLDOWN;
    this.fx.ring(player.pos, BLADE_RADIUS * 1.15, 0x66f0ff, 0.3);
    this.fx.ring(player.pos, BLADE_RADIUS * 0.7, 0xbffaff, 0.2);
    // tangential blade-glint swirl
    for (let i = 0; i < 26; i++) {
      const a = (i / 26) * Math.PI * 2;
      const rr = 0.5 + Math.random() * 0.5;
      this.fx.spawnParticle(
        player.pos.x + Math.cos(a) * rr, 0.35, player.pos.z + Math.sin(a) * rr,
        -Math.sin(a) * 7, 0.6 + Math.random(), Math.cos(a) * 7,
        i % 3 === 0 ? 0xffffff : 0x66f0ff, 0.18 + Math.random() * 0.12, 3
      );
    }
    this.fx.addShake(0.12);
    for (const e of [...this.enemies]) {
      if (e.pos.distanceTo(player.pos) < BLADE_RADIUS + e.r) this.damageEnemy(e, 2);
    }
    for (const t of [...this.turrets]) {
      if (t.pos.distanceTo(player.pos) < BLADE_RADIUS + 0.34) this.damageTurret(t, 2);
    }
  }

  /** C: deployable sentry orb — hovers in place, zaps the nearest enemy. */
  private deployOrb(): void {
    const player = this.player;
    if (!player || this.phase !== 'playing' || this.paused) return;
    if (player.orbCd > 0) return;
    player.orbCd = ORB_COOLDOWN;
    const group = makeOrbMesh();
    const pos = player.pos.clone();
    group.position.copy(pos).setY(0.85);
    this.scene.add(group);
    this.orbs.push({ group, pos, t: ORB_LIFE, fireCd: 0.4 });
    this.fx.ring(pos, 0.9, COL.orb, 0.35);
    this.fx.sparks(pos.clone().setY(0.85), COL.orb, 10, 2.5);
  }

  /** Shift: phase-dash — burst of speed + brief invulnerability. */
  private dash(): void {
    const player = this.player;
    if (!player || this.phase !== 'playing' || this.paused) return;
    if (player.dashCd > 0) return;
    let mx = 0;
    let mz = 0;
    if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) mz -= 1;
    if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) mz += 1;
    if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) mx -= 1;
    if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) mx += 1;
    if (mx !== 0 || mz !== 0) {
      const inv = 1 / Math.hypot(mx, mz);
      player.dashDir.set(mx * inv, 0, mz * inv);
    } else {
      // stationary: dash toward the aim point
      const aimPt = this.pointerOnFloor(this.tmpV3);
      if (aimPt) {
        player.dashDir.copy(aimPt).sub(player.pos).setY(0);
        if (player.dashDir.lengthSq() < 0.001) player.dashDir.set(1, 0, 0);
        player.dashDir.normalize();
      }
    }
    player.dashCd = DASH_COOLDOWN;
    player.dashT = DASH_TIME;
    player.invulnT = 0.35;
    this.fx.ring(player.pos, 0.8, COL.playerRing, 0.25);
    this.fx.addShake(0.06);
  }

  private explodeRocket(r: RocketState): void {
    this.scene.remove(r.group);
    disposeGroup(r.group);
    this.rockets = this.rockets.filter((x) => x !== r);
    const hurt = (pos: THREE.Vector3): boolean => pos.distanceTo(r.pos) < r.radius + 0.25;
    if (r.hostile) {
      this.fx.explosion(r.pos, r.radius, 1.1);
      this.fx.scorch(r.pos, r.radius * 0.55);
      if (this.player && hurt(this.player.pos)) this.damagePlayer(1);
    } else {
      // bigger than the bomb: double shockwave + heavier shake
      this.fx.explosion(r.pos, r.radius, 1.9);
      this.fx.ring(r.pos, r.radius * 1.6, 0xffe0a0, 0.65);
      this.fx.scorch(r.pos, r.radius * 0.5);
      this.fx.addShake(0.25);
      for (const e of [...this.enemies]) if (hurt(e.pos)) this.damageEnemy(e, 2);
      for (const t of [...this.turrets]) if (hurt(t.pos)) this.damageTurret(t, 2);
      // lighter self-damage: unlike the bomb there's no fuse to outrun
      if (this.player && hurt(this.player.pos)) this.damagePlayer(1);
    }
    for (const b of this.bombs) if (hurt(b.pos)) b.t = Math.min(b.t, 0.08);
  }

  private updateRockets(dt: number): void {
    const [w, h] = this.level.size;
    for (const r of [...this.rockets]) {
      r.t -= dt;
      const step = r.speed * dt;
      r.pos.addScaledVector(r.dir, step);
      r.group.position.copy(r.pos);
      r.group.rotation.z += dt * 9; // slow roll
      // exhaust trail: flame core + rising glow-smoke
      const tail = this.tmpV.copy(r.pos).addScaledVector(r.dir, -0.22);
      for (let i = 0; i < 2; i++) {
        this.fx.spawnParticle(
          tail.x + (Math.random() - 0.5) * 0.06, tail.y, tail.z + (Math.random() - 0.5) * 0.06,
          -r.dir.x * 1.2 + (Math.random() - 0.5), Math.random() * 0.6, -r.dir.z * 1.2 + (Math.random() - 0.5),
          Math.random() < 0.5 ? 0xff8a3c : 0xffd27a, 0.16 + Math.random() * 0.14, 0
        );
      }
      this.fx.spawnParticle(
        tail.x, tail.y + 0.04, tail.z,
        (Math.random() - 0.5) * 0.4, 0.5 + Math.random() * 0.5, (Math.random() - 0.5) * 0.4,
        0x55687f, 0.45 + Math.random() * 0.35, -1.2
      );
      // impact: walls / bounds
      const cx = Math.floor(r.pos.x + w / 2);
      const cz = Math.floor(r.pos.z + h / 2);
      if (cx < 0 || cz < 0 || cx >= w || cz >= h || this.wallSet.has(key(cx, cz))) {
        r.pos.addScaledVector(r.dir, -0.3); // blast just in front of the wall
        this.explodeRocket(r);
        continue;
      }
      // impact: bots (friendly rockets) or the player (hostile)
      let boom = r.t <= 0;
      if (!boom && r.hostile) {
        if (this.player && this.player.pos.distanceToSquared(r.pos) < 0.3) boom = true;
      } else if (!boom) {
        for (const e of this.enemies) if (e.pos.distanceToSquared(r.pos) < (e.r + 0.18) ** 2) { boom = true; break; }
        if (!boom) for (const t of this.turrets) if (t.pos.distanceToSquared(r.pos) < 0.25) { boom = true; break; }
      }
      if (boom) this.explodeRocket(r);
    }
  }

  /** Yellow floor-lasers auto-linking nearby enemy bots (the reference-GIF look). Touching one hurts. */
  private updateLinkLasers(dt: number): void {
    this.linkHurtCd = Math.max(0, this.linkHurtCd - dt);
    this.linkSparkT -= dt;
    const doSpark = this.linkSparkT <= 0;
    if (doSpark) this.linkSparkT = 0.16;
    const player = this.player;
    let links = 0;
    for (let i = 0; i < this.enemies.length && links < 12; i++) {
      for (let j = i + 1; j < this.enemies.length && links < 12; j++) {
        const a = this.enemies[i].pos;
        const b = this.enemies[j].pos;
        if (a.distanceToSquared(b) > LINK_RANGE * LINK_RANGE) continue;
        if (!this.clearPath(a, b)) continue;
        links++;
        this.fx.link(a, b, COL.laser, this.time);
        if (doSpark) {
          this.tmpV.copy(a).setY(0.12);
          this.fx.sparks(this.tmpV, COL.laser, 3, 1.6);
          this.tmpV.copy(b).setY(0.12);
          this.fx.sparks(this.tmpV, COL.laser, 3, 1.6);
        }
        // hurt the player for stepping over the wire
        if (player && this.linkHurtCd <= 0) {
          const d = this.segDist(player.pos, a, b);
          if (d < 0.34) {
            this.linkHurtCd = 0.9;
            this.damagePlayer(1);
            this.fx.sparks(player.pos.clone().setY(0.3), COL.laser, 14, 4);
          }
        }
      }
    }
  }

  /** XZ distance from point p to segment ab. */
  private segDist(p: THREE.Vector3, a: THREE.Vector3, b: THREE.Vector3): number {
    const abx = b.x - a.x;
    const abz = b.z - a.z;
    const len2 = abx * abx + abz * abz;
    const t = len2 < 0.0001 ? 0 : THREE.MathUtils.clamp(((p.x - a.x) * abx + (p.z - a.z) * abz) / len2, 0, 1);
    const dx = p.x - (a.x + abx * t);
    const dz = p.z - (a.z + abz * t);
    return Math.hypot(dx, dz);
  }

  /** Wall-free straight line between two bot positions. */
  private clearPath(a: THREE.Vector3, b: THREE.Vector3): boolean {
    const dir = this.tmpV.copy(b).sub(a);
    const res = this.castLaser(this.tmpV2.copy(a).setY(0.45), dir, [{ pos: b, r: 0.45, ref: 1 }]);
    return res.hit === 1;
  }

  private explodeBomb(b: BombState): void {
    this.scene.remove(b.group);
    disposeGroup(b.group);
    this.fx.explosion(b.pos, BOMB_RADIUS, 1.3);
    this.fx.scorch(b.pos, BOMB_RADIUS * 0.55);
    const hurt = (pos: THREE.Vector3): boolean => pos.distanceTo(b.pos) < BOMB_RADIUS + 0.25;
    for (const e of [...this.enemies]) if (hurt(e.pos)) this.damageEnemy(e, 2);
    for (const t of [...this.turrets]) if (hurt(t.pos)) this.damageTurret(t, 2);
    if (this.player && hurt(this.player.pos)) this.damagePlayer(2);
    // sympathetic detonation
    for (const other of this.bombs) {
      if (other !== b && hurt(other.pos)) other.t = Math.min(other.t, 0.08);
    }
  }

  /** Spawn glowing scrap chunks that scatter, then magnetize to the player. */
  private dropScrap(pos: THREE.Vector3, n: number): void {
    for (let i = 0; i < n; i++) {
      if (this.scraps.length >= SCRAP_MAX) {
        const old = this.scraps.shift();
        if (old) {
          this.scene.remove(old.mesh);
          disposeGroup(old.mesh);
        }
      }
      const mesh = makeScrapMesh();
      const p = pos.clone();
      p.y = 0.3;
      mesh.position.copy(p);
      this.scene.add(mesh);
      const a = Math.random() * Math.PI * 2;
      const s = 1 + Math.random() * 2;
      this.scraps.push({
        mesh, pos: p,
        vel: new THREE.Vector3(Math.cos(a) * s, 2 + Math.random() * 2, Math.sin(a) * s),
        t: 0, spin: 2 + Math.random() * 4,
      });
    }
  }

  private damageEnemy(e: EnemyState, n: number): void {
    e.hp -= n;
    e.flash = 0.14;
    if (e.hp <= 0) {
      this.scene.remove(e.rig.group);
      disposeGroup(e.rig.group);
      this.enemies = this.enemies.filter((x) => x !== e);
      if (e.tier === 'giga') {
        // multi-stage death: three staggered blasts + a big scorch
        this.fx.explosion(e.pos, 2.4, 1.6);
        this.fx.scorch(e.pos, 1.7);
        for (let i = 1; i <= 2; i++) {
          const jit = e.pos.clone();
          jit.x += (Math.random() - 0.5) * 1.2;
          jit.z += (Math.random() - 0.5) * 1.2;
          this.pendingBooms.push({ pos: jit, t: i * 0.28, radius: 1.6, power: 1.1 });
        }
        this.dropScrap(e.pos, 6);
      } else {
        this.fx.explosion(e.pos, 1.6, 1);
        this.fx.scorch(e.pos, 0.8);
        this.dropScrap(e.pos, 2 + ((Math.random() * 2) | 0));
      }
      this.checkWin();
    }
  }
  private damageTurret(t: TurretState, n: number): void {
    t.hp -= n;
    t.flash = 0.14;
    if (t.hp <= 0) {
      this.scene.remove(t.rig.group);
      disposeGroup(t.rig.group);
      this.turrets = this.turrets.filter((x) => x !== t);
      this.fx.explosion(t.pos, 1.5, 0.9);
      this.fx.scorch(t.pos, 0.7);
      this.dropScrap(t.pos, 2);
      this.checkWin();
    }
  }
  private damagePlayer(n: number): void {
    if (!this.player || this.phase !== 'playing') return;
    if (this.player.invulnT > 0) return; // phase-dash i-frames
    this.player.hp -= n;
    this.player.flash = 0.16;
    this.fx.addShake(0.22);
    this.hud?.setHP(Math.max(0, this.player.hp), PLAYER_HP);
    if (this.player.hp <= 0) {
      this.fx.explosion(this.player.pos, 2, 1.4);
      this.scene.remove(this.player.rig.group);
      disposeGroup(this.player.rig.group);
      this.player = null;
      this.phase = 'dead';
      this.hud?.setMessage('BOT DESTROYED', 'press R to retry · E to edit');
    }
  }
  private checkWin(): void {
    if (this.phase !== 'playing' || this.enemies.length > 0 || this.turrets.length > 0) return;
    if (this.level.tree && this.treeRig) {
      // defenses down: the Tree of Life unlocks — reach it to win
      if (!this.treeUnlocked) {
        this.treeUnlocked = true;
        this.treeRig.beam.visible = true;
        this.fx.addShake(0.12);
        this.fx.ring(this.treeRig.group.position, 3.2, COL.tree, 0.8);
        this.hud?.setMessage('DEFENSES DOWN', 'reach the Tree of Life');
        this.tempMsgT = 2.8;
      }
    } else {
      this.phase = 'cleared';
      this.fx.addShake(0.15);
      this.hud?.setMessage('LEVEL CLEARED', 'press R to replay · E to edit');
    }
  }

  private hasLOS(from: THREE.Vector3, to: THREE.Vector3): boolean {
    if (!this.player) return false;
    const dir = this.tmpV.copy(to).sub(from);
    const res = this.castLaser(
      this.tmpV2.copy(from).setY(0.45),
      dir,
      [{ pos: to, r: 0.4, ref: 1 }]
    );
    return res.hit === 1;
  }

  // ---------- frame ----------
  start(): void {
    this.renderer.setAnimationLoop(() => this.frame());
  }

  private frame(): void {
    const dt = Math.min(this.clock.getDelta(), 0.05);
    this.time += dt;

    this.fx.beginLinks();
    if (this.mode === 'play' && !this.paused && this.phase === 'playing') this.simulate(dt);
    // staged explosions (gigabot death) keep going even after the phase flips
    for (const pb of [...this.pendingBooms]) {
      pb.t -= dt;
      if (pb.t <= 0) {
        this.pendingBooms = this.pendingBooms.filter((x) => x !== pb);
        this.fx.explosion(pb.pos, pb.radius, pb.power);
        this.fx.scorch(pb.pos, pb.radius * 0.6);
      }
    }
    // transient center messages clear themselves
    if (this.tempMsgT > 0) {
      this.tempMsgT -= dt;
      if (this.tempMsgT <= 0 && this.phase === 'playing') this.hud?.setMessage(null);
    }
    // Tree of Life idle: pulse, slow beam swirl, rising spores
    if (this.treeRig) {
      const pulse = this.treeUnlocked ? 1.4 + 0.5 * Math.sin(this.time * 3) : 0.45 + 0.18 * Math.sin(this.time * 1.6);
      this.treeRig.canopyMat.emissiveIntensity = pulse;
      this.treeRig.light.intensity = this.treeUnlocked ? 4.5 : 2.2;
      if (this.treeUnlocked) {
        this.treeRig.beam.rotation.y += dt * 0.7;
        this.treeRig.beamMat.opacity = 0.16 + 0.07 * Math.sin(this.time * 2.4);
      }
      this.sporeT -= dt;
      if (this.sporeT <= 0 && this.mode === 'play') {
        this.sporeT = this.treeUnlocked ? 0.05 : 0.22;
        const tp = this.treeRig.group.position;
        this.fx.spawnParticle(
          tp.x + (Math.random() - 0.5) * 0.9, 0.3 + Math.random() * 0.8, tp.z + (Math.random() - 0.5) * 0.9,
          (Math.random() - 0.5) * 0.3, 0.5 + Math.random() * 0.5, (Math.random() - 0.5) * 0.3,
          COL.tree, 1 + Math.random(), -0.4
        );
      }
    }
    this.fx.update(dt);

    // accent tile pulse
    for (const mesh of this.accentMeshes.values()) {
      const mat = mesh.material as THREE.MeshBasicMaterial;
      const ph = (mat.userData.phase as number) ?? 0;
      mat.opacity = 0.5 + 0.22 * Math.sin(this.time * 1.8 + ph);
    }

    // editor hover ghost
    if (this.mode === 'editor') {
      const cell = this.pointerCell();
      this.hoverCell = cell;
      if (cell) {
        this.ghost.visible = true;
        this.cellToWorld(cell, this.ghost.position);
        this.ghost.position.y = 0.06;
      } else {
        this.ghost.visible = false;
      }
    }

    // camera
    const [w, h] = this.level.size;
    let dist: number;
    if (this.mode === 'editor') {
      // arrow keys pan too (speed scales with zoom)
      let px = 0;
      let pz = 0;
      if (this.keys.has('ArrowUp')) pz -= 1;
      if (this.keys.has('ArrowDown')) pz += 1;
      if (this.keys.has('ArrowLeft')) px -= 1;
      if (this.keys.has('ArrowRight')) px += 1;
      if (px !== 0 || pz !== 0) {
        const v = this.edDist * 0.65 * dt;
        this.edTarget.x = THREE.MathUtils.clamp(this.edTarget.x + px * v, -w / 2 - 2, w / 2 + 2);
        this.edTarget.z = THREE.MathUtils.clamp(this.edTarget.z + pz * v, -h / 2 - 2, h / 2 + 2);
      }
      this.camTarget.copy(this.edTarget);
      dist = this.edDist;
    } else {
      // play: big arenas follow the player closely, tiny ones frame the whole thing
      dist = Math.min(Math.max(w, h) * 0.92 + 4, 17.5);
      const follow = Math.max(w, h) > 16 ? 0.85 : 0.35;
      this.camTarget.set(0, 0, 0);
      if (this.player) this.camTarget.lerp(this.player.pos, follow);
    }
    this.fx.dustCenter.copy(this.camTarget);
    // fog tracks zoom so a zoomed-out editor still sees the whole arena
    const fog = this.scene.fog as THREE.Fog;
    fog.near = dist * 1.25;
    fog.far = dist * 3.2;
    this.tmpV.set(0, 1.05, 0.66).normalize().multiplyScalar(dist).add(this.camTarget);
    this.fx.shakeOffset(this.tmpV2);
    this.camera.position.copy(this.tmpV).add(this.tmpV2);
    this.camera.lookAt(this.camTarget.x + this.tmpV2.x * 0.5, 0, this.camTarget.z + this.tmpV2.z * 0.5);

    this.composer.render();
  }

  private simulate(dt: number): void {
    const player = this.player;
    if (!player) return;

    // --- player movement (dash overrides normal input) ---
    player.invulnT = Math.max(0, player.invulnT - dt);
    let moving: boolean;
    if (player.dashT > 0) {
      player.dashT -= dt;
      this.collideMove(player.pos, player.dashDir.x * DASH_SPEED * dt, player.dashDir.z * DASH_SPEED * dt, BOT_RADIUS);
      player.bobT += dt * 2;
      moving = true;
      // phase trail
      for (let i = 0; i < 3; i++) {
        this.fx.spawnParticle(
          player.pos.x + (Math.random() - 0.5) * 0.3, 0.25 + Math.random() * 0.3, player.pos.z + (Math.random() - 0.5) * 0.3,
          -player.dashDir.x * 2, 0.3, -player.dashDir.z * 2,
          COL.playerRing, 0.22 + Math.random() * 0.15, 0
        );
      }
    } else {
      let mx = 0;
      let mz = 0;
      if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) mz -= 1;
      if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) mz += 1;
      if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) mx -= 1;
      if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) mx += 1;
      moving = mx !== 0 || mz !== 0;
      if (moving) {
        const inv = 1 / Math.hypot(mx, mz);
        this.collideMove(player.pos, mx * inv * PLAYER_SPEED * dt, mz * inv * PLAYER_SPEED * dt, BOT_RADIUS);
        player.bobT += dt;
      }
    }
    player.rig.group.position.copy(player.pos);
    this.animateLegs(player.rig, player.bobT, moving);

    // --- aim + fire ---
    const aimPt = this.pointerOnFloor(this.aim);
    if (aimPt) {
      const dx = aimPt.x - player.pos.x;
      const dz = aimPt.z - player.pos.z;
      if (dx * dx + dz * dz > 0.01) player.rig.head.rotation.y = Math.atan2(dx, dz);
    }
    player.fireCd -= dt;
    player.bombCd -= dt;
    player.rocketCd -= dt;
    player.teslaCd -= dt;
    player.bladeCd -= dt;
    player.orbCd -= dt;
    player.dashCd -= dt;
    this.hud?.setCd(SLOT.bomb, Math.max(0, player.bombCd) / BOMB_COOLDOWN);
    this.hud?.setCd(SLOT.rocket, Math.max(0, player.rocketCd) / ROCKET_COOLDOWN);
    this.hud?.setCd(SLOT.tesla, Math.max(0, player.teslaCd) / TESLA_COOLDOWN);
    this.hud?.setCd(SLOT.blade, Math.max(0, player.bladeCd) / BLADE_COOLDOWN);
    this.hud?.setCd(SLOT.orb, Math.max(0, player.orbCd) / ORB_COOLDOWN);
    this.hud?.setCd(SLOT.dash, Math.max(0, player.dashCd) / DASH_COOLDOWN);
    if (this.firing && player.fireCd <= 0 && aimPt) {
      player.fireCd = 0.22;
      const targets = [
        ...this.enemies.map((e) => ({ pos: e.pos, r: e.r, ref: e as unknown })),
        ...this.turrets.map((t) => ({ pos: t.pos, r: 0.34, ref: t as unknown })),
      ];
      const hit = this.fireLaser(player.pos, aimPt, COL.laser, targets);
      this.fx.addShake(0.025);
      if (hit) {
        const e = this.enemies.find((x) => x === hit);
        if (e) this.damageEnemy(e, 1);
        else {
          const t = this.turrets.find((x) => x === hit);
          if (t) this.damageTurret(t, 1);
        }
      }
    }
    player.flash = Math.max(0, player.flash - dt);
    player.rig.bodyMat.emissiveIntensity = player.flash > 0 ? 2.5 : 0;

    // --- enemies ---
    for (const e of [...this.enemies]) {
      const giga = e.tier === 'giga';
      const toPlayer = this.tmpV.copy(player.pos).sub(e.pos);
      const dist = toPlayer.length();
      const los = dist < (giga ? 12 : 9) && this.hasLOS(e.pos, player.pos);
      let moved = false;
      if (los && dist > (giga ? 3.2 : 2.3)) {
        // recompute: hasLOS clobbers the shared temp vectors
        const step = (giga ? GIGA_SPEED : ENEMY_SPEED) * dt;
        const dirv = this.tmpV.copy(player.pos).sub(e.pos).normalize();
        this.collideMove(e.pos, dirv.x * step, dirv.z * step, e.r);
        moved = true;
      } else if (!los) {
        e.wanderT -= dt;
        if (e.wanderT <= 0) {
          e.wanderT = 1.2 + Math.random() * 2;
          e.wanderA = Math.random() * Math.PI * 2;
        }
        const step = (giga ? GIGA_SPEED : ENEMY_SPEED) * 0.55 * dt;
        const before = this.tmpV2.copy(e.pos);
        this.collideMove(e.pos, Math.cos(e.wanderA) * step, Math.sin(e.wanderA) * step, e.r);
        if (before.distanceToSquared(e.pos) < step * step * 0.1) e.wanderT = 0; // stuck → new direction
        moved = true;
      }
      // separation
      for (const o of this.enemies) {
        if (o === e) continue;
        const d2 = e.pos.distanceToSquared(o.pos);
        const minD = (e.r + o.r + 0.1) ** 2;
        if (d2 > 0.0001 && d2 < minD) {
          const push = this.tmpV2.copy(e.pos).sub(o.pos).normalize().multiplyScalar(dt * 1.5);
          this.collideMove(e.pos, push.x, push.z, e.r);
        }
      }
      if (moved) e.bobT += dt;
      e.rig.group.position.copy(e.pos);
      this.animateLegs(e.rig, e.bobT, moved);

      // aim head
      const hd = this.tmpV.copy(player.pos).sub(e.pos);
      e.rig.head.rotation.y = Math.atan2(hd.x, hd.z);

      // fire
      e.fireCd -= dt;
      if (giga) {
        // rocket volley at range, ground slam up close
        if (e.fireCd <= 0 && dist < 11 && dist > 2.6 && los) {
          e.fireCd = GIGA_VOLLEY_CD + Math.random();
          this.gigaVolley(e, player.pos);
        }
        e.slamCd -= dt;
        if (e.slamCd <= 0 && dist < 2.4) {
          e.slamCd = GIGA_SLAM_CD;
          this.fx.ring(e.pos, GIGA_SLAM_RADIUS * 1.2, 0xff7a50, 0.5);
          this.fx.ring(e.pos, GIGA_SLAM_RADIUS * 0.6, 0xffc0a0, 0.3);
          this.fx.sparks(e.pos.clone().setY(0.15), 0xff7a50, 18, 5);
          this.fx.scorch(e.pos, 1.2);
          this.fx.addShake(0.35);
          if (player.pos.distanceTo(e.pos) < GIGA_SLAM_RADIUS) this.damagePlayer(1);
        }
      } else if (e.fireCd <= 0 && dist < 7 && los) {
        e.fireCd = 1.15 + Math.random() * 0.7;
        const err = (Math.random() - 0.5) * 0.5;
        const target = this.tmpV2.copy(player.pos);
        target.x += Math.cos(err * 4) * err;
        target.z += Math.sin(err * 4) * err;
        const hit = this.fireLaser(e.pos, target, COL.enemyLaser, [
          { pos: player.pos, r: 0.32, ref: player as unknown },
        ]);
        if (hit === (player as unknown)) this.damagePlayer(1);
      }
      e.flash = Math.max(0, e.flash - dt);
      e.rig.bodyMat.emissiveIntensity = e.flash > 0 ? 2.5 : 0;
    }

    // --- turrets ---
    for (const t of [...this.turrets]) {
      const hd = this.tmpV.copy(player.pos).sub(t.pos);
      const dist = hd.length();
      const targetYaw = Math.atan2(hd.x, hd.z);
      let dy = targetYaw - t.rig.head.rotation.y;
      while (dy > Math.PI) dy -= Math.PI * 2;
      while (dy < -Math.PI) dy += Math.PI * 2;
      t.rig.head.rotation.y += THREE.MathUtils.clamp(dy, -2.2 * dt, 2.2 * dt);
      t.fireCd -= dt;
      if (t.fireCd <= 0 && dist < 7.5 && Math.abs(dy) < 0.2 && this.hasLOS(t.pos, player.pos)) {
        t.fireCd = 1.7;
        const hit = this.fireLaser(t.pos, player.pos, COL.enemyLaser, [
          { pos: player.pos, r: 0.32, ref: player as unknown },
        ]);
        if (hit === (player as unknown)) this.damagePlayer(1);
      }
      t.flash = Math.max(0, t.flash - dt);
      t.rig.bodyMat.emissiveIntensity = t.flash > 0 ? 2.5 : 0;
    }

    // --- rockets + enemy link-lasers ---
    this.updateRockets(dt);
    this.updateLinkLasers(dt);

    // --- bombs ---
    for (const b of [...this.bombs]) {
      b.t -= dt;
      if (b.t <= 0) {
        this.bombs = this.bombs.filter((x) => x !== b);
        this.explodeBomb(b);
        continue;
      }
      const urgency = 1 - b.t / BOMB_FUSE;
      const blink = Math.sin(this.time * (8 + urgency * 30)) > 0;
      b.capMat.color.set(blink ? 0xffdd44 : 0xff3311);
      const s = 1 + Math.sin(this.time * (8 + urgency * 30)) * 0.07 * urgency;
      b.group.scale.set(s, s, s);
    }

    // --- scrap pickups ---
    this.updateScrap(dt);

    // --- sentry orbs ---
    this.updateOrbs(dt);

    // --- Tree of Life: touch it once unlocked to win ---
    if (this.treeUnlocked && this.treeRig && player.pos.distanceTo(this.treeRig.group.position) < 0.95) {
      this.phase = 'cleared';
      this.fx.ring(this.treeRig.group.position, 5, COL.tree, 1);
      this.fx.sparks(this.treeRig.group.position.clone().setY(1), COL.tree, 40, 5);
      this.fx.flash(this.treeRig.group.position, COL.tree, 30);
      this.fx.addShake(0.2);
      this.hud?.setMessage('TREE OF LIFE RECOVERED', 'press R to replay · E to edit');
    }
  }

  private updateScrap(dt: number): void {
    const player = this.player;
    for (const s of [...this.scraps]) {
      s.t += dt;
      const toPlayer = player ? s.pos.distanceTo(player.pos) : 99;
      if (player && toPlayer < SCRAP_PICKUP_RANGE) {
        this.scrapCount++;
        this.hud?.setScrap(this.scrapCount);
        this.fx.sparks(s.pos.clone().setY(0.3), COL.scrap, 6, 2);
        this.scene.remove(s.mesh);
        disposeGroup(s.mesh);
        this.scraps = this.scraps.filter((x) => x !== s);
        continue;
      }
      if (player && toPlayer < SCRAP_MAGNET_RANGE && s.t > 0.35) {
        // magnetize: home in, faster as it gets closer
        const pull = this.tmpV.copy(player.pos).sub(s.pos).normalize()
          .multiplyScalar(4 + (SCRAP_MAGNET_RANGE - toPlayer) * 7);
        s.vel.lerp(pull, Math.min(1, dt * 10));
        s.pos.addScaledVector(s.vel, dt);
      } else {
        // scatter physics: gravity + floor bounce, then settle
        s.vel.y -= 9 * dt;
        s.pos.addScaledVector(s.vel, dt);
        if (s.pos.y < 0.12) {
          s.pos.y = 0.12;
          s.vel.y *= -0.4;
          s.vel.x *= 0.6;
          s.vel.z *= 0.6;
        }
      }
      s.mesh.position.copy(s.pos);
      s.mesh.position.y += 0.02 * Math.sin(this.time * 3 + s.spin);
      s.mesh.rotation.y += dt * s.spin;
    }
  }

  private updateOrbs(dt: number): void {
    for (const o of [...this.orbs]) {
      o.t -= dt;
      if (o.t <= 0) {
        this.fx.sparks(o.pos.clone().setY(0.85), COL.orb, 12, 3);
        this.scene.remove(o.group);
        disposeGroup(o.group);
        this.orbs = this.orbs.filter((x) => x !== o);
        continue;
      }
      o.group.position.y = 0.85 + Math.sin(this.time * 2.6) * 0.08;
      o.group.rotation.y += dt * 1.8;
      // blink when about to expire
      o.group.visible = o.t > 1.5 || Math.sin(this.time * 14) > -0.3;
      o.fireCd -= dt;
      if (o.fireCd <= 0) {
        let best: EnemyState | null = null;
        let bestD = ORB_RANGE * ORB_RANGE;
        for (const e of this.enemies) {
          const d = o.pos.distanceToSquared(e.pos);
          if (d < bestD && this.clearPath(o.pos, e.pos)) {
            best = e;
            bestD = d;
          }
        }
        if (best) {
          o.fireCd = ORB_FIRE_CD;
          const from = o.group.position.clone();
          const to = best.pos.clone().setY(0.45);
          this.fx.beam(from, to, COL.orb);
          this.fx.sparks(to, COL.orb, 6, 2.5);
          this.damageEnemy(best, 1);
        } else {
          o.fireCd = 0.2; // rescan soon
        }
      }
    }
  }

  /**
   * Procedural gait with an IK feel: each leg alternates a slow planted
   * "stance" sweep (foot pinned, body passes over it) and a fast lifted
   * "swing" forward — two tripod groups out of phase, like a real spider.
   */
  private animateLegs(rig: BotRig, t: number, moving: boolean): void {
    const STANCE = 0.62; // fraction of the cycle the foot stays planted
    const RATE = 2.1; // gait cycles per second of bobT
    for (const leg of rig.legs) {
      const base = leg.userData.baseYaw as number;
      const phase = leg.userData.phase as number;
      const upper = leg.userData.upper as THREE.Mesh | undefined;
      const lower = leg.userData.lower as THREE.Mesh | undefined;
      if (!moving) {
        // ease back to the neutral pose
        leg.rotation.y += (base - leg.rotation.y) * 0.18;
        if (upper) upper.rotation.z += (0.55 - upper.rotation.z) * 0.18;
        if (lower) lower.rotation.z += (-1.18 - lower.rotation.z) * 0.18;
        continue;
      }
      const u = ((t * RATE + phase / (Math.PI * 2)) % 1 + 1) % 1;
      let yawOff: number;
      let lift: number;
      if (u < STANCE) {
        // planted: linear sweep back while the body moves forward
        yawOff = THREE.MathUtils.lerp(0.2, -0.2, u / STANCE);
        lift = 0;
      } else {
        // swing: snap forward with a knee lift
        const s = (u - STANCE) / (1 - STANCE);
        const e = s * s * (3 - 2 * s);
        yawOff = THREE.MathUtils.lerp(-0.2, 0.2, e);
        lift = Math.sin(s * Math.PI);
      }
      leg.rotation.y = base + yawOff;
      if (upper) upper.rotation.z = 0.55 + lift * 0.5;
      if (lower) lower.rotation.z = -1.18 - lift * 0.55;
    }
    // two tripods → two body dips per gait cycle
    rig.group.position.y = moving ? Math.abs(Math.sin(t * RATE * Math.PI * 2)) * 0.028 : 0;
  }
}
