import * as THREE from 'three';
import { makePlanetSpec, pickSchemes } from './planet.js';
import { createSpace } from './space.js';
import { createSurface, FPController } from './surface.js';
import { createShip, createParkedShip, ShipController } from './ship.js';
import { Scanner, Miner, Bursts } from './scanner.js';
import { createPost } from './post.js';
import { systemName } from './names.js';
import { hud } from './hud.js';
import { audio } from './audio.js';

// ---------------- renderer / camera ----------------
const canvasHost = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.info.autoReset = false; // reset once per frame so diagnostics include all post passes
canvasHost.appendChild(renderer.domElement);
window.__renderer = renderer;

const camera = new THREE.PerspectiveCamera(72, window.innerWidth / window.innerHeight, 0.1, 12000);
const ACCENT = new THREE.Color(0x46f0e6);

// ---------------- state ----------------
const PARAMS = new URLSearchParams(location.search);
const forcedSeed = PARAMS.get('seed');
const SYSTEM_SEED = forcedSeed != null
  ? (parseInt(forcedSeed, 10) >>> 0)
  : ((window.crypto?.getRandomValues(new Uint32Array(1))[0] || Date.now()) % 1e9) >>> 0;
const NUM_PLANETS = 3;
const specs = [];
const schemeIdxs = pickSchemes(SYSTEM_SEED % 7, NUM_PLANETS);
for (let i = 0; i < NUM_PLANETS; i++) specs.push(makePlanetSpec((SYSTEM_SEED ^ (i * 0x9e3779b9 + 7)) >>> 0, i, schemeIdxs[i]));

const state = {
  phase: 'title',          // title | playing | win
  mode: 'space',           // space | entry | surface
  planetIndex: 0,
  units: 0,
  resources: 0,
  scans: 0,
  scannedSpecies: new Set(),  // "i:species" keys
  scannedMinerals: new Set(),
  visited: new Set(),
  goal: { scanNeed: 5, mineNeed: 40, repaired: false },
  systemName: systemName(SYSTEM_SEED),
};

// ---------------- scenes ----------------
const space = createSpace(specs, SYSTEM_SEED);
const ship = createShip();
space.scene.add(ship);
const shipCtl = new ShipController(ship);

// speed lines (parented to camera, used on boost + entry)
const speedlines = makeSpeedLines();
camera.add(speedlines.group);
space.scene.add(camera); // camera in space initially

const surfaces = {}; // index -> built surface bundle
function getSurface(idx) {
  if (!surfaces[idx]) {
    const s = createSurface(specs[idx], specs, space.sunDir);
    // parked ship
    const parked = createParkedShip();
    const px = 5, pz = 11;
    parked.position.set(px, s.heightAt(px, pz) + 1.7, pz);
    parked.rotation.y = -0.6;
    s.scene.add(parked);
    s.parked = parked;
    s.bursts = new Bursts(s.scene);
    s.scanner = new Scanner(s.scene, ACCENT);
    s.miner = new Miner(s.scene, s.bursts, specs[idx].palette.crystalEmissive.clone());
    s.fp = new FPController(s.heightAt, specs[idx].gravity);
    surfaces[idx] = s;
  }
  return surfaces[idx];
}
let cur = null; // current surface bundle when on foot

// ---------------- post ----------------
const post = createPost(renderer, space.scene, camera);

// ---------------- input ----------------
const input = { f: 0, b: 0, l: 0, r: 0, thrust: 0, boost: 0, jump: 0, mine: 0, mouseDX: 0, mouseDY: 0 };
let pointerLocked = false;
const isTouch = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
if (isTouch) document.body.classList.add('touch');
if (new URLSearchParams(location.search).has('debug')) document.body.classList.add('debug');

const keyMap = {
  KeyW: 'thrust', KeyS: 'b', KeyA: 'l', KeyD: 'r',
  ShiftLeft: 'boost', ShiftRight: 'boost', Space: 'jump',
};
addEventListener('keydown', (e) => {
  if (state.phase !== 'playing') return;
  if (e.code === 'KeyW') { input.f = 1; input.thrust = 1; }
  else if (keyMap[e.code]) input[keyMap[e.code]] = 1;
  if (e.code === 'KeyC') doScan();
  if (e.code === 'KeyE') doAction();
  if (e.code === 'Space') e.preventDefault();
});
addEventListener('keyup', (e) => {
  if (e.code === 'KeyW') { input.f = 0; input.thrust = 0; }
  else if (keyMap[e.code]) input[keyMap[e.code]] = 0;
});
renderer.domElement.addEventListener('mousedown', (e) => {
  if (state.phase !== 'playing') return;
  if (!pointerLocked) { renderer.domElement.requestPointerLock?.(); return; }
  if (e.button === 0) input.mine = 1;
});
addEventListener('mouseup', (e) => { if (e.button === 0) input.mine = 0; });
document.addEventListener('pointerlockchange', () => { pointerLocked = document.pointerLockElement === renderer.domElement; });
addEventListener('mousemove', (e) => {
  if (!pointerLocked) return;
  input.mouseDX += e.movementX; input.mouseDY += e.movementY;
});

// touch controls
setupTouch();

// ---------------- start / restart ----------------
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('againBtn').addEventListener('click', () => location.reload());

function startGame() {
  if (state.phase === 'playing') return;
  audio.init(); audio.resume();
  state.phase = 'playing';
  hud.hideTitle(); hud.show();
  enterSpace(0, true);
  if (!isTouch) renderer.domElement.requestPointerLock?.();
}

// ---------------- mode transitions ----------------
function enterSpace(targetIdx, initial) {
  state.mode = 'space';
  entry.active = false; entry.freeze = false;
  hud.clearFlash();
  cur = null;
  post.setScene(space.scene, camera);
  space.scene.add(camera);
  const p = space.planets[targetIdx];
  const outward = new THREE.Vector3().subVectors(p.pos, new THREE.Vector3(0, 0, 0)).normalize();
  const start = p.pos.clone().addScaledVector(outward, -p.radius * 4.2).add(new THREE.Vector3(0, p.radius * 0.6, 0));
  ship.position.copy(start);
  const d = new THREE.Vector3().subVectors(p.pos, start).normalize();
  shipCtl.yaw = Math.atan2(-d.x, -d.z);
  shipCtl.pitch = Math.asin(THREE.MathUtils.clamp(d.y, -1, 1));
  shipCtl.roll = 0; shipCtl.curSpeed = initial ? 30 : 60;
  // snap camera behind ship
  ship.quaternion.setFromEuler(new THREE.Euler(shipCtl.pitch, shipCtl.yaw, 0, 'YXZ'));
  placeChaseCam(true);
  hud.mode('DEEP SPACE');
  hud.planet(state.systemName.toUpperCase(), '— interstellar transit —');
  hud.reticle(false);
  audio.setAmbient(0.05);
}

const entry = { active: false, t: 0, dur: 4.0, planet: null, switched: false, freeze: false };
function beginEntry(planetEntry) {
  if (entry.active) return;
  state.mode = 'entry';
  entry.active = true; entry.t = 0; entry.planet = planetEntry; entry.switched = false;
  entry.idx = space.planets.indexOf(planetEntry);
  hud.hint('');
  hud.mode('ATMOSPHERIC ENTRY');
  audio.boost();
}

function enterSurface(idx, fromEntry) {
  const s = getSurface(idx);
  cur = s; state.mode = 'surface'; state.planetIndex = idx;
  entry.active = false; entry.freeze = false;
  hud.clearFlash();
  post.setScene(s.scene, camera);
  // spawn player a few meters from parked ship, facing the vista
  s.fp.setSpawn(0, 0, 0);
  s.fp.applyTo(camera);
  s.scene.add(camera);
  camera.fov = 72; camera.updateProjectionMatrix();
  hud.mode('SURFACE');
  const sp = specs[idx];
  hud.planet(sp.name, sp.biome);
  hud.banner(sp.name, sp.biome);
  hud.reticle(true);
  post.setBloom(0.62, 0.8, 0.85);
  audio.land(); audio.setAmbient(0.14);
  speedlines.set(0);
  post.setChroma(0);
  const wasVisited = state.visited.has(idx);
  state.visited.add(idx);
  // win check: repaired drive + reached a second distinct world
  if (state.goal.repaired && state.visited.size >= 2 && !wasVisited) {
    setTimeout(winGame, 2600);
  }
}

function takeoff() {
  if (state.mode !== 'surface') return;
  audio.boost();
  // next target = a planet we have not landed yet, else next index
  let target = 0;
  for (let i = 0; i < specs.length; i++) { if (!state.visited.has(i)) { target = i; break; } }
  if (state.visited.has(target)) target = (state.planetIndex + 1) % specs.length;
  enterSpace(target, false);
  hud.mode('DEEP SPACE');
  if (state.goal.repaired) hud.banner(specs[target].name, 'NEXT WAYPOINT');
}

function winGame() {
  state.phase = 'win';
  document.exitPointerLock?.();
  audio.win();
  hud.showWin(state.visited.size, state.scans, state.units);
}

// ---------------- actions ----------------
function doAction() {
  if (state.mode === 'surface') {
    if (cur && camera.position.distanceTo(cur.parked.position) < 7) takeoff();
  } else if (state.mode === 'space') {
    const ap = space.getApproach(ship.position);
    if (ap.inRange) beginEntry(ap.planet);
  }
}

function doScan() {
  if (state.mode !== 'surface' || !cur) return;
  audio.scan();
  hud.scanWave();
  const targets = [...cur.flora.userData.specimens, ...cur.minerals.userData.crystals.filter((c) => !c.mined)];
  cur.scanner.trigger(camera.position, targets, (tg) => {
    if (tg.type === 'flora') {
      const key = state.planetIndex + ':' + tg.speciesIdx;
      if (!state.scannedSpecies.has(key)) {
        state.scannedSpecies.add(key); state.scans++;
        state.units += 60; audio.discovery();
        hud.toast('Species Catalogued', tg.name, 60);
        checkRepair(); refreshHud();
        return true;
      }
    } else if (tg.type === 'mineral') {
      if (!state.scannedMinerals.has(tg.name)) {
        state.scannedMinerals.add(tg.name);
        state.units += 20;
        hud.toast('Mineral Logged', tg.name, 20);
        refreshHud();
        return true;
      }
    }
    return false;
  });
}

let mineSoundOn = false;
function updateMining(dt) {
  if (state.mode !== 'surface' || !cur) { if (mineSoundOn) { audio.mineStop(); mineSoundOn = false; } return; }
  const active = !!input.mine;
  const res = cur.miner.update(dt, active, camera, cur.minerals);
  const beaming = cur.miner.beam.visible;
  if (beaming && !mineSoundOn) { audio.mineStart(); mineSoundOn = true; }
  else if (!beaming && mineSoundOn) { audio.mineStop(); mineSoundOn = false; }
  if (res && res.mined) {
    state.resources += res.amount;
    state.units += res.amount * 2;
    audio.shatter();
    hud.toast('Crystallite +' + res.amount, res.target.name, res.amount * 2);
    checkRepair(); refreshHud();
  }
}

function checkRepair() {
  if (!state.goal.repaired && state.scans >= state.goal.scanNeed && state.resources >= state.goal.mineNeed) {
    state.goal.repaired = true;
    audio.discovery();
    hud.toast('◈ PULSE DRIVE', 'REPAIRED — return to ship', 0);
    hud.banner('DRIVE ONLINE', 'return to ship · E to launch');
  }
}

function refreshHud() {
  hud.stats(state.units, state.resources);
  hud.goal(Math.min(state.scans, state.goal.scanNeed), state.goal.scanNeed,
    Math.min(state.resources, state.goal.mineNeed), state.goal.mineNeed);
}

// ---------------- camera ----------------
const _camDesired = new THREE.Vector3();
const _camOffset = new THREE.Vector3(0, 1.6, 9.5);
const _lookAt = new THREE.Vector3();
function placeChaseCam(snap) {
  _camDesired.copy(_camOffset).applyQuaternion(ship.quaternion).add(ship.position);
  if (snap) camera.position.copy(_camDesired);
  else camera.position.lerp(_camDesired, 0.12);
  _lookAt.copy(ship.position).add(new THREE.Vector3(0, 0.5, 0));
  camera.lookAt(_lookAt);
}

// ---------------- main loop ----------------
const clock = new THREE.Clock();
let fpsT = 0, fpsN = 0, fpsAcc = 0;
function loop() {
  requestAnimationFrame(loop);
  const dt = Math.min(clock.getDelta(), 0.05);

  if (state.phase !== 'title') {
    if (state.mode === 'space') updateSpace(dt);
    else if (state.mode === 'entry') updateEntry(dt);
    else if (state.mode === 'surface') updateSurface(dt);
  }
  space.update(dt);

  renderer.info.reset();
  post.render(dt);

  // fps
  fpsAcc += dt; fpsN++;
  if (fpsAcc >= 0.5) {
    const fps = Math.round(fpsN / fpsAcc); hud.fps(fps);
    const ri = renderer.info;
    window.__rinfo = { fps, calls: ri.render.calls, tris: ri.render.triangles, geometries: ri.memory.geometries, textures: ri.memory.textures };
    fpsN = 0; fpsAcc = 0;
  }

  // consume mouse deltas
  input.mouseDX = 0; input.mouseDY = 0;
}

function updateSpace(dt) {
  const speed = shipCtl.update(dt, input);
  placeChaseCam(false);
  audio.setThrust(speed / shipCtl.boostSpeed);
  // boost speed lines + fov
  const boostAmt = shipCtl.boosting ? 1 : Math.max(0, speed / shipCtl.boostSpeed - 0.4);
  speedlines.set(THREE.MathUtils.clamp(boostAmt, 0, 1), speed);
  post.setChroma(shipCtl.boosting ? 0.004 : 0);
  const targetFov = 72 + (shipCtl.boosting ? 12 : 0);
  camera.fov += (targetFov - camera.fov) * 0.1; camera.updateProjectionMatrix();

  const ap = space.getApproach(ship.position);
  if (ap.inRange) {
    hud.hint(`<span class="key">E</span> initiate landing — <b>${ap.planet.spec.name}</b>`);
  } else {
    const d = Math.max(0, (ap.dist - ap.thresh) | 0);
    hud.hint(`Approach a world · nearest <b>${ap.planet.spec.name}</b> · ${d}u`);
  }
}

function updateEntry(dt) {
  if (!entry.freeze) entry.t += dt;
  const p = Math.min(1, entry.t / entry.dur);
  const pl = entry.planet;
  // dive toward the planet
  const toP = new THREE.Vector3().subVectors(pl.pos, ship.position).normalize();
  shipCtl.yaw = Math.atan2(-toP.x, -toP.z);
  shipCtl.pitch = Math.asin(THREE.MathUtils.clamp(toP.y, -1, 1));
  ship.quaternion.setFromEuler(new THREE.Euler(shipCtl.pitch, shipCtl.yaw, 0, 'YXZ'));
  const dive = 90 + p * 520;
  if (!entry.freeze) ship.position.addScaledVector(toP, dive * dt);
  placeChaseCam(false);
  // rumble
  const shake = p * 0.5;
  camera.position.x += (Math.random() - 0.5) * shake;
  camera.position.y += (Math.random() - 0.5) * shake;
  // vfx ramp
  speedlines.set(Math.min(1, p * 1.6), 220);
  post.setChroma(0.002 + p * 0.012);
  post.setBloom(0.7 + p * 0.8);
  audio.setThrust(0.9);
  // sky-color overlay builds
  const sky = pl.spec.palette.skyHorizon;
  const css = `rgb(${sky.r * 255 | 0},${sky.g * 255 | 0},${sky.b * 255 | 0})`;
  if (p > 0.5) hud.setFlash(css, Math.min(0.82, (p - 0.5) * 2.1));
  if (!entry.freeze && p >= 0.9 && !entry.switched) {
    entry.switched = true;
    enterSurface(entry.idx, true);
    hud.flash(css, 0.97); // now fade it out from surface
  }
  if (!entry.freeze && p >= 1) { entry.active = false; }
}

function updateSurface(dt) {
  const fp = cur.fp;
  if (pointerLocked) fp.look(input.mouseDX, input.mouseDY);
  fp.update(dt, { f: input.f || input.thrust, b: input.b, l: input.l, r: input.r, jump: input.jump });
  input.jump = 0;
  fp.applyTo(camera);
  cur.update(dt, camera.position);
  cur.scanner.update(dt);
  cur.bursts.update(dt);
  updateMining(dt);

  // contextual hint
  const nearShip = camera.position.distanceTo(cur.parked.position) < 7;
  if (nearShip) hud.hint(`<span class="key">E</span> ${state.goal.repaired ? 'launch to orbit' : 'enter ship'}`);
  else if (state.goal.repaired) hud.hint(`Drive online · return to your ship`);
  else hud.hint(`◇ <span class="key">C</span> scan species · hold <span class="key">LMB</span> mine crystallite`);
  audio.setThrust(0);
}

// ---------------- speed lines ----------------
function makeSpeedLines() {
  const N = 180;
  const geo = new THREE.CylinderGeometry(0.02, 0.02, 1, 4);
  geo.rotateX(Math.PI / 2); // length along z
  const mat = new THREE.MeshBasicMaterial({ color: 0xbff7ff, transparent: true, opacity: 0, blending: THREE.AdditiveBlending, depthWrite: false, fog: false });
  const mesh = new THREE.InstancedMesh(geo, mat, N);
  mesh.frustumCulled = false;
  const data = [];
  const m = new THREE.Matrix4();
  for (let i = 0; i < N; i++) {
    const ang = Math.random() * Math.PI * 2;
    const rad = 2.5 + Math.random() * 16;
    data.push({ x: Math.cos(ang) * rad, y: Math.sin(ang) * rad, z: -Math.random() * 140 - 4 });
    m.makeTranslation(data[i].x, data[i].y, data[i].z);
    mesh.setMatrixAt(i, m);
  }
  const group = new THREE.Group(); group.add(mesh);
  const q = new THREE.Quaternion(), s = new THREE.Vector3(), t = new THREE.Vector3();
  return {
    group,
    set(intensity, speed = 60) {
      mat.opacity = intensity * 0.9;
      if (intensity <= 0.001) { mesh.visible = false; return; }
      mesh.visible = true;
      const len = 6 + speed * 0.16;
      for (let i = 0; i < N; i++) {
        const d = data[i];
        d.z += (speed * 0.9 + 40) * 0.016;
        if (d.z > 6) d.z -= 150;
        t.set(d.x, d.y, d.z); s.set(1, 1, len);
        m.compose(t, q, s); mesh.setMatrixAt(i, m);
      }
      mesh.instanceMatrix.needsUpdate = true;
    },
  };
}

// ---------------- touch ----------------
function setupTouch() {
  const stick = document.getElementById('stick');
  if (!stick) return;
  const nub = stick.querySelector('.nub');
  let id = null, cx = 0, cy = 0;
  stick.addEventListener('pointerdown', (e) => {
    id = e.pointerId; const r = stick.getBoundingClientRect(); cx = r.left + r.width / 2; cy = r.top + r.height / 2;
    stick.setPointerCapture(id);
  });
  stick.addEventListener('pointermove', (e) => {
    if (e.pointerId !== id) return;
    let dx = (e.clientX - cx) / 60, dy = (e.clientY - cy) / 60;
    const len = Math.hypot(dx, dy); if (len > 1) { dx /= len; dy /= len; }
    nub.style.transform = `translate(${dx * 36}px, ${dy * 36}px)`;
    input.l = dx < -0.3 ? 1 : 0; input.r = dx > 0.3 ? 1 : 0;
    input.f = dy < -0.3 ? 1 : 0; input.b = dy > 0.3 ? 1 : 0;
    input.thrust = dy < -0.3 ? 1 : 0;
  });
  const end = (e) => { if (e.pointerId !== id) return; id = null; nub.style.transform = ''; input.l = input.r = input.f = input.b = input.thrust = 0; };
  stick.addEventListener('pointerup', end); stick.addEventListener('pointercancel', end); stick.addEventListener('lostpointercapture', end);

  const bind = (elId, on, off) => {
    const el = document.getElementById(elId); if (!el) return;
    el.addEventListener('pointerdown', (e) => { e.preventDefault(); on(); el.classList.add('on'); });
    const up = () => { off && off(); el.classList.remove('on'); };
    el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); el.addEventListener('lostpointercapture', up);
  };
  bind('tA', () => doScan());
  bind('tB', () => { input.mine = 1; }, () => { input.mine = 0; });
  bind('tE', () => doAction());
}

// ---------------- resize ----------------
addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  post.resize(window.innerWidth, window.innerHeight);
});

// ---------------- debug hook ----------------
window.__game = {
  get phase() { return state.phase; },
  get mode() { return state.mode; },
  get planet() { return specs[state.planetIndex]?.name; },
  get planetSpec() { return specs[state.planetIndex]; },
  get units() { return Math.round(state.units); },
  get scans() { return state.scans; },
  get resources() { return state.resources; },
  get visited() { return [...state.visited]; },
  get specs() { return specs; },
  get _plates() { return cur ? cur.scanner.plates.length : 0; },
  start: startGame,
  land() {
    if (state.phase === 'title') startGame();
    if (state.mode === 'space') { const ap = space.getApproach(ship.position); beginEntry(ap.planet); }
  },
  takeoff,
  teleportPlanet(i) {
    if (state.phase === 'title') startGame();
    i = ((i % specs.length) + specs.length) % specs.length;
    entry.active = false; state.mode = 'surface';
    enterSurface(i, false);
  },
  // helpers for screenshot vantages
  _grantGoal() { state.scans = 5; state.resources = 40; state.goal.repaired = true; refreshHud(); },
  _scanAll() { if (cur) { doScan(); } },
  _scanShot() { if (cur) { doScan(); cur.scanner.forceReveal(12); } },
  _spaceShot(i = 0) {
    if (state.phase === 'title') startGame();
    enterSpace(i, false);
    const p = space.planets[i];
    const sd = space.sunDir; // toward the sun => sunlit hemisphere faces the camera
    const side = new THREE.Vector3().crossVectors(sd, new THREE.Vector3(0, 1, 0)).normalize();
    const off = sd.clone().multiplyScalar(p.radius * 2.5)
      .addScaledVector(side, p.radius * 1.6)
      .add(new THREE.Vector3(0, p.radius * 0.5, 0));
    ship.position.copy(p.pos).add(off);
    const d = new THREE.Vector3().subVectors(p.pos, ship.position).normalize();
    // bank away from the planet so it frames off to the side instead of hiding behind the hull
    shipCtl.yaw = Math.atan2(-d.x, -d.z) + 0.42;
    shipCtl.pitch = Math.asin(THREE.MathUtils.clamp(d.y, -1, 1)) - 0.12;
    shipCtl.roll = 0.24;
    ship.quaternion.setFromEuler(new THREE.Euler(shipCtl.pitch, shipCtl.yaw, shipCtl.roll, 'YXZ'));
    shipCtl.curSpeed = 0; input.thrust = 0;
    placeChaseCam(true);
  },
  _entry(i = 0, prog = 0.72) {
    if (state.phase === 'title') startGame();
    enterSpace(i, false);
    const p = space.planets[i];
    const sd = space.sunDir;
    ship.position.copy(p.pos).addScaledVector(sd, p.radius * 1.7).add(new THREE.Vector3(0, p.radius * 0.35, 0));
    beginEntry(p);
    entry.freeze = true; entry.t = prog * entry.dur;
  },
  _faceShip() {
    if (!cur) return;
    const s = cur.parked.position;
    // stand on the origin side of the ship so the ship is hero foreground with the field behind it
    const out = new THREE.Vector3(s.x, 0, s.z).normalize();
    const cx = s.x - out.x * 16, cz = s.z - out.z * 16;
    cur.fp.pos.set(cx, cur.heightAt(cx, cz) + cur.fp.eye, cz);
    const d = new THREE.Vector3(s.x - cx, 0, s.z - cz).normalize();
    cur.fp.yaw = Math.atan2(-d.x, -d.z); cur.fp.pitch = -0.07;
    cur.fp.applyTo(camera);
  },
  _view(yaw, pitch) { if (cur) { cur.fp.yaw = yaw; cur.fp.pitch = pitch ?? -0.05; cur.fp.applyTo(camera); } },
};

hud.init();
refreshHud();
loop();

// auto-start for headless screenshots
if (new URLSearchParams(location.search).has('auto')) {
  setTimeout(startGame, 300);
}
