/* Sprite sheets.
 *
 * pack_assets.py did the hard part: every frame of every animation in a set is
 * composited onto one canvas with the character's chest at the same pixel, so
 * nothing here needs to know that the source art was trimmed differently per
 * animation. Drawing is just "rotate about the pivot".
 *
 * All sprites face RIGHT at angle 0, matching Math.atan2 - so a sprite's angle
 * is the same number as its direction of travel, with no offset anywhere.
 * The vehicle art faces UP, and that one exception is corrected in Atlas.car
 * rather than being left for every caller to remember.
 */
const Atlas = {
  sheets: {},
  cars: null,
  ready: false,

  /* Heavy sets are fetched but not waited for. The game is playable with the
   * feet, the handgun and the cars; making someone stare at a progress bar
   * while the shotgun's 78 frames arrive is a worse trade than having the
   * shotgun unavailable for the first two seconds. draw() falls back to the
   * handgun for a set that has not landed yet. */
  ESSENTIAL: ['feet', 'handgun', 'cars'],

  /* The character packs must be waited for even though they are small: the
   * first pedestrians are created during boot, and a pack that has not arrived
   * yet reads as "not installed", so the whole city would be populated with
   * the fallback recolours and then never re-rolled. */
  isEssential(name) {
    return this.ESSENTIAL.indexOf(name) >= 0 || name.indexOf('ped_') === 0;
  },

  load(onProgress) {
    const man = window.ATLAS;
    if (!man) return Promise.reject(new Error(
      'atlas/atlas.js did not load - run pack_assets.py'));

    const names = Object.keys(man.character);
    let done = 0;
    const total = names.length + 1;

    const one = (name, meta, into) => new Promise((res) => {
      const img = new Image();
      img.onload = () => {
        into[name] = Object.assign({}, meta, { img: img });
        onProgress && onProgress(++done, total, name);
        res(true);
      };
      img.onerror = () => {
        console.error('atlas: could not load', meta.file);
        onProgress && onProgress(++done, total, name);
        res(false);
      };
      img.src = 'atlas/' + meta.file;
    });

    // Every request is issued now, in parallel; only the wait is selective.
    const jobs = [];
    const essential = [];
    const track = (name, p) => {
      jobs.push(p);
      if (this.isEssential(name)) essential.push(p);
    };
    names.forEach((n) => track(n, one(n, man.character[n], this.sheets)));
    track('cars', one('cars', man.vehicles, this.sheets).then(() => {
      this.cars = man.vehicles.cars;
    }));

    Promise.all(jobs).then(() => { this.ready = true; });
    return Promise.all(essential);
  },

  /* Recoloured copies of the pedestrian sheets.
   *
   * The art is one character, so a street full of pedestrians is a street full
   * of the same person in the same olive jacket - which is the single thing
   * that most makes a crowd look fake. Tinting the whole sheet once at load
   * costs one canvas per variant and nothing per frame, whereas compositing a
   * tint per sprite per frame would be ~40 extra composite passes every frame.
   *
   * 'source-atop' is what makes this work: it paints only where the sheet is
   * already opaque, so the transparent gaps between frames stay transparent
   * and the frame grid is preserved exactly. */
  TINTS: [
    { key: 'a', col: '#2f4f9e', amt: 0.85 },   // navy - also the police
    { key: 'b', col: '#7a4526', amt: 0.72 },   // brown
    { key: 'c', col: '#8e2f3f', amt: 0.70 },   // maroon
    { key: 'd', col: '#1f6b58', amt: 0.72 },   // teal
    { key: 'e', col: '#6a6470', amt: 0.62 },   // grey
    { key: 'f', col: '#9a7a2a', amt: 0.68 },   // tan
  ],

  /* Baked at HALF the atlas resolution.
   *
   * The obvious alternative - ctx.filter per sprite - was tried and measured,
   * and it is a trap. It benchmarks at 12us a draw, but every filtered draw
   * makes the browser allocate a layer the size of the whole canvas, and with
   * ~160 filtered sprites a frame the allocation churn produced recurring
   * 750ms stalls and one of 7.9 SECONDS, in among perfectly normal 4ms frames.
   * A microbenchmark cannot see that; only running it for a while can.
   *
   * Baking is boring and predictable instead: fixed cost at load, zero per
   * draw. Half resolution is what makes six colourways affordable - ~4.8MB
   * each instead of 19MB - and costs nothing visible, because a pedestrian is
   * already drawn at 0.62 of an atlas that is itself 0.5 of the source art.
   * The sheet records its own `mul` so draw() scales it back up. */
  buildTints(sets, downscale) {
    const k = downscale || 0.5;
    for (const name of sets) {
      const s = this.sheets[name];
      if (!s) continue;
      for (const t of this.TINTS) {
        const c = document.createElement('canvas');
        c.width = Math.max(1, Math.round(s.img.width * k));
        c.height = Math.max(1, Math.round(s.img.height * k));
        const x = c.getContext('2d');
        x.drawImage(s.img, 0, 0, c.width, c.height);

        /* 'color' takes the hue and saturation of the fill and keeps the
         * LUMINOSITY of what is underneath, so the character's shading,
         * highlights and black outline all survive and only the cloth changes
         * colour. Flooding a flat fill through 'source-atop' instead - the
         * first attempt - washes exactly that shading out and turns everyone
         * into a pastel silhouette.
         *
         * 'color' composites like source-over though, so it paints the
         * transparent gaps between frames too; 'destination-in' with the
         * original then clips the sheet back to its own alpha and restores the
         * frame grid. */
        x.globalCompositeOperation = 'color';
        x.globalAlpha = t.amt;
        x.fillStyle = t.col;
        x.fillRect(0, 0, c.width, c.height);
        x.globalAlpha = 1;
        x.globalCompositeOperation = 'destination-in';
        x.drawImage(s.img, 0, 0, c.width, c.height);

        this.sheets[name + '#' + t.key] = Object.assign({}, s, {
          img: c, mul: 1 / k,
          fw: s.fw * k, fh: s.fh * k,
          pivot: [s.pivot[0] * k, s.pivot[1] * k],
        });
      }
    }
  },

  /* Frame count of one animation, so callers can drive a clock without
   * reaching into the manifest. */
  frames(set, anim) {
    const s = this.sheets[set];
    const a = s && s.anims[anim];
    return a ? a[1] : 0;
  },

  has(set, anim) {
    const s = this.sheets[set];
    return !!(s && s.anims[anim]);
  },

  /* True for one-piece character sheets - the Enemy packs - as opposed to the
   * survivor, whose legs and upper body are separate sets. */
  isWhole(set) {
    const s = this.sheets[set];
    return !!(s && s.whole);
  },

  /* Scale that renders this sheet's character at `target` world pixels across.
   *
   * The seven Enemy packs were authored at seven different sizes - the mafioso
   * measures 27px across, the butcher 15 - so drawing them all at one fixed
   * scale puts people of visibly different heights on the same pavement. Each
   * sheet records its own measured body size at extraction time and this turns
   * that into the scale needed. */
  bodyScale(set, target) {
    const s = this.sheets[set];
    if (!s || !s.body) return 1;
    return target / s.body;
  },

  /* Draw frame `i` of `set`/`anim` centred on its pivot at world-space x,y,
   * rotated to `ang`. The caller has already applied the camera transform.
   *
   * There is deliberately no per-draw filter argument. See buildTints for what
   * happened when there was one. */
  draw(ctx, set, anim, i, x, y, ang, scale, alpha) {
    let s = this.sheets[set];
    // A set still in flight, or a weapon with no such animation (the knife has
    // no reload): fall back rather than drawing nothing, so a missing sheet
    // reads as "wrong gun in hand" instead of an invisible player.
    if (!s) s = this.sheets.handgun;
    if (!s) return;
    let range = s.anims[anim];
    if (!range) range = s.anims.idle || s.anims[Object.keys(s.anims)[0]];
    if (!range) return;

    const n = range[1];
    const idx = range[0] + (((i % n) + n) % n);
    const sx = (idx % s.cols) * s.fw;
    const sy = ((idx / s.cols) | 0) * s.fh;
    // Tinted sheets are baked smaller; `mul` scales them back to world size so
    // callers never need to know which sheet they are drawing.
    const sc = scale * (s.mul || 1);

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(ang);
    if (alpha !== undefined && alpha < 1) ctx.globalAlpha = alpha;
    ctx.drawImage(s.img, sx, sy, s.fw, s.fh,
                  -s.pivot[0] * sc, -s.pivot[1] * sc,
                  s.fw * sc, s.fh * sc);
    ctx.restore();
  },

  /* Vehicles. `name` is a key of ATLAS.vehicles.cars; `lightPhase` picks a
   * flashing-light frame for the police car and ambulance and is ignored by
   * everything else.
   *
   * The +90 degrees is the whole reason this is a separate function: the car
   * art points up the canvas while every angle in the game points right. */
  car(ctx, name, x, y, ang, scale, lightPhase) {
    const s = this.sheets.cars;
    if (!s) return;
    const e = this.cars && this.cars[name];
    if (!e) return;

    let idx = e.index;
    if (lightPhase !== undefined && e.lights && e.lights.length) {
      idx = e.lights[lightPhase % e.lights.length];
    }
    const sx = (idx % s.cols) * s.fw;
    const sy = ((idx / s.cols) | 0) * s.fh;
    const half = s.fw * scale / 2;

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(ang + Math.PI / 2);
    ctx.drawImage(s.img, sx, sy, s.fw, s.fh, -half, -half, s.fw * scale, s.fw * scale);
    ctx.restore();
  },

  /* Body length and width in atlas pixels, for collision boxes. */
  carSize(name) {
    const e = this.cars && this.cars[name];
    return e ? { len: e.len, wide: e.wide } : { len: 108, wide: 46 };
  },

  carNames() {
    return this.cars ? Object.keys(this.cars) : [];
  },
};
