/* Everything that moves.
 *
 * The layered character is the reason this file exists in the shape it does.
 * The art ships the legs and the upper body as SEPARATE sets, and that is a
 * feature, not an inconvenience: it is what lets the character walk one way
 * while aiming another, which is the single thing that makes a top-down
 * shooter feel like one rather than like a tank. So the feet get their own
 * angle (direction of travel) and their own clip chosen by how the movement
 * relates to the facing - forward becomes walk/run, sideways becomes
 * strafe_left or strafe_right - while the body gets the aim angle and a clip
 * chosen by what the weapon is doing.
 */

/* SCALE.
 *
 * These two numbers are the whole reason people looked like giants next to the
 * cars. At the old 0.62/0.66 the character's torso came out ~37px across while
 * an Audi was ~32px wide - a pedestrian was literally wider than a car, and
 * with the rifle held out the sprite's bounding box (90px) was bigger than the
 * whole car sprite (84px).
 *
 * Now: torso ~24px against a car 40px wide and 88px long. That is about 60% of
 * the car's width and a bit over a quarter of its length, which is the
 * exaggerated-but-readable proportion top-down games actually use - a true
 * 1:9 human-to-car footprint would render a person as a 9px dot.
 *
 * Everything measured in world pixels below - collision radii, melee reach,
 * bullet hit radius - is derived from these rather than hard-coded, so
 * changing a scale does not silently leave the hitboxes at the old size. */
const CHAR_SCALE = 0.40;
const CAR_SCALE  = 0.82;

// The survivor's torso measures ~60px across in the atlas, so half of that,
// scaled, is the body radius: ~12 world px at CHAR_SCALE 0.40.
/* How long a body stays. Long, deliberately: bodies are the record of what
 * you did to a street, and a corpse that evaporates after 20 seconds means
 * that by the time the police arrive there is nothing to have arrived about. */
const PED_CORPSE_LIFE = 48;

const BODY_R  = 30 * CHAR_SCALE;
const HIT_R   = BODY_R * 1.2;      // bullet vs person, a shade forgiving
const MELEE_R = BODY_R * 2.8;

const WEAPONS = {
  fists:      { set: 'knife',      melee: true,  dmg: 9,  rate: 0.34, reach: MELEE_R * 1.0, name: 'Fists',    ammo: -1 },
  knife:      { set: 'knife',      melee: true,  dmg: 34, rate: 0.30, reach: MELEE_R * 1.2, name: 'Knife',    ammo: -1 },
  handgun:    { set: 'handgun',    dmg: 22, rate: 0.17, spread: 0.030, speed: 1500, mag: 13, clips: 6,  name: 'Pistol',  range: 620 },
  shotgun:    { set: 'shotgun',    dmg: 13, rate: 0.72, spread: 0.150, speed: 1250, mag: 6,  clips: 4,  pellets: 7, name: 'Shotgun', range: 380 },
  rifle:      { set: 'rifle',      dmg: 19, rate: 0.085, spread: 0.055, speed: 1750, mag: 30, clips: 5, auto: true, name: 'Rifle',  range: 780 },
  flashlight: { set: 'flashlight', melee: true, dmg: 12, rate: 0.40, reach: MELEE_R * 1.05, name: 'Torch',   ammo: -1, light: true },
};
const WEAPON_ORDER = ['fists', 'knife', 'handgun', 'shotgun', 'rifle', 'flashlight'];

/* ── the player ─────────────────────────────────────────────────────────── */
function makePlayer(x, y) {
  return {
    x: x, y: y, vx: 0, vy: 0,
    ang: 0,              // where the body aims - follows the mouse
    feetAng: 0,          // where the legs point - follows travel
    r: BODY_R,
    hp: 100, maxHp: 100, armour: 0,
    cash: 500,
    wanted: 0,           // 0..5, real this time
    dead: false, respawnAt: 0,

    weapon: 'handgun',
    owned: { fists: true, handgun: true },
    ammo:  { handgun: { in: 13, spare: 39 } },

    clip: 'idle', clipT: 0, fClip: 'idle', fT: 0,
    fireCd: 0, reloading: 0, meleeT: 0,
    car: null,           // the vehicle being driven, or null
    enterCd: 0,
    sprint: false,
    kills: 0, crimes: 0,
    lastCrimeAt: -999,
    noise: 0,            // decays; police use it to find you
  };
}

const Player = {
  weaponDef(p) { return WEAPONS[p.weapon]; },

  /* Cycle to the next weapon the player actually has. */
  cycle(p, dir) {
    const have = WEAPON_ORDER.filter((w) => p.owned[w]);
    if (!have.length) return;
    let i = have.indexOf(p.weapon);
    i = (i + dir + have.length) % have.length;
    this.select(p, have[i]);
  },

  select(p, w) {
    if (!p.owned[w] || p.weapon === w) return;
    p.weapon = w;
    p.reloading = 0;
    p.clip = 'idle'; p.clipT = 0;
  },

  ammoIn(p) {
    const d = WEAPONS[p.weapon];
    if (d.melee) return -1;
    const a = p.ammo[p.weapon];
    return a ? a.in : 0;
  },
  ammoSpare(p) {
    const a = p.ammo[p.weapon];
    return a ? a.spare : 0;
  },

  give(p, w, clips) {
    p.owned[w] = true;
    const d = WEAPONS[w];
    if (d.melee) return;
    if (!p.ammo[w]) p.ammo[w] = { in: d.mag, spare: 0 };
    p.ammo[w].spare += (clips || 2) * d.mag;
  },

  reload(p) {
    const d = WEAPONS[p.weapon];
    if (d.melee || p.reloading > 0) return;
    const a = p.ammo[p.weapon];
    if (!a || a.spare <= 0 || a.in >= d.mag) return;
    // the rifle and shotgun have a 20-frame reload, the pistol 15; play the
    // whole clip rather than a fixed timer so the animation never gets cut off
    const frames = Atlas.frames(d.set, 'reload') || 15;
    p.reloading = frames / 22;
    p.clip = 'reload'; p.clipT = 0;
  },

  _finishReload(p) {
    const d = WEAPONS[p.weapon];
    const a = p.ammo[p.weapon];
    if (!a) return;
    const need = d.mag - a.in;
    const take = Math.min(need, a.spare);
    a.in += take; a.spare -= take;
  },

  update(p, dt, input, G) {
    if (p.dead) return;
    p.fireCd -= dt;
    p.enterCd -= dt;
    p.noise = Math.max(0, p.noise - dt * 1.2);

    if (p.reloading > 0) {
      p.reloading -= dt;
      if (p.reloading <= 0) { this._finishReload(p); p.clip = 'idle'; p.clipT = 0; }
    }
    if (p.meleeT > 0) p.meleeT -= dt;

    // aim always tracks the cursor, in a car or out of one
    p.ang = Math.atan2(input.aimY - p.y, input.aimX - p.x);

    if (p.car) { this._updateDriving(p, dt, input, G); return; }

    // ── on foot ──
    let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);
    let iy = (input.down ? 1 : 0) - (input.up ? 1 : 0);
    const mag = Math.hypot(ix, iy);
    p.sprint = input.sprint && mag > 0;

    /* Sprint used to do nothing, and the reason was arithmetic rather than
     * input handling. With a fixed accel of 1900 and drag of 12, speed settles
     * where thrust balances drag: 1900/12 = 158 px/s. Both the 168 walk cap
     * and the 268 sprint cap sit ABOVE that, so neither clamp ever engaged and
     * holding Shift changed only the leg animation.
     *
     * Deriving accel from the cap keeps the two honest: equilibrium is now the
     * cap itself, so sprint is genuinely faster and the clamp is a safety net
     * rather than the thing that was supposed to be doing the work. */
    const top = p.sprint ? 268 : 168;
    const drag = 12;
    const accel = top * drag;

    if (mag > 0) {
      ix /= mag; iy /= mag;
      p.vx += ix * accel * dt;
      p.vy += iy * accel * dt;
    }
    p.vx -= p.vx * drag * dt;
    p.vy -= p.vy * drag * dt;

    const sp = Math.hypot(p.vx, p.vy);
    if (sp > top) { p.vx = p.vx / sp * top; p.vy = p.vy / sp * top; }

    p.x += p.vx * dt;
    p.y += p.vy * dt;

    const res = World.resolveCircle(p.x, p.y, p.r);
    p.x = res.x; p.y = res.y;
    if (res.hit) {
      // kill the component going into the wall, keep the one along it
      const dot = p.vx * res.nx + p.vy * res.ny;
      if (dot < 0) { p.vx -= res.nx * dot; p.vy -= res.ny * dot; }
    }

    this._animate(p, dt, sp);
    this._tryFire(p, dt, input, G);
  },

  _animate(p, dt, sp) {
    const d = WEAPONS[p.weapon];
    const moving = sp > 14;

    // legs: point where we are going, and pick the clip from the angle
    // between travel and facing
    if (moving) {
      p.feetAng = Math.atan2(p.vy, p.vx);
      let rel = p.feetAng - p.ang;
      while (rel > Math.PI) rel -= 6.283185;
      while (rel < -Math.PI) rel += 6.283185;
      const a = Math.abs(rel);
      if (a > 2.36) {                       // walking backwards
        p.fClip = 'walk';
        p.feetAng = p.ang;                  // legs stay under the body
        p.fT -= dt * 12;                    // and the cycle runs in reverse
      } else if (a > 0.9) {
        p.fClip = rel > 0 ? 'strafe_right' : 'strafe_left';
        p.feetAng = p.ang;
        p.fT += dt * 14;
      } else {
        p.fClip = p.sprint ? 'run' : 'walk';
        p.fT += dt * (p.sprint ? 20 : 15);
      }
    } else {
      p.fClip = 'idle';
      p.feetAng = p.ang;
      p.fT = 0;
    }

    // body
    if (p.reloading > 0) p.clip = 'reload';
    else if (p.meleeT > 0) p.clip = 'meleeattack';
    else if (moving && !d.melee) p.clip = 'move';
    else if (moving) p.clip = 'move';
    else p.clip = 'idle';
    p.clipT += dt * (p.clip === 'reload' ? 22 : (p.clip === 'meleeattack' ? 26 : 15));
  },

  _tryFire(p, dt, input, G) {
    const d = WEAPONS[p.weapon];
    if (!input.fire) { p.heldFire = false; return; }
    if (p.reloading > 0) return;
    if (p.fireCd > 0) return;
    // semi-auto weapons need the button released between shots
    if (!d.auto && !d.melee && p.heldFire) return;
    p.heldFire = true;

    if (d.melee) {
      p.fireCd = d.rate;
      p.meleeT = 0.28;
      p.clip = 'meleeattack'; p.clipT = 0;
      G.meleeSwing(p, d);
      return;
    }

    const a = p.ammo[p.weapon];
    if (!a || a.in <= 0) { this.reload(p); return; }

    a.in--;
    p.fireCd = d.rate;
    p.clip = 'shoot'; p.clipT = 0;
    p.noise = 1;
    G.shoot(p, d, p.ang, true);
  },

  _updateDriving(p, dt, input, G) {
    const c = p.car;
    Vehicles.drive(c, dt, {
      throttle: (input.up ? 1 : 0) - (input.down ? 1 : 0),
      steer: (input.right ? 1 : 0) - (input.left ? 1 : 0),
      brake: input.sprint,
    }, G);
    p.x = c.x; p.y = c.y;
    p.vx = c.vx; p.vy = c.vy;

    // shooting from the driver's seat, guns only - no knifing at 60mph
    const d = WEAPONS[p.weapon];
    if (input.fire && !d.melee && p.fireCd <= 0) {
      const a = p.ammo[p.weapon];
      if (a && a.in > 0) {
        a.in--; p.fireCd = Math.max(d.rate, 0.16); p.noise = 1;
        G.shoot(p, d, p.ang, true);
      }
    }
  },

  draw(ctx, p) {
    if (p.dead || p.car) return;
    const d = WEAPONS[p.weapon];
    Atlas.draw(ctx, 'feet', p.fClip, Math.floor(p.fT), p.x, p.y, p.feetAng, CHAR_SCALE);
    Atlas.draw(ctx, d.set, p.clip, Math.floor(p.clipT), p.x, p.y, p.ang, CHAR_SCALE);
  },
};

/* ── pedestrians ────────────────────────────────────────────────────────── */
/* The art is one man in one olive jacket, so a crowd of him reads as clones.
 * Variety comes from three axes, none of which needs new artwork:
 *
 *   pose   - the three unarmed-looking weapon sets
 *   colour - one of six baked colourways, or the original
 *   build  - a few percent of scale either way
 *
 * 21 appearances times a continuous build, which is enough that a street stops
 * reading as clones. See Atlas.buildTints for why the colour is baked into
 * sheets rather than applied per draw. */
/* Seven characters out of the Enemy_* sheets, each a different person rather
 * than the same person in a different coat. They are WHOLE-BODY sprites - one
 * image containing legs and all - so they do not use the separate feet layer
 * the survivor needs, and they carry their own death frames.
 *
 * The survivor recolours stay as a fallback for anyone running this without
 * having extracted the enemy packs. */
const PED_CHARS = ['ped_gang', 'ped_mafia', 'ped_colombians', 'ped_prisoner',
                   'ped_soldier', 'ped_pigbutcher'];
const PED_FALLBACK = (function () {
  const out = [];
  for (const pose of ['handgun', 'knife', 'flashlight']) {
    out.push(pose);
    for (const t of ['a', 'b', 'c', 'd', 'e', 'f']) out.push(pose + '#' + t);
  }
  return out;
})();

/* Every pack was drawn at its own size - the mafioso is 27px across, the
 * butcher 15 - so each is scaled to a common body width at draw time. Without
 * this the same street contains people twice each other's height. */
const PED_TARGET_BODY = 25;

function pedSkins() {
  const have = PED_CHARS.filter((n) => Atlas.has(n, 'idle'));
  return have.length ? have : PED_FALLBACK;
}

function makePed(x, y, rnd) {
  const pool = pedSkins();
  const skin = pool[(rnd() * pool.length) | 0];
  const hash = skin.indexOf('#');
  return {
    x: x, y: y, vx: 0, vy: 0, r: BODY_R,
    ang: rnd() * 6.283, feetAng: 0,
    hp: 34, dead: false, deadT: 0,
    // Fixed per body so a corpse always falls and bleeds the same way rather
    // than shimmering as it is redrawn each frame.
    deathTurn: (rnd() - 0.5) * 1.5,
    deathAng: rnd() * 6.283,
    deathFrame: (rnd() * 4) | 0,     // which way this one dies
    idleFrame: (rnd() * 6) | 0,      // held, never cycled - see drawWhole
    gait: rnd() * 6.283,             // so a crowd is not in lockstep
    bloodDx: (rnd() - 0.5) * 10,
    bloodDy: (rnd() - 0.5) * 10,
    set: skin,
    // Whole-body packs draw in one piece; only the survivor recolours need a
    // separate legs layer, and then it must match the jacket colourway or the
    // whole city wears olive trousers under six different coats.
    whole: Atlas.isWhole(skin),
    feetSet: hash > 0 ? 'feet' + skin.slice(hash) : 'feet',
    build: 0.93 + rnd() * 0.15,        // not everyone is the same size

    clip: 'idle', clipT: rnd() * 20, fClip: 'idle', fT: rnd() * 20,
    state: 'wander',                       // wander | flee | watch
    tx: x, ty: y, think: rnd() * 2,
    speed: 52 + rnd() * 26,
    cash: 8 + (rnd() * 90) | 0,
    panic: 0,
  };
}

const Peds = {
  update(pd, dt, G) {
    if (pd.dead) { pd.deadT += dt; return; }
    pd.think -= dt;

    const p = G.player;
    const dist = Math.hypot(p.x - pd.x, p.y - pd.y);

    // fear: a fired shot nearby, a wanted level, or a car bearing down
    if (p.noise > 0.2 && dist < 420) pd.panic = Math.max(pd.panic, 2.6);
    if (p.wanted >= 2 && dist < 300) pd.panic = Math.max(pd.panic, 1.6);
    if (p.car && dist < 190 && Math.hypot(p.vx, p.vy) > 180) pd.panic = Math.max(pd.panic, 2.0);
    pd.panic = Math.max(0, pd.panic - dt);

    if (pd.panic > 0) {
      pd.state = 'flee';
      const a = Math.atan2(pd.y - p.y, pd.x - p.x);
      pd.tx = pd.x + Math.cos(a) * 200;
      pd.ty = pd.y + Math.sin(a) * 200;
    } else if (pd.think <= 0) {
      pd.state = 'wander';
      pd.think = 1.6 + Math.random() * 3.4;
      const spot = World.randomWalk(Math.random, pd.x, pd.y, 60, 260);
      if (spot) { pd.tx = spot.x; pd.ty = spot.y; }
    }

    const dx = pd.tx - pd.x, dy = pd.ty - pd.y;
    const dd = Math.hypot(dx, dy);
    const spd = pd.speed * (pd.panic > 0 ? 2.5 : 1);
    if (dd > 6) {
      pd.vx = dx / dd * spd;
      pd.vy = dy / dd * spd;
      pd.x += pd.vx * dt; pd.y += pd.vy * dt;
      const res = World.resolveCircle(pd.x, pd.y, pd.r);
      pd.x = res.x; pd.y = res.y;
      if (res.hit) pd.think = 0;           // stuck on a wall, pick a new target
      pd.feetAng = Math.atan2(pd.vy, pd.vx);
      pd.ang = pd.feetAng;
      pd.fClip = pd.panic > 0 ? 'run' : 'walk';
      pd.fT += dt * (pd.panic > 0 ? 20 : 14);
      pd.clip = 'move'; pd.clipT += dt * 15;

      /* Stand clear of vehicles. Without this, people walk straight through
       * parked cars - and since a car only injures at speed, a stationary one
       * ends up with a pedestrian standing inside the bonnet, which is the
       * kind of detail that makes a street stop reading as real. */
      for (const c of G.cars) {
        // Cheap reject first, then the capsule. Using the car's bounding
        // circle for the push itself would hold pedestrians ~37px off the
        // flank of a car only 20px wide - a phantom bubble that got much more
        // obvious once the vehicles were scaled up.
        const cdx = pd.x - c.x, cdy = pd.y - c.y;
        const reach = c.len * 0.5 + c.wide * 0.5 + pd.r;
        if (cdx * cdx + cdy * cdy > reach * reach) continue;
        const nd2 = Vehicles.nodes(c, Vehicles._nB);
        for (let i = 0; i < 2; i++) {
          const dx2 = pd.x - nd2[i].x, dy2 = pd.y - nd2[i].y;
          const rr = nd2[i].r + pd.r * 0.6;
          const d2 = dx2 * dx2 + dy2 * dy2;
          if (d2 >= rr * rr || d2 < 1e-6) continue;
          const dd2 = Math.sqrt(d2);
          pd.x = nd2[i].x + dx2 / dd2 * rr;
          pd.y = nd2[i].y + dy2 / dd2 * rr;
        }
        if (Math.abs(c.speed) > 30) pd.panic = Math.max(pd.panic, 1.4);
      }
    } else {
      pd.vx = pd.vy = 0;
      pd.fClip = 'idle'; pd.clip = 'idle';
      pd.clipT += dt * 14; pd.fT += dt * 8;
    }
  },

  /* Death.
   *
   * Seen from directly above, a person lying down has almost the same
   * silhouette as a person standing up - which is why holding a frame, as this
   * did before, left no way to tell a corpse from someone loitering. Top-down
   * games solve it with the ground, not the body: the unmistakable signal is
   * the POOL. So the sequence is
   *
   *   0.00-0.42s  collapse: the body turns as it falls and settles a little
   *               smaller, with the spray thrown on the frame of the kill
   *   0.42s on    still, darkened, with a pool that spreads under it and then
   *               stays for the rest of the body's life
   *
   * The pool is drawn first so the body sits IN it rather than on top of a
   * red disc, and it keeps spreading for a second and a half, which reads as
   * bleeding rather than as a decal that popped into existence.
   */
  draw(ctx, pd) {
    /* Whole-body packs are drawn at their own normalising scale so seven packs
     * authored at seven sizes end up the same height on the street; the
     * survivor recolours keep the layered feet-plus-body path. */
    if (pd.whole) return this.drawWhole(ctx, pd);

    const sc = CHAR_SCALE * (pd.build || 1);

    if (pd.dead) {
      const t = pd.deadT;
      const fade = Math.max(0, Math.min(1, 1 - (t - PED_CORPSE_LIFE + 5) / 5));
      if (fade <= 0) return;

      // pool: spreads over ~1.6s, with a couple of fixed offset blobs so it is
      // not a perfect circle. The offsets come off the ped's own coordinates
      // so a given body always bleeds the same shape.
      const grow = Math.min(1, t / 1.6);
      const R = (9 + 13 * grow) * (pd.build || 1);
      ctx.save();
      ctx.globalAlpha = fade * 0.85;
      ctx.fillStyle = '#5e0d14';
      ctx.beginPath();
      ctx.ellipse(pd.x + pd.bloodDx, pd.y + pd.bloodDy, R * 1.25, R, pd.deathAng, 0, 6.284);
      ctx.fill();
      ctx.beginPath();
      ctx.ellipse(pd.x - pd.bloodDy * 0.8, pd.y + pd.bloodDx * 0.8, R * 0.62, R * 0.5,
                  pd.deathAng, 0, 6.284);
      ctx.fill();
      ctx.globalAlpha = fade * 0.5;
      ctx.fillStyle = '#8f1420';
      ctx.beginPath();
      ctx.ellipse(pd.x + pd.bloodDx, pd.y + pd.bloodDy, R * 0.7, R * 0.55, pd.deathAng, 0, 6.284);
      ctx.fill();
      ctx.restore();

      // body: turns and settles during the collapse, then holds
      const k = Math.min(1, t / 0.42);
      const ease = 1 - (1 - k) * (1 - k);
      const ang = pd.ang + pd.deathTurn * ease;
      const bodyScale = sc * (1 - 0.10 * ease);
      Atlas.draw(ctx, pd.set, 'meleeattack', 14, pd.x, pd.y, ang, bodyScale, fade);

      // darken it, so a corpse reads as a corpse even against dark asphalt
      ctx.save();
      ctx.globalAlpha = fade * 0.32 * ease;
      ctx.fillStyle = '#0a0508';
      ctx.beginPath();
      ctx.ellipse(pd.x, pd.y, 15 * (pd.build || 1), 11 * (pd.build || 1), ang, 0, 6.284);
      ctx.fill();
      ctx.restore();
      return;
    }

    Atlas.draw(ctx, pd.feetSet || 'feet', pd.fClip, Math.floor(pd.fT),
               pd.x, pd.y, pd.feetAng, sc);
    Atlas.draw(ctx, pd.set, pd.clip, Math.floor(pd.clipT), pd.x, pd.y, pd.ang, sc);
  },

  /* One-piece characters from the Enemy packs.
   *
   * These have no walk cycle - the sheets are damage catalogues, so what
   * exists is a handful of standing frames and a lot of ways to die. A body
   * that slides across the pavement without moving its legs is the single most
   * obviously wrong thing in a top-down game, so movement is sold two other
   * ways instead: the sprite rocks a few degrees side to side in time with a
   * per-person gait phase, and bobs very slightly along its own axis. At 25px
   * that reads as walking, and it costs nothing.
   */
  drawWhole(ctx, pd) {
    const sc = Atlas.bodyScale(pd.set, PED_TARGET_BODY) * (pd.build || 1);

    if (pd.dead) {
      const t = pd.deadT;
      const fade = Math.max(0, Math.min(1, 1 - (t - PED_CORPSE_LIFE + 5) / 5));
      if (fade <= 0) return;

      // These packs bring their own blood, so the drawn pool is smaller here -
      // enough to read at a glance without doubling up on the art.
      const grow = Math.min(1, t / 1.6);
      const R = (5 + 8 * grow) * (pd.build || 1);
      ctx.save();
      ctx.globalAlpha = fade * 0.7;
      ctx.fillStyle = '#5e0d14';
      ctx.beginPath();
      ctx.ellipse(pd.x + pd.bloodDx * 0.6, pd.y + pd.bloodDy * 0.6,
                  R * 1.3, R, pd.deathAng, 0, 6.284);
      ctx.fill();
      ctx.restore();

      const k = Math.min(1, t / 0.42);
      const ease = 1 - (1 - k) * (1 - k);
      // Hold one death frame per body rather than cycling: these are four
      // different ways of being dead, not four frames of one animation.
      Atlas.draw(ctx, pd.set, 'dead', pd.deathFrame, pd.x, pd.y,
                 pd.ang + pd.deathTurn * ease, sc, fade);
      return;
    }

    const moving = pd.fClip !== 'idle';
    let ang = pd.ang, ox = 0, oy = 0;
    if (moving) {
      const ph = pd.gait + pd.clipT * (pd.panic > 0 ? 2.6 : 1.7);
      ang += Math.sin(ph) * 0.13;
      const bob = Math.sin(ph * 2) * 0.9;
      ox = -Math.sin(pd.ang) * bob;
      oy = Math.cos(pd.ang) * bob;
    }
    /* A FIXED frame per person, not a cycle.
     *
     * Cycling the idle range is what made characters flash between two
     * completely different poses: the extractor's "alive" frames were anything
     * not bleeding, which for these packs covers standing, crouching, aiming
     * and several sub-variants, and stepping through them nine times a second
     * strobes. The extractor now groups them by actual pixel similarity, but
     * holding one frame per person makes the failure impossible rather than
     * merely unlikely - and costs nothing, because a tight group of frames is
     * by definition almost the same picture. The rock and bob above are what
     * sell the walk. */
    Atlas.draw(ctx, pd.set, 'idle', pd.idleFrame, pd.x + ox, pd.y + oy, ang, sc);
  },
};

/* ── police ─────────────────────────────────────────────────────────────── */
function makeCop(x, y, rnd) {
  const c = makePed(x, y, rnd);
  // Police read as police at a glance: their own pack, one look, never a
  // random build. Telling an officer from a bystander in a crowd instantly is
  // the difference between a chase and a guessing game - and the police pack
  // is cyan, which nothing else in the city is.
  if (Atlas.has('ped_police', 'idle')) {
    c.set = 'ped_police';
    c.whole = true;
  } else {
    c.set = Atlas.has('rifle#a', 'idle') ? 'rifle#a' : 'rifle';
    c.feetSet = Atlas.has('feet#a', 'idle') ? 'feet#a' : 'feet';
    c.whole = false;
  }
  c.build = 1;
  c.hp = 78;
  c.cop = true;
  c.speed = 118;
  c.fireCd = 0.6 + rnd() * 0.8;
  c.cash = 0;
  return c;
}

const Cops = {
  update(c, dt, G) {
    if (c.dead) { c.deadT += dt; return; }
    const p = G.player;
    const dx = p.x - c.x, dy = p.y - c.y;
    const dist = Math.hypot(dx, dy);
    const sees = dist < 620 && World.lineClear(c.x, c.y, p.x, p.y);

    c.ang = Math.atan2(dy, dx);
    c.fireCd -= dt;

    // close in, but stop at a firing distance so they do not pile onto you
    const want = 190;
    let mvx = 0, mvy = 0;
    if (dist > want) { mvx = dx / dist; mvy = dy / dist; }
    else if (dist < want * 0.62) { mvx = -dx / dist; mvy = -dy / dist; }

    const spd = c.speed * (dist > 420 ? 1.35 : 1);
    if (mvx || mvy) {
      c.x += mvx * spd * dt; c.y += mvy * spd * dt;
      const res = World.resolveCircle(c.x, c.y, c.r);
      c.x = res.x; c.y = res.y;
      c.feetAng = Math.atan2(mvy, mvx);
      c.fClip = 'run'; c.fT += dt * 18;
      c.clip = 'move'; c.clipT += dt * 15;
    } else {
      c.fClip = 'idle'; c.clip = 'idle';
      c.fT += dt * 8; c.clipT += dt * 14;
    }

    if (sees && c.fireCd <= 0 && dist < 520) {
      c.fireCd = 0.55 + Math.random() * 0.55;
      c.clip = 'shoot'; c.clipT = 0;
      // deliberately inaccurate: a squad of pinpoint shooters is not a
      // difficulty curve, it is a death sentence
      G.copShoot(c, c.ang + (Math.random() - 0.5) * 0.22);
    }
  },
  draw(ctx, c) { Peds.draw(ctx, c); },
};

/* ── vehicles ───────────────────────────────────────────────────────────── */
const CIVILIAN_CARS = ['Car', 'Audi', 'Black_viper', 'Mini_van', 'taxi', 'Mini_truck', 'truck'];

function makeCar(x, y, name, rnd) {
  const sz = Atlas.carSize(name);
  return {
    x: x, y: y, ang: 0, vx: 0, vy: 0,
    speed: 0,                     // signed, along the facing
    name: name,
    len: sz.len * CAR_SCALE, wide: sz.wide * CAR_SCALE,
    r: Math.max(sz.len, sz.wide) * CAR_SCALE * 0.42,
    hp: 200,
    driver: null,                 // 'player' | ped object | null
    ai: null,
    lightT: 0,
    // handling, varied a little per vehicle so a truck is not a sports car
    top: 0, accel: 0, grip: 0,
  };
}

const Vehicles = {
  /* A car is ~70 long and ~32 wide. One circle big enough to contain that is
   * radius 30, which is nearly as wide as a lane - two cars passing in
   * opposite lanes would collide with 30px of clear road between them, and a
   * car could never get closer than 30px to a wall it is visually touching.
   *
   * So the body is two circles of half-width radius, set at the front and rear
   * axles: a capsule. Tight across the width where it matters for lanes, still
   * covering the full length, and four cheap circle tests for a pair of cars.
   */
  nodes(c, out) {
    const r = Math.max(9, c.wide * 0.5);
    const off = Math.max(0, c.len * 0.5 - r);
    const cx = Math.cos(c.ang) * off, cy = Math.sin(c.ang) * off;
    out[0].x = c.x + cx; out[0].y = c.y + cy; out[0].r = r;
    out[1].x = c.x - cx; out[1].y = c.y - cy; out[1].r = r;
    return out;
  },

  _nA: [{ x: 0, y: 0, r: 0 }, { x: 0, y: 0, r: 0 }],
  _nB: [{ x: 0, y: 0, r: 0 }, { x: 0, y: 0, r: 0 }],

  /* Heavier vehicles win a shunt.
   *
   * This deliberately does NOT come from the sprite's footprint, which was the
   * first attempt: the truck art is 89px wide and the Audi's is 98, so by area
   * the truck came out LIGHTER than the hatchback and got bounced backwards by
   * it. Pixel dimensions describe the drawing, not the vehicle. */
  mass(c) {
    return c._m;
  },

  configure(c) {
    const heavy = c.name === 'truck' || c.name === 'Mini_truck' || c.name === 'Ambulance';
    const fast  = c.name === 'Black_viper' || c.name === 'Audi' || c.name === 'Police';
    c.top   = fast ? 720 : (heavy ? 400 : 540);
    c.accel = fast ? 620 : (heavy ? 300 : 450);
    c.grip  = heavy ? 5.5 : (fast ? 9.5 : 7.5);
    c.turn  = heavy ? 2.0 : (fast ? 3.1 : 2.6);
    c._m    = heavy ? 3.4 : (fast ? 1.0 : 1.3);   // tonnes, near enough
    return c;
  },

  /* The handling model.
   *
   * Velocity is kept as a real 2D vector and only PARTLY snapped to where the
   * car is pointing, at a rate set by `grip`. That one choice is what gives
   * the car weight: turn hard at speed and the momentum keeps going the old
   * way for a moment, which is a slide. A model that just sets
   * vx = cos(ang)*speed can never slide, and reads as a sprite being dragged
   * around rather than a car being driven.
   *
   * Steering is scaled by speed so the wheel does nothing while stopped -
   * without that, a parked car spins on the spot. */
  drive(c, dt, in_, G) {
    const fx = Math.cos(c.ang), fy = Math.sin(c.ang);
    const fwd = c.vx * fx + c.vy * fy;          // speed along the nose
    const latx = -fy, laty = fx;
    const lat = c.vx * latx + c.vy * laty;      // sideways speed

    if (in_.throttle > 0) { c.vx += fx * c.accel * dt; c.vy += fy * c.accel * dt; }
    else if (in_.throttle < 0) { c.vx -= fx * c.accel * 0.62 * dt; c.vy -= fy * c.accel * 0.62 * dt; }

    // handbrake: kills grip so the back steps out, and scrubs speed
    const grip = in_.brake ? c.grip * 0.18 : c.grip;
    if (in_.brake) { c.vx -= c.vx * 1.6 * dt; c.vy -= c.vy * 1.6 * dt; }

    // bleed the sideways component - this IS the grip
    const k = Math.min(1, grip * dt);
    c.vx -= latx * lat * k;
    c.vy -= laty * lat * k;

    // rolling resistance
    c.vx -= c.vx * 0.9 * dt;
    c.vy -= c.vy * 0.9 * dt;

    const sp = Math.hypot(c.vx, c.vy);
    if (sp > c.top) { c.vx = c.vx / sp * c.top; c.vy = c.vy / sp * c.top; }

    // steering authority ramps in with speed and fades at the very top end
    if (in_.steer) {
      const auth = Math.min(1, sp / 130) * (fwd < -6 ? -1 : 1);
      c.ang += in_.steer * c.turn * auth * dt;
    }
    c.speed = fwd;

    c.x += c.vx * dt;
    c.y += c.vy * dt;

    // Walls, tested against both ends of the capsule. The corrections are
    // summed and applied as one translation - the car never rotates from a
    // wall hit, so this behaves as a swept capsule rather than two bodies.
    const nd = this.nodes(c, this._nA);
    let dx = 0, dy = 0, nx = 0, ny = 0, hit = false;
    for (let i = 0; i < 2; i++) {
      const r0 = World.resolveCircle(nd[i].x, nd[i].y, nd[i].r);
      if (!r0.hit) continue;
      dx += r0.x - nd[i].x; dy += r0.y - nd[i].y;
      nx += r0.nx; ny += r0.ny; hit = true;
    }
    const res = { x: c.x + dx, y: c.y + dy, hit: hit,
                  nx: nx / (Math.hypot(nx, ny) || 1), ny: ny / (Math.hypot(nx, ny) || 1) };
    c.x = res.x; c.y = res.y;
    if (res.hit) {
      const into = c.vx * res.nx + c.vy * res.ny;
      if (into < 0) {
        const impact = -into;
        // Reflect ONLY the part heading into the wall, and scrub the part
        // running along it just slightly.
        //
        // Damping the whole velocity vector instead - which is what this did
        // first - compounds at 0.55 per overlapping frame, so eight frames of
        // grazing a corner take 574px/s down to 12. In play that reads as the
        // car hitting an invisible barrier and dying every time it touches
        // anything. Clipping a wall should cost you the crash, not the chase.
        c.vx -= res.nx * into * 1.18;            // >1 gives a little bounce
        c.vy -= res.ny * into * 1.18;
        // Scrub along the wall at a rate PER SECOND, not per frame. A flat
        // per-frame fraction compounds over however long contact lasts, and
        // sliding down a wall lasts ~55 frames - at 12% each that is 0.09% of
        // the original speed, i.e. a full stop from a glancing touch.
        const tx = -res.ny, ty = res.nx;
        const tang = c.vx * tx + c.vy * ty;
        const scrub = Math.min(1, 0.9 * dt);
        c.vx -= tx * tang * scrub;
        c.vy -= ty * tang * scrub;
        if (impact > 190 && G) G.carCrash(c, impact);
      }
    }
    c.lightT += dt;
  },

  /* Vehicle against vehicle.
   *
   * Resolved as one pass over every pair, which is 820 pairs for 41 cars - the
   * centre-distance reject below throws out almost all of them before any real
   * work, so a grid would buy nothing at this count.
   *
   * Separation is split by mass and the impulse is applied along the contact
   * normal with restitution, so a truck barely notices a hatchback and the
   * hatchback gets thrown. Only the normal component is exchanged: the cars
   * keep whatever speed they had along the contact, which is what lets a
   * side-swipe at speed feel like a scrape rather than a wall.
   */
  collidePairs(cars, G) {
    const n = cars.length;
    for (let i = 0; i < n; i++) {
      const a = cars[i];
      if (a.dead) continue;
      const na = this.nodes(a, this._nA);
      const reachA = a.len * 0.5 + a.wide * 0.5;
      for (let j = i + 1; j < n; j++) {
        const b = cars[j];
        if (b.dead) continue;
        const cdx = b.x - a.x, cdy = b.y - a.y;
        const reach = reachA + b.len * 0.5 + b.wide * 0.5;
        if (cdx * cdx + cdy * cdy > reach * reach) continue;   // cheap reject

        const nb = this.nodes(b, this._nB);
        for (let p = 0; p < 2; p++) {
          for (let q = 0; q < 2; q++) {
            const A = na[p], B = nb[q];
            const dx = B.x - A.x, dy = B.y - A.y;
            const rr = A.r + B.r;
            const d2 = dx * dx + dy * dy;
            if (d2 >= rr * rr || d2 < 1e-6) continue;
            const d = Math.sqrt(d2);
            const ux = dx / d, uy = dy / d;

            const ma = this.mass(a), mb = this.mass(b);
            const tot = ma + mb;
            const push = (rr - d);
            a.x -= ux * push * (mb / tot); a.y -= uy * push * (mb / tot);
            b.x += ux * push * (ma / tot); b.y += uy * push * (ma / tot);

            const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
            const vn = rvx * ux + rvy * uy;
            if (vn > 0) continue;                    // already separating
            const imp = -(1 + 0.32) * vn / (1 / ma + 1 / mb);
            a.vx -= imp * ux / ma; a.vy -= imp * uy / ma;
            b.vx += imp * ux / mb; b.vy += imp * uy / mb;

            const impact = -vn;
            if (impact > 150 && G) {
              a.hp -= impact * 0.05; b.hp -= impact * 0.05;
              G.fx.push({ kind: 'spark', x: (A.x + B.x) / 2, y: (A.y + B.y) / 2, t: 0.2 });
              if (a.driver === 'player' || b.driver === 'player') {
                G.cam.shake = Math.min(16, G.cam.shake + impact * 0.035);
                // Ramming a squad car is a crime; bumping a civilian is not,
                // any more than it is in a real car park.
                const other = a.driver === 'player' ? b : a;
                if (other.siren) G.crime(0.35, 'rammed a police car');
              }
            }
            // Re-read a's nodes: it just moved, and the second node pair must
            // be tested against where the body actually is now.
            this.nodes(a, this._nA);
            this.nodes(b, this._nB);
          }
        }
      }
    }
  },

  /* Traffic. Not a pathfinder - it drives down the middle of a road strip and
   * picks a new heading when it runs out of road. At this camera distance that
   * is indistinguishable from real navigation, and it costs almost nothing. */
  updateAI(c, dt, G) {
    if (c.driver === 'player') return;
    const a = c.ai;
    if (!a) return;

    a.t -= dt;
    // look ahead; if the road is about to run out, turn
    const look = 74 + Math.abs(c.speed) * 0.34;
    const ax = c.x + Math.cos(c.ang) * look, ay = c.y + Math.sin(c.ang) * look;
    const blocked = World.solidAt(ax, ay) || !World.isRoad(ax, ay);

    if (blocked || a.t <= 0) {
      const opts = [0, Math.PI / 2, -Math.PI / 2, Math.PI];
      let best = null, bestScore = -1;
      for (const turn of opts) {
        const na = c.ang + turn;
        const px = c.x + Math.cos(na) * look, py = c.y + Math.sin(na) * look;
        if (World.solidAt(px, py) || !World.isRoad(px, py)) continue;
        const score = (turn === 0 ? 3 : 1) * (0.5 + Math.random());
        if (score > bestScore) { bestScore = score; best = na; }
      }
      a.target = best !== null ? best : c.ang + Math.PI;
      a.t = 1.4 + Math.random() * 2.2;
    }

    // ease onto the target heading
    let d = (a.target !== undefined ? a.target : c.ang) - c.ang;
    while (d > Math.PI) d -= 6.283185;
    while (d < -Math.PI) d += 6.283185;
    const steer = Math.max(-1, Math.min(1, d * 2.2));

    // slow for the player so traffic does not simply drive through them
    const p = G.player;
    const near = Math.hypot(p.x - c.x, p.y - c.y) < 110 && !p.car;
    this.drive(c, dt, { throttle: near ? 0 : a.gas, steer: steer, brake: near }, G);
  },

  draw(ctx, c) {
    ctx.save();
    ctx.globalAlpha = 0.30;
    ctx.fillStyle = '#000';
    ctx.beginPath();
    ctx.ellipse(c.x + 5, c.y + 6, c.len * 0.44, c.wide * 0.52, c.ang, 0, 6.284);
    ctx.fill();
    ctx.restore();

    const flashing = (c.name === 'Police' || c.name === 'Ambulance') && c.siren;
    Atlas.car(ctx, c.name, c.x, c.y, c.ang, CAR_SCALE,
              flashing ? Math.floor(c.lightT * 9) : undefined);
  },
};
