/* ---------------------------------------------------------------------------
   Loading screen behaviour.

   Three jobs:
     1. draw the city  - a procedural night skyline flown over on canvas
     2. report progress - bound to the load events the game actually emits
     3. play ambience   - synthesised in the browser, nothing is downloaded
   --------------------------------------------------------------------------- */

'use strict';

const $ = (id) => document.getElementById(id);

/* =========================================================================
   1. The city
   ========================================================================= */

/* Small deterministic PRNG so the skyline is stable across a resize. */
function rng(seed) {
  let s = seed >>> 0;
  return () => {
    s = (s * 1664525 + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const SKY = {
  canvas: $('sky'),
  ctx: null,
  layers: [],
  w: 0,
  h: 0,
  dpr: 1,
  t0: performance.now(),
};

/* Depth layers, far to near. Every layer is darker than the hazy sky behind
   it, which is what makes a silhouette read at all; the far ones are lifted
   towards the sky colour to stand in for atmosphere. `base` is the ground line
   as a fraction of the viewport, so near towers rise right through the frame.
   The difference in scroll rate is what reads as flight. */
const LAYER_SPEC = [
  { depth: 0.00, speed: 4,   minW: 28, maxW: 66,  minH: 0.09, maxH: 0.21, base: 0.76, tone: '#20263a', lit: 0.16, win: 0.30, haze: 0.17 },
  { depth: 0.25, speed: 11,  minW: 36, maxW: 90,  minH: 0.13, maxH: 0.30, base: 0.83, tone: '#171c2b', lit: 0.20, win: 0.42, haze: 0.14 },
  { depth: 0.50, speed: 24,  minW: 50, maxW: 122, minH: 0.19, maxH: 0.42, base: 0.91, tone: '#10141f', lit: 0.24, win: 0.58, haze: 0.10 },
  { depth: 0.75, speed: 50,  minW: 74, maxW: 178, minH: 0.28, maxH: 0.62, base: 1.02, tone: '#090c14', lit: 0.26, win: 0.74, haze: 0.06 },
  { depth: 1.00, speed: 100, minW: 104, maxW: 262, minH: 0.44, maxH: 0.98, base: 1.20, tone: '#04060b', lit: 0.19, win: 0.90, haze: 0.00 },
];

function buildLayer(spec, index, w, h) {
  const cv = document.createElement('canvas');
  cv.width = Math.ceil(w * 2);
  cv.height = h;
  const c = cv.getContext('2d');
  const rand = rng(9173 + index * 7717);

  const base = h * spec.base;
  const towers = [];

  let x = -60;
  while (x < cv.width + 60) {
    const bw = spec.minW + rand() * (spec.maxW - spec.minW);
    const bh = h * (spec.minH + rand() * (spec.maxH - spec.minH));
    const top = base - bh;

    c.fillStyle = spec.tone;
    c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), Math.ceil(h - top));

    /* a hairline of sodium bounce along the roof edge */
    if (rand() < 0.55) {
      c.fillStyle = `rgba(255,159,69,${0.05 + spec.glow * 0.16})`;
      c.fillRect(Math.round(x), Math.round(top), Math.ceil(bw), 1);
    }

    /* windows */
    const cell = 6 + spec.depth * 8;
    const pad = Math.max(2, cell * 0.34);
    for (let wy = top + pad * 2; wy < base - pad; wy += cell) {
      for (let wx = x + pad; wx < x + bw - pad; wx += cell) {
        if (rand() > spec.lit) continue;
        const warm = rand();
        /* mostly sodium/tungsten, a few cold offices; distant windows are
           dimmed so they read as depth rather than as static */
        const col = warm < 0.72
          ? `rgba(255,${150 + Math.floor(rand() * 50)},${70 + Math.floor(rand() * 40)},${(0.35 + rand() * 0.5) * spec.win})`
          : `rgba(190,205,225,${(0.18 + rand() * 0.3) * spec.win})`;
        c.fillStyle = col;
        c.fillRect(Math.round(wx), Math.round(wy), Math.max(1, cell * 0.34), Math.max(1, cell * 0.42));
      }
    }

    /* tall towers get an aviation light */
    if (bh > h * 0.5 && rand() < 0.5) {
      towers.push({ x: x + bw / 2, y: top - 2, phase: rand() * Math.PI * 2 });
    }

    x += bw + 2 + rand() * 16;
  }

  return { cv, spec, towers, base };
}

function resizeSky() {
  const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
  SKY.dpr = dpr;
  SKY.w = window.innerWidth;
  SKY.h = window.innerHeight;
  SKY.canvas.width = Math.floor(SKY.w * dpr);
  SKY.canvas.height = Math.floor(SKY.h * dpr);
  SKY.ctx = SKY.canvas.getContext('2d');
  SKY.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  SKY.layers = LAYER_SPEC.map((s, i) => buildLayer(s, i, SKY.w, SKY.h));
}

function drawSky(now) {
  const { ctx, w, h } = SKY;
  if (!ctx) return;
  const t = (now - SKY.t0) / 1000;

  /* Sky: light pollution rather than stars. It has to stay brighter than the
     buildings all the way down, otherwise there is no silhouette to see. */
  const g = ctx.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0.00, '#080b14');
  g.addColorStop(0.34, '#121627');
  g.addColorStop(0.60, '#23202f');
  g.addColorStop(0.78, '#412c27');
  g.addColorStop(0.90, '#6b4527');
  g.addColorStop(1.00, '#8a5726');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, w, h);

  /* the haze dome over downtown */
  const halo = ctx.createRadialGradient(w * 0.6, h * 0.9, 0, w * 0.6, h * 0.9, h * 0.85);
  halo.addColorStop(0, 'rgba(255,159,69,0.34)');
  halo.addColorStop(0.45, 'rgba(255,140,60,0.12)');
  halo.addColorStop(1, 'rgba(255,140,60,0)');
  ctx.fillStyle = halo;
  ctx.fillRect(0, 0, w, h);

  /* a slow descent: everything drifts down and grows a touch over time */
  const descend = Math.sin(t * 0.06) * 10 + t * 0.9;

  for (const layer of SKY.layers) {
    const { cv, spec } = layer;
    const span = cv.width / 2;
    let ox = -((t * spec.speed) % span);
    const oy = descend * (0.25 + spec.depth * 0.9);

    ctx.globalAlpha = 1;
    ctx.drawImage(cv, ox, oy, cv.width, h);

    /* aviation lights blink on their own phase */
    for (const tw of layer.towers) {
      const blink = Math.sin(t * 1.5 + tw.phase);
      if (blink < 0.72) continue;
      const px = tw.x + ox;
      const py = tw.y + oy - h * 0.06;
      ctx.fillStyle = `rgba(226,72,52,${(blink - 0.72) / 0.28})`;
      ctx.beginPath();
      ctx.arc(px, py, 1.6, 0, Math.PI * 2);
      ctx.fill();
      if (px > span) {
        ctx.beginPath();
        ctx.arc(px - span, py, 1.6, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    /* Aerial perspective: a warm veil laid over everything drawn so far, so
       each nearer layer sits in front of progressively more atmosphere. */
    if (spec.haze > 0) {
      ctx.fillStyle = `rgba(104,72,50,${spec.haze})`;
      ctx.fillRect(0, 0, w, h);
    }
  }
  ctx.globalAlpha = 1;
}

/* =========================================================================
   2. Progress, bound to the game's own load events
   ========================================================================= */

const Progress = {
  shown: 0,
  target: 0,
  step: 'Waiting for the game',

  set(fraction, step) {
    if (typeof fraction === 'number' && isFinite(fraction)) {
      /* never let it walk backwards - it reads as a fault */
      this.target = Math.max(this.target, Math.min(1, Math.max(0, fraction)));
    }
    if (step) this.step = step;
  },

  tick() {
    /* ease towards the real figure so long steps still feel alive */
    this.shown += (this.target - this.shown) * 0.08;
    const pct = Math.min(100, Math.round(this.shown * 100));
    $('bar').style.width = (this.shown * 100).toFixed(2) + '%';
    $('pct').firstChild.nodeValue = String(pct);
    if ($('step').textContent !== this.step) $('step').textContent = this.step;
  },
};

/* Turns the game's internal init-function names into something a player can read. */
const STEP_NAMES = {
  MAP: 'Loading the map',
  BEFORE_MAP_LOADED: 'Preparing the world',
  AFTER_MAP_LOADED: 'Placing the world',
  SESSION_INIT: 'Joining the session',
  INIT_BEFORE_MAP_LOADED: 'Starting up',
  INIT_AFTER_MAP_LOADED: 'Finishing up',
  INIT_SESSION: 'Joining the session',
};

window.addEventListener('message', (event) => {
  const d = event.data || {};
  switch (d.eventName) {
    case 'loadProgress':
      Progress.set(d.loadFraction);
      break;

    case 'startInitFunction':
      Progress.set(null, STEP_NAMES[d.type] || 'Starting up');
      break;

    case 'initFunctionInvoking':
      if (typeof d.idx === 'number' && typeof d.count === 'number' && d.count > 0) {
        Progress.set(null, `${STEP_NAMES[d.type] || 'Starting up'} ${d.idx}/${d.count}`);
      }
      break;

    case 'startDataFileEntries':
      Progress.set(null, `Streaming ${d.count} asset packs`);
      break;

    case 'performMapLoadFunction':
      Progress.set(null, 'Building the map');
      break;

    case 'startWarning':
    case 'onLogLine':
      if (d.message) Progress.set(null, String(d.message).slice(0, 70));
      break;

    /* --- our own messages, sent from rp_ui once the client is running --- */
    case 'rp:rules':
      if (Array.isArray(d.rules) && d.rules.length) {
        Notices.load(d.rules);
      }
      break;

    case 'rp:server':
      if (d.name) document.querySelector('.wordmark').textContent = d.name;
      if (d.serial) $('serial').textContent = d.serial;
      if (d.status) $('hostcount').textContent = d.status;
      break;

    case 'rp:progress':
      Progress.set(d.fraction, d.step);
      break;

    /* the world is ready: dissolve into the live camera behind us */
    case 'rp:handover':
      Progress.set(1, 'Ready');
      document.body.classList.add('handover');
      break;
  }
});

/* =========================================================================
   3. Posted notices
   ========================================================================= */

const Notices = {
  items: [
    { title: 'Stay in character', body: 'Your character does not know what you know. Breaking character in the world is the fastest way to lose the story.' },
    { title: 'Value your life', body: 'Act like the consequences are permanent. A gun in your face changes what you are willing to do.' },
    { title: 'No random deathmatch', body: 'Violence needs a reason the other person can understand. Escalate, do not detonate.' },
    { title: 'Do not power game', body: 'Give people a fair chance to react. Winning is not the point of a scene.' },
    { title: 'Leave the scene alive', body: 'If you die, your character forgets the thirty minutes before it. No revenge from the grave.' },
  ],
  i: 0,
  timer: null,

  load(items) {
    this.items = items;
    this.i = 0;
    this.render();
    this.schedule();
  },

  render() {
    const item = this.items[this.i % this.items.length];
    const title = $('noticeTitle');
    const body = $('noticeBody');

    /* restart the entry animation by reflowing the nodes */
    title.style.animation = 'none';
    body.style.animation = 'none';
    void title.offsetWidth;
    title.style.animation = '';
    body.style.animation = '';

    title.textContent = item.title;
    body.textContent = item.body;
    $('noticeIndex').textContent = `${(this.i % this.items.length) + 1} / ${this.items.length}`;
  },

  schedule() {
    clearInterval(this.timer);
    this.timer = setInterval(() => {
      this.i += 1;
      this.render();
    }, 7600);
  },
};

/* =========================================================================
   4. Ambience - synthesised, never downloaded
   ========================================================================= */

class Ambience {
  constructor() {
    this.ctx = null;
    this.master = null;
    this.volume = 0.45;
    this.muted = false;
    this.started = false;
  }

  /* A short noise burst with exponential decay makes a serviceable hall. */
  makeImpulse(seconds, decay) {
    const rate = this.ctx.sampleRate;
    const len = Math.floor(rate * seconds);
    const buf = this.ctx.createBuffer(2, len, rate);
    for (let ch = 0; ch < 2; ch++) {
      const data = buf.getChannelData(ch);
      for (let i = 0; i < len; i++) {
        data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
      }
    }
    return buf;
  }

  noiseBuffer(seconds) {
    const rate = this.ctx.sampleRate;
    const buf = this.ctx.createBuffer(1, rate * seconds, rate);
    const d = buf.getChannelData(0);
    let last = 0;
    for (let i = 0; i < d.length; i++) {
      /* brown-ish noise: closer to distant traffic than white hiss */
      const white = Math.random() * 2 - 1;
      last = (last + 0.02 * white) / 1.02;
      d[i] = last * 3.2;
    }
    return buf;
  }

  start() {
    if (this.started) return;
    const Ctor = window.AudioContext || window.webkitAudioContext;
    if (!Ctor) return;
    this.ctx = new Ctor();
    this.started = true;

    const ctx = this.ctx;
    this.master = ctx.createGain();
    this.master.gain.value = 0;
    this.master.connect(ctx.destination);

    const verb = ctx.createConvolver();
    verb.buffer = this.makeImpulse(4.2, 2.6);
    const verbGain = ctx.createGain();
    verbGain.gain.value = 0.55;
    verb.connect(verbGain).connect(this.master);

    const dry = ctx.createGain();
    dry.gain.value = 0.75;
    dry.connect(this.master);

    const bus = ctx.createGain();
    bus.connect(dry);
    bus.connect(verb);

    /* --- the pad: a minor triad that never quite resolves --------------- */
    const lp = ctx.createBiquadFilter();
    lp.type = 'lowpass';
    lp.frequency.value = 420;
    lp.Q.value = 0.7;
    lp.connect(bus);

    const lfo = ctx.createOscillator();
    lfo.frequency.value = 0.031;
    const lfoAmt = ctx.createGain();
    lfoAmt.gain.value = 210;
    lfo.connect(lfoAmt).connect(lp.frequency);
    lfo.start();

    [55, 82.41, 110, 130.81, 164.81].forEach((freq, i) => {
      const osc = ctx.createOscillator();
      osc.type = i % 2 ? 'sine' : 'triangle';
      osc.frequency.value = freq;
      osc.detune.value = (i - 2) * 4;

      const g = ctx.createGain();
      g.gain.value = 0.16 / (1 + i * 0.35);

      /* each voice breathes on its own slow cycle */
      const breath = ctx.createOscillator();
      breath.frequency.value = 0.017 + i * 0.009;
      const breathAmt = ctx.createGain();
      breathAmt.gain.value = g.gain.value * 0.6;
      breath.connect(breathAmt).connect(g.gain);
      breath.start();

      osc.connect(g).connect(lp);
      osc.start();
    });

    /* --- distant traffic ------------------------------------------------ */
    const noise = ctx.createBufferSource();
    noise.buffer = this.noiseBuffer(8);
    noise.loop = true;
    const nf = ctx.createBiquadFilter();
    nf.type = 'bandpass';
    nf.frequency.value = 240;
    nf.Q.value = 0.55;
    const ng = ctx.createGain();
    ng.gain.value = 0.09;
    noise.connect(nf).connect(ng).connect(bus);
    noise.start();

    const wind = ctx.createOscillator();
    wind.frequency.value = 0.043;
    const windAmt = ctx.createGain();
    windAmt.gain.value = 130;
    wind.connect(windAmt).connect(nf.frequency);
    wind.start();

    /* --- a siren, far away, every now and then -------------------------- */
    const siren = () => {
      if (!this.ctx || this.ctx.state === 'closed') return;
      const t = ctx.currentTime;
      const o = ctx.createOscillator();
      o.type = 'triangle';
      const g = ctx.createGain();
      const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
      g.gain.value = 0;
      o.frequency.setValueAtTime(620, t);
      o.frequency.linearRampToValueAtTime(880, t + 0.7);
      o.frequency.linearRampToValueAtTime(620, t + 1.4);
      g.gain.linearRampToValueAtTime(0.014, t + 0.6);
      g.gain.linearRampToValueAtTime(0.0, t + 2.6);
      if (pan) {
        pan.pan.value = Math.random() * 1.6 - 0.8;
        o.connect(g).connect(pan).connect(verb);
      } else {
        o.connect(g).connect(verb);
      }
      o.start(t);
      o.stop(t + 2.8);
      setTimeout(siren, 24000 + Math.random() * 40000);
    };
    setTimeout(siren, 12000 + Math.random() * 12000);

    this.applyVolume(1.8);
  }

  /* perceptual curve: a linear fader sounds wrong */
  applyVolume(rampSeconds) {
    if (!this.master) return;
    const target = this.muted ? 0 : Math.pow(this.volume, 2.2) * 0.9;
    const now = this.ctx.currentTime;
    this.master.gain.cancelScheduledValues(now);
    this.master.gain.setValueAtTime(this.master.gain.value, now);
    this.master.gain.linearRampToValueAtTime(target, now + (rampSeconds || 0.12));
  }

  setVolume(v) {
    this.volume = Math.min(1, Math.max(0, v));
    if (this.volume > 0) this.muted = false;
    this.applyVolume();
  }

  toggleMute() {
    this.muted = !this.muted;
    this.applyVolume();
    return this.muted;
  }
}

const ambience = new Ambience();

function wireAudio() {
  const vol = $('vol');
  const volval = $('volval');
  const mute = $('mute');
  const hint = $('audiohint');

  const saved = parseInt(window.localStorage.getItem('rp.volume') || '45', 10);
  vol.value = String(isFinite(saved) ? saved : 45);
  volval.textContent = vol.value;
  ambience.volume = Number(vol.value) / 100;

  const tryStart = () => {
    ambience.start();
    if (ambience.ctx && ambience.ctx.state === 'suspended') {
      hint.hidden = false;
      return false;
    }
    hint.hidden = true;
    return true;
  };

  /* Autoplay may be blocked; the first input of any kind releases it. */
  if (!tryStart()) {
    const release = () => {
      if (ambience.ctx) ambience.ctx.resume();
      hint.hidden = true;
      window.removeEventListener('pointerdown', release);
      window.removeEventListener('keydown', release);
    };
    window.addEventListener('pointerdown', release);
    window.addEventListener('keydown', release);
  }

  vol.addEventListener('input', () => {
    volval.textContent = vol.value;
    ambience.setVolume(Number(vol.value) / 100);
    mute.classList.toggle('off', Number(vol.value) === 0);
    window.localStorage.setItem('rp.volume', vol.value);
  });

  mute.addEventListener('click', () => {
    const muted = ambience.toggleMute();
    mute.classList.toggle('off', muted);
  });
}

/* =========================================================================
   boot
   ========================================================================= */

function serial() {
  const n = Math.floor(Math.random() * 9000 + 1000);
  const m = Math.floor(Math.random() * 900 + 100);
  return `${new Date().getFullYear()}-${n}${m}`;
}

function frame(now) {
  drawSky(now);
  Progress.tick();
  requestAnimationFrame(frame);
}

window.addEventListener('resize', resizeSky);

document.addEventListener('DOMContentLoaded', () => {
  $('serial').textContent = serial();
  resizeSky();
  Notices.render();
  Notices.schedule();
  wireAudio();
  requestAnimationFrame(frame);
});
