/* Quantum Arcade — 3D rendering.
 *
 * Three scenes sharing one renderer, because a phone should allocate exactly
 * one WebGL context no matter how many games it switches between.
 *
 * Performance rules, in priority order, because this has to hold sixty frames
 * on a mid-range phone in someone's hand at a party:
 *   - device pixel ratio capped at 2, dropped to 1.5 on small screens
 *   - low-poly geometry, no shadow maps, no post-processing passes
 *   - additive materials for glow instead of a bloom pass
 *   - particles are one BufferGeometry updated in place, never re-allocated
 *   - the render loop stops entirely when the tab is hidden
 */

import * as THREE from '/vendor/three.module.min.js';

const GREEN = 0x00ff9c;
const MAGENTA = 0xff2e88;
const AMBER = 0xffb000;
const RED = 0xff3355;

const PARTICLES = 220;

export class Arcade3D {
  constructor(canvas) {
    this.canvas = canvas;
    this.renderer = new THREE.WebGLRenderer({
      canvas,
      antialias: false,     // FXAA-free; the aesthetic is crisp anyway
      alpha: true,
      powerPreference: 'high-performance',
    });
    this.renderer.setClearColor(0x000000, 0);

    this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 400);
    this.clock = new THREE.Clock();

    this.scenes = {
      rocket: this.buildRocket(),
      orbital: this.buildOrbital(),
      tower: this.buildTower(),
    };
    this.active = 'rocket';
    this.progress = 0;
    this.crashed = false;
    this.shake = 0;

    this.resize();
    window.addEventListener('resize', () => this.resize());

    // Stop rendering when the page is hidden; a background tab burning GPU on
    // someone's phone is a battery bug, not a feature.
    document.addEventListener('visibilitychange', () => {
      if (document.hidden) this.stop();
      else this.start();
    });
  }

  resize() {
    const w = this.canvas.clientWidth;
    const h = this.canvas.clientHeight;
    if (!w || !h) return;
    const cap = w < 500 ? 1.5 : 2;
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, cap));
    this.renderer.setSize(w, h, false);
    this.camera.aspect = w / h;
    this.camera.updateProjectionMatrix();
  }

  /* ---------- shared pieces ---------- */

  // A wireframe floor grid that scrolls toward the camera, which is what sells
  // the sensation of speed.
  makeGrid() {
    const grid = new THREE.GridHelper(120, 40, GREEN, 0x0a7a52);
    grid.material.transparent = true;
    grid.material.opacity = 0.28;
    grid.position.y = -6;
    return grid;
  }

  // One BufferGeometry of points, recycled every frame.
  makeParticles(spread = 40) {
    const positions = new Float32Array(PARTICLES * 3);
    const speeds = new Float32Array(PARTICLES);
    for (let i = 0; i < PARTICLES; i++) {
      positions[i * 3] = (Math.random() - 0.5) * spread;
      positions[i * 3 + 1] = (Math.random() - 0.5) * spread;
      positions[i * 3 + 2] = (Math.random() - 0.5) * spread;
      speeds[i] = 0.4 + Math.random() * 1.6;
    }
    const geo = new THREE.BufferGeometry();
    geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    const mat = new THREE.PointsMaterial({
      color: GREEN, size: 0.22, transparent: true, opacity: 0.75,
      blending: THREE.AdditiveBlending, depthWrite: false,
    });
    const points = new THREE.Points(geo, mat);
    points.userData.speeds = speeds;
    points.userData.spread = spread;
    return points;
  }

  glowLine(geometry, color, opacity = 1) {
    return new THREE.LineSegments(
      new THREE.EdgesGeometry(geometry),
      new THREE.LineBasicMaterial({
        color, transparent: true, opacity,
        blending: THREE.AdditiveBlending, depthWrite: false,
      }));
  }

  /* ---------- rocket: a climb against gravity ---------- */

  buildRocket() {
    const scene = new THREE.Scene();
    scene.fog = new THREE.FogExp2(0x000000, 0.012);

    const grid = this.makeGrid();
    scene.add(grid);

    const particles = this.makeParticles(50);
    scene.add(particles);

    // Low-poly craft: a cone body with a wireframe overlay so it reads as
    // "instrument" rather than "toy".
    const body = new THREE.Mesh(
      new THREE.ConeGeometry(0.7, 2.4, 6),
      new THREE.MeshBasicMaterial({ color: 0x00291c }));
    const wire = this.glowLine(new THREE.ConeGeometry(0.7, 2.4, 6), GREEN);
    const craft = new THREE.Group();
    craft.add(body, wire);
    scene.add(craft);

    // Exhaust: a stretched cone that grows with thrust.
    const plume = new THREE.Mesh(
      new THREE.ConeGeometry(0.45, 2.2, 6),
      new THREE.MeshBasicMaterial({
        color: GREEN, transparent: true, opacity: 0.6,
        blending: THREE.AdditiveBlending, depthWrite: false,
      }));
    plume.rotation.x = Math.PI;
    plume.position.y = -1.9;
    craft.add(plume);

    // The planet you are leaving, which shrinks as you climb.
    const planet = this.glowLine(new THREE.IcosahedronGeometry(9, 1), 0x0a7a52, 0.5);
    planet.position.y = -18;
    scene.add(planet);

    return { scene, grid, particles, craft, plume, planet, wire };
  }

  updateRocket(s, dt, t) {
    const p = this.progress;

    s.craft.position.y = -2 + p * 9;
    s.craft.rotation.y += dt * 0.6;
    // Increasing strain: the craft trembles harder the higher it goes.
    s.craft.position.x = Math.sin(t * 9) * p * 0.28;
    s.craft.rotation.z = Math.sin(t * 7) * p * 0.14;

    const thrust = 0.6 + p * 2.6;
    s.plume.scale.set(1 + p * 0.5, thrust, 1 + p * 0.5);
    s.plume.position.y = -1.2 - thrust * 0.55;
    s.plume.material.opacity = 0.45 + Math.random() * 0.35;

    s.planet.position.y = -18 - p * 26;
    s.planet.rotation.y += dt * 0.1;

    s.grid.position.z = (s.grid.position.z + dt * (6 + p * 60)) % 3;
    s.grid.position.y = -6 - p * 4;

    const colour = this.crashed ? RED : GREEN;
    s.wire.material.color.setHex(colour);
    s.plume.material.color.setHex(colour);

    this.camera.position.set(0, s.craft.position.y + 1.5, 11 - p * 2);
    this.camera.lookAt(0, s.craft.position.y, 0);
  }

  /* ---------- orbital: a decaying orbit ---------- */

  buildOrbital() {
    const scene = new THREE.Scene();
    scene.fog = new THREE.FogExp2(0x000000, 0.02);

    const particles = this.makeParticles(44);
    scene.add(particles);

    const planet = this.glowLine(new THREE.IcosahedronGeometry(4, 1), GREEN, 0.85);
    scene.add(planet);

    const core = new THREE.Mesh(
      new THREE.IcosahedronGeometry(3.85, 1),
      new THREE.MeshBasicMaterial({ color: 0x00160f }));
    scene.add(core);

    // The orbit ring the craft is riding, which tightens as it decays.
    const ring = new THREE.Mesh(
      new THREE.TorusGeometry(9, 0.035, 6, 90),
      new THREE.MeshBasicMaterial({
        color: GREEN, transparent: true, opacity: 0.5,
        blending: THREE.AdditiveBlending, depthWrite: false,
      }));
    ring.rotation.x = Math.PI / 2.35;
    scene.add(ring);

    const craft = new THREE.Mesh(
      new THREE.OctahedronGeometry(0.42),
      new THREE.MeshBasicMaterial({ color: MAGENTA }));
    scene.add(craft);

    // A fading trail behind the craft.
    const trailPos = new Float32Array(60 * 3);
    const trailGeo = new THREE.BufferGeometry();
    trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
    const trail = new THREE.Line(trailGeo, new THREE.LineBasicMaterial({
      color: MAGENTA, transparent: true, opacity: 0.55,
      blending: THREE.AdditiveBlending, depthWrite: false,
    }));
    scene.add(trail);

    return { scene, planet, core, ring, craft, trail, particles, trailIdx: 0 };
  }

  updateOrbital(s, dt, t) {
    const p = this.progress;
    const radius = 9 - p * 4.6;   // the orbit decays inward
    const angle = t * (1.1 + p * 5.5);

    s.craft.position.set(
      Math.cos(angle) * radius,
      Math.sin(angle * 0.55) * 1.2,
      Math.sin(angle) * radius);

    const pos = s.trail.geometry.attributes.position.array;
    // Shift the trail back one vertex and write the head.
    pos.copyWithin(3, 0, pos.length - 3);
    pos[0] = s.craft.position.x;
    pos[1] = s.craft.position.y;
    pos[2] = s.craft.position.z;
    s.trail.geometry.attributes.position.needsUpdate = true;

    s.ring.scale.setScalar(radius / 9);
    s.planet.rotation.y += dt * 0.25;
    s.planet.rotation.x += dt * 0.08;

    const colour = this.crashed ? RED : MAGENTA;
    s.craft.material.color.setHex(colour);
    s.trail.material.color.setHex(colour);
    s.planet.material.color.setHex(this.crashed ? RED : GREEN);

    this.camera.position.set(0, 8 + p * 3, 20 - p * 5);
    this.camera.lookAt(0, 0, 0);
  }

  /* ---------- tower: a stack that wobbles ---------- */

  buildTower() {
    const scene = new THREE.Scene();
    scene.fog = new THREE.FogExp2(0x000000, 0.014);

    const grid = this.makeGrid();
    scene.add(grid);

    const particles = this.makeParticles(46);
    scene.add(particles);

    // Pre-allocate the maximum stack; visibility is toggled per frame rather
    // than creating and destroying meshes mid-round.
    const MAX = 26;
    const blocks = [];
    const geo = new THREE.BoxGeometry(2.4, 0.7, 2.4);
    for (let i = 0; i < MAX; i++) {
      const group = new THREE.Group();
      group.add(new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x00170f })));
      group.add(this.glowLine(geo, GREEN, 0.9));
      group.visible = false;
      scene.add(group);
      blocks.push(group);
    }

    return { scene, grid, particles, blocks, max: MAX };
  }

  updateTower(s, dt, t) {
    const p = this.progress;
    const count = Math.max(1, Math.floor(p * s.max));

    for (let i = 0; i < s.max; i++) {
      const b = s.blocks[i];
      b.visible = i < count;
      if (!b.visible) continue;

      const h = i / s.max;
      // Sway grows with height, and violently once it collapses.
      const amp = h * h * (this.crashed ? 3.4 : 0.9);
      b.position.set(
        Math.sin(t * 2.2 + i * 0.45) * amp,
        -5 + i * 0.74,
        Math.cos(t * 1.8 + i * 0.35) * amp * 0.6);
      b.rotation.y = t * 0.15 + i * 0.08;
      b.rotation.z = Math.sin(t * 2 + i * 0.4) * amp * 0.05;

      const top = i === count - 1;
      const colour = this.crashed ? RED : (top ? AMBER : GREEN);
      b.children[1].material.color.setHex(colour);
      b.children[1].material.opacity = top ? 1 : 0.45 + h * 0.4;
    }

    s.grid.position.z = (s.grid.position.z + dt * 4) % 3;

    const height = -5 + count * 0.74;
    this.camera.position.set(0, height * 0.55 + 3, 15 - p * 2);
    this.camera.lookAt(0, height * 0.5, 0);
  }

  /* ---------- particles ---------- */

  updateParticles(points, dt) {
    const pos = points.geometry.attributes.position.array;
    const speeds = points.userData.speeds;
    const spread = points.userData.spread;
    const half = spread / 2;
    const rush = 2 + this.progress * 34;

    for (let i = 0; i < PARTICLES; i++) {
      pos[i * 3 + 2] += speeds[i] * rush * dt;
      if (pos[i * 3 + 2] > half) {
        pos[i * 3 + 2] = -half;
        pos[i * 3] = (Math.random() - 0.5) * spread;
        pos[i * 3 + 1] = (Math.random() - 0.5) * spread;
      }
    }
    points.geometry.attributes.position.needsUpdate = true;
    points.material.color.setHex(this.crashed ? RED : GREEN);
  }

  /* ---------- public API ---------- */

  setGame(game) {
    if (this.scenes[game]) this.active = game;
  }

  // progress is 0..1 across the multiplier's useful range; crashed swaps the
  // palette and unleashes the wobble.
  setState(progress, crashed) {
    this.progress = Math.max(0, Math.min(1, progress));
    if (crashed && !this.crashed) this.shake = 1; // kick the camera once
    this.crashed = crashed;
  }

  start() {
    if (this.raf) return;
    this.clock.getDelta(); // discard time spent hidden
    const loop = () => {
      this.raf = requestAnimationFrame(loop);
      this.frame();
    };
    this.raf = requestAnimationFrame(loop);
  }

  stop() {
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = null;
  }

  frame() {
    const dt = Math.min(this.clock.getDelta(), 0.05); // clamp after a stall
    const t = this.clock.elapsedTime;
    const s = this.scenes[this.active];

    if (this.active === 'rocket') this.updateRocket(s, dt, t);
    else if (this.active === 'orbital') this.updateOrbital(s, dt, t);
    else this.updateTower(s, dt, t);

    this.updateParticles(s.particles, dt);

    // Camera shake on the crash, decaying fast.
    if (this.shake > 0.001) {
      this.camera.position.x += (Math.random() - 0.5) * this.shake * 1.6;
      this.camera.position.y += (Math.random() - 0.5) * this.shake * 1.6;
      this.shake *= 0.86;
    }

    this.renderer.render(s.scene, this.camera);
  }
}
