Back to Stick Brawl
SOURCE / PINNED RELEASE

Made of little things.

Stick Brawl

Release
732bb70ea856…
Author-recorded commit
918970bb749a…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-554b329db93

Archive hash verified: 67f16c1148706bbd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/game.ts
import {
  blendPose,
  drawFigure,
  blockPose,
  dropKickPose,
  figureJoints,
  holsterPose,
  shootPose,
  slidePose,
  swordPose,
  hurtPose,
  idlePose,
  jumpPose,
  kickPose,
  knockedOutPose,
  punchPose,
  runPose,
  tuckPose,
  type Point,
  type Pose,
} from './figure.js';
import { drawHolster, drawRevolverInHand, drawSwordInHand, drawSwordOnBack, muzzlePoint, swordTip } from './gear.js';
import {
  GROUND_Y,
  ROOF_Y,
  ROOM_W,
  WORLD_LEFT,
  WORLD_RIGHT,
  buildBuilding,
  drawBuilding,
  drawSky,
  firstStep,
  resetDoors,
  surfaceAt,
  type Building,
  type Solid,
  type SpawnPoint,
  type Stairs,
  type Surface,
} from './building.js';

/** The camera shows 480×270 units of the building; it is scaled to fit the window. */
export const VIEW_W = 480;
export const VIEW_H = 270;

/** Half the width of a stickman's body, and his height, for bumping into things. */
const BODY_HALF = 5;
const BODY_H = 36;
/** How fast stickmen climb the stairs. */
const CLIMB_SPEED = 150;
/** How close to the end of the stairs you need to be to use them. */
const STAIRS_REACH = 14;

const GRAVITY = 1500;
const JUMP_SPEED = 500;
const PLAYER_SPEED = 150;
const ROLL_SPEED = 250;
const ROLL_TIME = 0.42;
/** Hold ↓ longer than this to slide instead of rolling. */
const SLIDE_HOLD = 0.14;
/** A slide starts a bit faster than you were running, then slows down quickly. */
const SLIDE_BOOST = 1.35;
const SLIDE_SLOWDOWN = 110;
const SLIDE_DRAG = 1.4;
/** Sliding keeps you so low that punches go over your head. */
const SLIDE_HEIGHT = 16;

const PLAYER_HP = 100;
const ENEMY_HP = 2;
const PLAYER_COLOR = '#3ddc5a';
const SPAWN_GAP = 2.6;
const DEFEAT_PAUSE = 3;
const STEP = 1 / 120;
const MAX_STAINS = 500;

type Action = 'none' | 'punch' | 'kick' | 'dropkick' | 'roll' | 'shoot' | 'sword' | 'climb' | 'hurt' | 'dead';

/** How each attack works. Heights are measured up from the feet. */
interface AttackKind {
  length: number;
  activeFrom: number;
  activeTo: number;
  reach: number;
  top: number;
  bottom: number;
  knockback: number;
  /** How many hits this counts as (1 if not given). */
  damage?: number;
}

const PLAYER_PUNCH: AttackKind = { length: 0.28, activeFrom: 0.07, activeTo: 0.16, reach: 26, top: 36, bottom: 18, knockback: 140 };
const PLAYER_KICK: AttackKind = { length: 0.42, activeFrom: 0.1, activeTo: 0.26, reach: 32, top: 30, bottom: 4, knockback: 950 };
// A drop-kick: flying feet-first through the air. It hits everything in the way until you land.
const DROP_KICK: AttackKind = { length: 3, activeFrom: 0.04, activeTo: 3, reach: 26, top: 22, bottom: -6, knockback: 1300 };
const DROP_KICK_SPEED = 300;
// Enemies pull their fist back for a moment first, so you can see it coming.
// Brutes are big and swing much faster, with a longer reach.
const BRUTE_PUNCH: AttackKind = { length: 0.3, activeFrom: 0.14, activeTo: 0.22, reach: 30, top: 48, bottom: 22, knockback: 170 };
// A revolver bullet counts as one hit.
const GUNSHOT: AttackKind = { length: 0, activeFrom: 0, activeTo: 0, reach: 0, top: 0, bottom: 0, knockback: 120 };
const BRUTE_HP = 3;
const BRUTE_SIZE = 1.35;
const BRUTE_CHANCE = 0.25;
// Runts are small, go down after 1½ hits, punch for ½ a hit, and keep coming every 1.5 seconds.
const RUNT_SIZE = 0.7;
const RUNT_HP = 1.5;
const RUNT_SPAWN_GAP = 1.5;
// Every 2 minutes a big gang of 10 normal stickmen turns up together.
const GANG_SIZE = 10;
const GANG_SPAWN_GAP = 120;
const RUNT_PUNCH: AttackKind = { length: 0.45, activeFrom: 0.24, activeTo: 0.34, reach: 19, top: 26, bottom: 12, knockback: 60, damage: 0.5 };
// The revolver: hand to the hip, pull it out and aim, fire, then put it back.
const SHOT_TIME = 0.42;
const SHOT_FIRE_AT = 0.12;
const SHOT_PUT_AWAY_AT = 0.3;
// The sword: grab it off the back, raise it, slash, then put it back.
const SWORD: AttackKind = { length: 0.55, activeFrom: 0.2, activeTo: 0.34, reach: 40, top: 54, bottom: 0, knockback: 260, damage: 1.5 };
const BULLET_SPEED = 1100;
/** The revolver can fire anywhere in this spread (degrees) in front of the player. */
const SHOT_SPREAD = 25;
const ENEMY_PUNCH: AttackKind = { length: 0.5, activeFrom: 0.28, activeTo: 0.38, reach: 24, top: 36, bottom: 18, knockback: 90 };

class Fighter {
  vx = 0;
  vy = 0;
  onGround = true;
  facing = 1;
  hp: number;
  action: Action = 'none';
  attack: AttackKind | null = null;
  actionTime = 0;
  actionLength = 0;
  /** Who this attack has already hit, so one swing only hits each stickman once. */
  hitAlready = new Set<Fighter>();
  pose: Pose = idlePose(0);
  runPhase = 0;
  flash = 0;
  deadTime = 0;
  /** On the stairs: which flight, which way, and how far along (0 = bottom, 1 = top). */
  climb: { stairs: Stairs; up: boolean; along: number } | null = null;
  /** Knocked out on the stairs: stays put instead of falling through the floor. */
  stuck = false;
  /** Enemies only: */
  cooldown = 0.8;
  /** Player only: how fast the current attack plays (mashing Space speeds it up). */
  attackSpeed = 1;
  blocking = false;
  /** Player only: sliding along the floor (this is separate from actions, so he can attack while sliding). */
  slide: { dir: number; speed: number } | null = null;
  speed = 100;
  range = 20;
  /** Brutes are drawn bigger and have bigger hit boxes. */
  size = 1;
  punch: AttackKind = ENEMY_PUNCH;
  /** Player only: has the revolver fired yet during this draw? */
  fired = false;
  /** Player only: recent sword tip positions, for the swoosh. */
  trail: Point[] = [];

  constructor(
    public x: number,
    public y: number,
    public color: string,
    public isPlayer: boolean,
  ) {
    this.hp = isPlayer ? PLAYER_HP : ENEMY_HP;
  }

  start(action: Action, length: number, attack: AttackKind | null = null): void {
    this.action = action;
    this.actionTime = 0;
    this.actionLength = length;
    this.attack = attack;
    this.attackSpeed = 1;
    this.hitAlready.clear();
  }

  get alive(): boolean {
    return this.action !== 'dead';
  }
}

interface Drop {
  x: number;
  y: number;
  vx: number;
  vy: number;
  size: number;
  color: string;
  /** Some drops splat onto the back wall in mid-air. */
  wallAt: number | null;
  age: number;
}

interface Bullet {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
}

interface Popup {
  x: number;
  y: number;
  text: string;
  life: number;
}

interface Spark {
  x: number;
  y: number;
  vx: number;
  vy: number;
  life: number;
  /** Dust kicked up by sliding, instead of a bright spark. */
  dust?: boolean;
}

interface Splinter {
  x: number;
  y: number;
  vx: number;
  vy: number;
  angle: number;
  spin: number;
  life: number;
}

interface Stain {
  x: number;
  y: number;
  rx: number;
  ry: number;
  color: string;
}

export interface HeldKeys {
  left: boolean;
  right: boolean;
  down: boolean;
  block: boolean;
}

export class Game {
  held: HeldKeys = { left: false, right: false, down: false, block: false };
  paused = true;

  private ctx: CanvasRenderingContext2D;
  private scale = 1;
  private building: Building = buildBuilding();
  private camera = { x: 0, y: 0 };
  private lastPlayerSurface: Surface | null = null;
  /** When ↓ was pressed, while we wait to see if it's a tap (roll) or a hold (slide). */
  private downPressedAt: number | null = null;
  /** How fast (and which way) the player was going when ↓ was pressed. */
  private downPressedVx = 0;
  private player!: Fighter;
  private enemies: Fighter[] = [];
  private drops: Drop[] = [];
  private stains: Stain[] = [];
  private sparks: Spark[] = [];
  private bullets: Bullet[] = [];
  private popups: Popup[] = [];
  private splinters: Splinter[] = [];
  private presses: ('jump' | 'attack' | 'kick' | 'roll' | 'shoot' | 'sword')[] = [];
  private lastAttackPress = -10;
  private spawnTimer = 1;
  private runtTimer = 2;
  private gangTimer = GANG_SPAWN_GAP;
  private knockouts = 0;
  private headshots = 0;
  private defeatTimer = 0;
  private time = 0;
  private lastFrame = 0;
  private leftover = 0;

  constructor(private canvas: HTMLCanvasElement) {
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('Canvas 2D is not available');
    this.ctx = ctx;
    this.restart();
  }

  /** Start a fresh fight. */
  restart(): void {
    this.player = new Fighter(ROOM_W / 2, GROUND_Y, PLAYER_COLOR, true);
    resetDoors(this.building);
    this.lastPlayerSurface = null;
    this.downPressedAt = null;
    this.snapCamera();
    this.enemies = [];
    this.drops = [];
    this.stains = [];
    this.sparks = [];
    this.bullets = [];
    this.popups = [];
    this.splinters = [];
    this.presses = [];
    this.knockouts = 0;
    this.headshots = 0;
    this.spawnTimer = 1;
    this.runtTimer = 2;
    this.gangTimer = GANG_SPAWN_GAP;
    this.defeatTimer = 0;
  }

  /** A key was pressed this moment (not held). */
  press(what: 'jump' | 'attack' | 'kick' | 'roll' | 'shoot' | 'sword'): void {
    if (!this.paused) this.presses.push(what);
  }

  resize(cssWidth: number, cssHeight: number): void {
    const ratio = window.devicePixelRatio || 1;
    this.canvas.width = Math.max(1, Math.round(cssWidth * ratio));
    this.canvas.height = Math.max(1, Math.round(cssHeight * ratio));
    this.scale = this.canvas.width / VIEW_W;
    this.draw();
  }

  start(): void {
    const frame = (now: number) => {
      const dt = this.lastFrame ? Math.min((now - this.lastFrame) / 1000, 0.1) : 0;
      this.lastFrame = now;
      if (!this.paused) {
        this.leftover += dt;
        while (this.leftover >= STEP) {
          this.update(STEP);
          this.leftover -= STEP;
        }
      }
      this.draw();
      requestAnimationFrame(frame);
    };
    requestAnimationFrame(frame);
  }

  // ---------- game rules ----------

  private update(dt: number): void {
    this.time += dt;
    if (this.defeatTimer > 0) {
      this.defeatTimer -= dt;
      if (this.defeatTimer <= 0) this.restart();
    } else {
      this.controlPlayer();
      this.spawnEnemies(dt);
    }
    this.presses = [];
    for (const enemy of this.enemies) this.thinkEnemy(enemy, dt);
    for (const fighter of [this.player, ...this.enemies]) this.moveFighter(fighter, dt);
    this.enemies = this.enemies.filter((enemy) => !(enemy.action === 'dead' && enemy.deadTime > 2.5));
    this.updateBullets(dt);
    this.updateDrops(dt);
  }

  private controlPlayer(): void {
    const p = this.player;
    if (!p.alive) return;
    for (const what of this.presses) {
      // Once an attack has landed, another press can start the next one straight away.
      const canChain =
        p.action === 'none' ||
        ((p.action === 'punch' || p.action === 'kick') && p.attack !== null && p.actionTime > p.attack.activeTo);
      if (what === 'attack') {
        // The faster you press, the faster you punch (up to a bit over twice as fast).
        const gap = this.time - this.lastAttackPress;
        const speed = Math.max(1, Math.min(2.2, 0.3 / gap));
        if (canChain) {
          p.start('punch', PLAYER_PUNCH.length, PLAYER_PUNCH);
          p.attackSpeed = speed;
        }
        this.lastAttackPress = this.time;
      } else if (what === 'kick') {
        if (!p.onGround && (p.action === 'none' || p.action === 'punch')) {
          // Kicking in the air is a drop-kick.
          p.start('dropkick', DROP_KICK.length, DROP_KICK);
          p.vy = Math.max(p.vy, -120);
        } else if (canChain) {
          p.start('kick', PLAYER_KICK.length, PLAYER_KICK);
        }
      } else if (what === 'jump') {
        // At the bottom of the stairs, ↑ climbs them. Anywhere else it jumps.
        const stairs = this.stairsHere(p, 'bottom');
        if (stairs && p.action === 'none') this.startClimb(p, stairs, true);
        else if (p.onGround && p.action === 'none') {
          p.vy = -JUMP_SPEED;
          p.onGround = false;
        }
      } else if (what === 'shoot') {
        // Quick draw: one shot per press. If the gun is already out, aim and fire again straight away.
        if (p.action === 'shoot' && p.fired) {
          p.actionTime = SHOT_FIRE_AT - 0.04;
          p.fired = false;
        } else if (p.action !== 'roll' && p.action !== 'hurt' && p.action !== 'sword' && p.action !== 'shoot') {
          p.start('shoot', SHOT_TIME);
          p.fired = false;
        }
      } else if (what === 'sword') {
        if (p.action !== 'roll' && p.action !== 'hurt' && p.action !== 'sword') {
          p.start('sword', SWORD.length, SWORD);
          p.trail = [];
        }
      } else if (what === 'roll') {
        // At the top of the stairs, ↓ goes down them. Anywhere else it rolls.
        const stairs = this.stairsHere(p, 'top');
        if (stairs && p.action === 'none') this.startClimb(p, stairs, false);
        else {
          this.downPressedAt = this.time;
          this.downPressedVx = p.vx;
        }
      }
    }
    this.rollOrSlide(p);
    // Holding block: stand still with arms up. Punches from the front do nothing.
    p.blocking = this.held.block && p.onGround && p.action === 'none' && !p.slide;
    if (p.slide) {
      // Sliding: the slide decides how fast you go, whatever else you're doing.
      p.slide.speed = Math.max(0, p.slide.speed - (SLIDE_SLOWDOWN + p.slide.speed * SLIDE_DRAG) * STEP);
      p.vx = p.slide.dir * p.slide.speed;
      if (isAttack(p.action)) {
        const want = (this.held.right ? 1 : 0) - (this.held.left ? 1 : 0);
        if (want !== 0) p.facing = want;
      }
      if (Math.random() < 0.5) {
        this.sparks.push({
          x: p.x - p.slide.dir * 4,
          y: p.y - 1,
          vx: -p.slide.dir * (20 + Math.random() * 40),
          vy: -10 - Math.random() * 40,
          life: 0.2 + Math.random() * 0.2,
          dust: true,
        });
      }
    } else if (p.blocking) {
      p.vx = approach(p.vx, 0, 1400 * STEP);
    } else if (p.action === 'none') {
      const want = (this.held.right ? 1 : 0) - (this.held.left ? 1 : 0);
      const accel = p.onGround ? 1400 : 900;
      p.vx = approach(p.vx, want * PLAYER_SPEED, accel * STEP);
      if (want !== 0) p.facing = want;
    } else if (isAttack(p.action)) {
      // You can keep moving (and turn around) in the middle of an attack.
      const want = (this.held.right ? 1 : 0) - (this.held.left ? 1 : 0);
      const accel = p.onGround ? 1400 : 900;
      p.vx = approach(p.vx, want * PLAYER_SPEED, accel * STEP);
      if (want !== 0) p.facing = want;
    }
  }

  private spawnEnemies(dt: number): void {
    // There's no limit: new enemies come in every so often, however many are already here.
    this.runtTimer -= dt;
    if (this.runtTimer <= 0) {
      this.runtTimer = RUNT_SPAWN_GAP;
      this.spawnEnemy('runt');
    }
    this.spawnTimer -= dt;
    if (this.spawnTimer <= 0) {
      this.spawnTimer = SPAWN_GAP;
      this.spawnEnemy(Math.random() < BRUTE_CHANCE ? 'brute' : 'normal');
    }
    this.gangTimer -= dt;
    if (this.gangTimer <= 0) {
      this.gangTimer = GANG_SPAWN_GAP;
      // The whole gang arrives at the same place, bunched up together.
      const spawn = this.randomSpawn();
      for (let i = 0; i < GANG_SIZE; i++) this.spawnEnemy('normal', spawn, i * 2);
    }
  }

  private randomSpawn(): SpawnPoint {
    return this.building.spawns[Math.floor(Math.random() * this.building.spawns.length)];
  }

  private spawnEnemy(kind: 'normal' | 'brute' | 'runt', spawn = this.randomSpawn(), offset = 0): void {
    // Enemies turn up on a balcony or at the end of the street, and have to break in.
    const enemy = new Fighter(spawn.x + spawn.facing * offset, spawn.y, randomEnemyColor(), false);
    enemy.facing = spawn.facing;
    enemy.speed = 70 + Math.random() * 45;
    enemy.range = 18 + Math.random() * 5;
    if (kind === 'runt') {
      enemy.size = RUNT_SIZE;
      enemy.hp = RUNT_HP;
      enemy.punch = RUNT_PUNCH;
      enemy.range = 13 + Math.random() * 4;
    } else if (kind === 'brute') {
      enemy.size = BRUTE_SIZE;
      enemy.hp = BRUTE_HP;
      enemy.punch = BRUTE_PUNCH;
      enemy.speed = 32 + Math.random() * 12;
      enemy.range = 24 + Math.random() * 4;
    }
    enemy.cooldown = 0.1 + Math.random() * 0.25;
    this.enemies.push(enemy);
  }

  private thinkEnemy(e: Fighter, dt: number): void {
    if (!e.alive) {
      e.deadTime += dt;
      return;
    }
    e.cooldown -= dt;
    if (e.action !== 'none') return;
    const p = this.player;
    if (!p.alive || this.defeatTimer > 0) {
      if (e.onGround) e.vx = approach(e.vx, 0, 800 * dt);
      return;
    }
    if (!e.onGround) {
      // Keep going forward while jumping, so he clears whatever he jumped over.
      e.vx = approach(e.vx, e.facing * e.speed, 600 * dt);
      return;
    }
    // Work out where to head: straight for the player if he's on the same floor,
    // otherwise along the quickest way there (through doors, along streets, up and down stairs).
    const mySurface = surfaceAt(this.building, e.x, e.y);
    const playerSurface = this.playerSurface;
    let goalX = p.x;
    let chasing = mySurface !== null && mySurface === playerSurface;
    if (mySurface && playerSurface && !chasing) {
      const step = firstStep(this.building, mySurface, playerSurface);
      if (step && 'stairs' in step) {
        goalX = step.up ? step.stairs.bottomX : step.stairs.topX;
        if (Math.abs(e.x - goalX) < 6) {
          this.startClimb(e, step.stairs, step.up);
          return;
        }
      } else if (step) {
        goalX = step.walkTo;
      } else {
        chasing = true;
      }
    }
    const dx = goalX - e.x;
    e.facing = dx >= 0 ? 1 : -1;
    // A closed door in the way gets punched down.
    const door = this.doorAhead(e);
    if (door) {
      e.vx = approach(e.vx, 0, 900 * dt);
      if (e.cooldown <= 0) {
        e.start('punch', e.punch.length, e.punch);
        e.cooldown = e.punch.length + 0.2;
      }
      return;
    }
    // Hop over crates and anything else low in the way.
    if (this.lowObstacleAhead(e)) {
      e.vy = -JUMP_SPEED;
      e.vx = e.facing * e.speed;
      e.onGround = false;
      return;
    }
    const distance = Math.abs(dx);
    if (!chasing || distance > e.range) {
      // Don't bunch up: wait if a friend is already in the way.
      const blocked = this.enemies.some(
        (other) =>
          other !== e &&
          other.alive &&
          Math.sign(other.x - e.x) === e.facing &&
          Math.abs(other.x - e.x) < 15 &&
          Math.abs(p.x - other.x) < distance,
      );
      e.vx = approach(e.vx, blocked && chasing ? 0 : e.facing * e.speed, 900 * dt);
    } else {
      e.vx = approach(e.vx, 0, 900 * dt);
      if (e.cooldown <= 0 && Math.abs(p.y - e.y) < 30) {
        e.start('punch', e.punch.length, e.punch);
        const rest = e.punch === BRUTE_PUNCH ? 0.05 + Math.random() * 0.15 : 0.15 + Math.random() * 0.45;
        e.cooldown = e.punch.length + rest;
      }
    }
  }

  private moveFighter(f: Fighter, dt: number): void {
    f.flash = Math.max(0, f.flash - dt * 5);
    if (f.action === 'climb' && f.climb) {
      this.moveOnStairs(f, dt);
      this.animate(f, dt);
      return;
    }
    if (f.stuck) {
      this.animate(f, dt);
      return;
    }

    if (f.action !== 'none' && f.action !== 'dead') {
      f.actionTime += dt * (f.action === 'punch' || f.action === 'kick' ? f.attackSpeed : 1);
      if (f.action === 'shoot' && !f.fired && f.actionTime >= SHOT_FIRE_AT) {
        f.fired = true;
        this.fire(f);
      }
      if (f.action === 'roll') f.vx = f.facing * ROLL_SPEED;
      else if (f.action === 'dropkick') {
        f.vx = f.facing * DROP_KICK_SPEED;
        // Landing ends the drop-kick.
        if (f.onGround) {
          f.action = 'none';
          f.vx *= 0.3;
        }
      }
      else if (f.onGround && !(f.isPlayer && isAttack(f.action))) f.vx = approach(f.vx, 0, 700 * dt);
      if (f.attack && f.actionTime >= f.attack.activeFrom && f.actionTime <= f.attack.activeTo) this.checkHits(f, f.attack);
      if (f.actionTime >= f.actionLength) f.action = 'none';
    }
    if (f.action === 'dead' && f.onGround) f.vx = approach(f.vx, 0, 600 * dt);

    f.vy = Math.min(f.vy + GRAVITY * dt, 900);
    this.moveX(f, f.vx * dt);
    this.moveY(f, f.vy * dt);
    // Balcony railings keep everyone from falling off the building.
    f.x = Math.max(WORLD_LEFT + 6, Math.min(WORLD_RIGHT - 6, f.x));

    this.animate(f, dt);
  }

  private solidsFor(f: Fighter): Solid[] {
    return this.building.solids.filter((solid) => !solid.broken && this.overlaps(f, solid));
  }

  private overlaps(f: Fighter, solid: Solid): boolean {
    const half = BODY_HALF * f.size;
    return f.x + half > solid.x && f.x - half < solid.x + solid.w && f.y > solid.y && f.y - BODY_H * f.size < solid.y + solid.h;
  }

  private moveX(f: Fighter, dx: number): void {
    f.x += dx;
    const half = BODY_HALF * f.size;
    for (const solid of this.solidsFor(f)) {
      if (dx > 0) f.x = solid.x - half;
      else if (dx < 0) f.x = solid.x + solid.w + half;
      if (f.action !== 'dead') f.vx = 0;
      else f.vx = -f.vx * 0.3;
    }
  }

  private moveY(f: Fighter, dy: number): void {
    f.y += dy;
    f.onGround = false;
    for (const solid of this.solidsFor(f)) {
      if (dy > 0) {
        f.y = solid.y;
        f.onGround = true;
      } else if (dy < 0) {
        f.y = solid.y + solid.h + BODY_H * f.size;
      }
      f.vy = 0;
    }
  }

  /** Is this stickman standing at the bottom (or top) of a flight of stairs? */
  private stairsHere(f: Fighter, end: 'bottom' | 'top'): Stairs | null {
    if (!f.onGround) return null;
    for (const stairs of this.building.stairs) {
      const x = end === 'bottom' ? stairs.bottomX : stairs.topX;
      const y = end === 'bottom' ? stairs.bottomY : stairs.topY;
      if (Math.abs(f.x - x) < STAIRS_REACH && Math.abs(f.y - y) < 2) return stairs;
    }
    return null;
  }

  private startClimb(f: Fighter, stairs: Stairs, up: boolean): void {
    f.start('climb', 99);
    f.climb = { stairs, up, along: up ? 0 : 1 };
    f.vx = 0;
    f.vy = 0;
  }

  /** Walk up or down the stairs, passing through the floor above. */
  private moveOnStairs(f: Fighter, dt: number): void {
    const c = f.climb!;
    const s = c.stairs;
    const length = Math.hypot(s.topX - s.bottomX, s.topY - s.bottomY);
    c.along += ((c.up ? 1 : -1) * CLIMB_SPEED * dt) / length;
    const along = Math.max(0, Math.min(1, c.along));
    f.x = s.bottomX + (s.topX - s.bottomX) * along;
    f.y = s.bottomY + (s.topY - s.bottomY) * along;
    f.facing = c.up ? s.dir : -s.dir;
    f.vx = f.facing * CLIMB_SPEED * 0.75;
    f.onGround = true;
    if (c.along <= 0 || c.along >= 1) {
      // Step off onto the floor, still close enough to turn round and use the stairs again.
      f.action = 'none';
      f.climb = null;
      f.x += f.facing * 8;
      f.vx = 0;
    }
  }

  /** Tap ↓ to roll, hold it to slide. Also ends the slide when it's over. */
  private rollOrSlide(p: Fighter): void {
    if (this.downPressedAt !== null) {
      const heldFor = this.time - this.downPressedAt;
      if (!this.held.down) {
        if (heldFor < SLIDE_HOLD && p.onGround && p.action === 'none') {
          p.slide = null;
          p.start('roll', ROLL_TIME);
        }
        this.downPressedAt = null;
      } else if (heldFor >= SLIDE_HOLD) {
        this.downPressedAt = null;
        const busy = p.action === 'roll' || p.action === 'climb' || p.action === 'dropkick' || p.action === 'hurt';
        if (p.onGround && !busy) {
          const vx = Math.abs(p.vx) > Math.abs(this.downPressedVx) ? p.vx : this.downPressedVx;
          p.slide = { dir: Math.sign(vx) || p.facing, speed: Math.abs(vx) * SLIDE_BOOST };
        }
      }
    }
    if (p.slide) {
      const stopped = p.slide.speed < 12;
      const interrupted = p.action === 'roll' || p.action === 'climb' || p.action === 'dropkick' || p.action === 'hurt' || p.action === 'dead';
      if (stopped || interrupted || !this.held.down || !p.onGround) p.slide = null;
    }
  }

  /** Something low and solid (like a crate) right in front of this stickman, that he could jump over. */
  private lowObstacleAhead(f: Fighter): boolean {
    const half = BODY_HALF * f.size;
    const probeX = f.x + f.facing * (half + 4);
    return this.building.solids.some(
      (solid) =>
        solid.kind === 'crate' &&
        probeX > solid.x &&
        probeX < solid.x + solid.w &&
        solid.y < f.y &&
        solid.y > f.y - 75,
    );
  }

  /** The surface the player is on, or the one he's climbing towards. Remembered while he's in the air. */
  private get playerSurface(): Surface | null {
    const p = this.player;
    if (p.climb) {
      const s = p.climb.stairs;
      this.lastPlayerSurface = p.climb.up ? surfaceAt(this.building, s.topX, s.topY) : surfaceAt(this.building, s.bottomX, s.bottomY);
    } else {
      this.lastPlayerSurface = surfaceAt(this.building, p.x, p.y) ?? this.lastPlayerSurface;
    }
    return this.lastPlayerSurface;
  }

  /** A closed door right in front of this stickman, on his floor. */
  private doorAhead(f: Fighter): Solid | null {
    for (const door of this.building.doors) {
      if (door.broken || Math.abs(door.y + door.h - f.y) > 2) continue;
      const ahead = (door.x + door.w / 2 - f.x) * f.facing;
      if (ahead > 0 && ahead < BODY_HALF * f.size + 10) return door;
    }
    return null;
  }

  /** One hit smashes a door into splinters. */
  private breakDoor(door: Solid, dir: number): void {
    door.broken = true;
    for (let i = 0; i < 18; i++) {
      this.splinters.push({
        x: door.x + door.w / 2,
        y: door.y + Math.random() * door.h,
        vx: dir * (40 + Math.random() * 180) + (Math.random() - 0.5) * 60,
        vy: -60 - Math.random() * 160,
        spin: (Math.random() - 0.5) * 20,
        angle: Math.random() * Math.PI,
        life: 1.2 + Math.random() * 0.6,
      });
    }
    // Stains that were on the door go with it.
    this.stains = this.stains.filter(
      (stain) => !(stain.x > door.x - 2 && stain.x < door.x + door.w + 2 && stain.y > door.y && stain.y < door.y + door.h),
    );
  }

  private checkHits(attacker: Fighter, attack: AttackKind): void {
    const attackTop = attacker.y - attack.top;
    const attackBottom = attacker.y - attack.bottom;
    for (const door of this.building.doors) {
      if (door.broken) continue;
      const ahead = (door.x + door.w / 2 - attacker.x) * attacker.facing;
      if (ahead < -4 || ahead > attack.reach + 4) continue;
      if (attackBottom < door.y || attackTop > door.y + door.h) continue;
      this.breakDoor(door, attacker.facing);
    }
    const targets = attacker.isPlayer ? this.enemies : [this.player];
    for (const target of targets) {
      if (!target.alive || target.action === 'roll' || attacker.hitAlready.has(target)) continue;
      const ahead = (target.x - attacker.x) * attacker.facing;
      if (ahead < -4 || ahead > attack.reach + 6 * target.size) continue;
      const height = target.slide ? SLIDE_HEIGHT : 36 * target.size;
      if (attackBottom < target.y - height || attackTop > target.y) continue;
      attacker.hitAlready.add(target);
      if (target.blocking && Math.sign(attacker.x - target.x) === target.facing) this.blocked(target, attacker);
      else this.hurt(target, attacker, attack);
    }
  }

  private hurt(target: Fighter, attacker: Fighter, attack: AttackKind): void {
    const damage = attack.damage ?? 1;
    target.hp -= damage;
    target.flash = 1;
    const dir = attacker.facing;
    const knockedOut = target.hp <= 0;
    this.splat(target, dir, (knockedOut ? 80 : 16) * target.size * damage, knockedOut ? 2.2 : 1);
    // A big puddle where a stickman goes down.
    if (knockedOut && target.onGround) this.addStain(target.x + dir * 6, target.y + 1, 16, 3, target.color);
    if (!target.isPlayer) target.facing = -dir;
    const onStairs = target.action === 'climb';
    if (target.hp <= 0) {
      target.start('dead', 0);
      // Knocked out on the stairs: stay there instead of dropping through the floor.
      target.stuck = onStairs;
      target.climb = null;
      target.deadTime = 0;
      target.vx = (dir * attack.knockback * 0.8) / target.size;
      target.vy = -200;
      if (target.isPlayer) this.defeatTimer = DEFEAT_PAUSE;
      else this.knockouts += 1;
      return;
    }
    // Someone on the stairs keeps climbing; they just take the hit.
    if (onStairs) return;
    const kicked = attack === PLAYER_KICK || attack === DROP_KICK;
    target.start('hurt', target.isPlayer ? 0.12 : kicked ? 0.6 : 0.25);
    // Big brutes are heavier, so they don't fly quite as far.
    target.vx = (dir * attack.knockback) / target.size;
    target.vy = kicked ? -320 : -70;
  }

  /** Fire one revolver bullet from the hip, somewhere inside the spread. */
  private fire(p: Fighter): void {
    const angle = ((Math.random() - 0.5) * SHOT_SPREAD * Math.PI) / 180;
    const muzzle = muzzlePoint(figureJoints(p.pose));
    const x = p.x + p.facing * muzzle.x;
    const y = p.y + muzzle.y;
    this.bullets.push({ x, y, vx: Math.cos(angle) * BULLET_SPEED * p.facing, vy: Math.sin(angle) * BULLET_SPEED, life: 1 });
    // Muzzle flash sparks.
    for (let i = 0; i < 6; i++) {
      this.sparks.push({
        x: x + p.facing * 4,
        y,
        vx: p.facing * (60 + Math.random() * 120),
        vy: (Math.random() - 0.5) * 120,
        life: 0.08 + Math.random() * 0.08,
      });
    }
  }

  private updateBullets(dt: number): void {
    for (const bullet of this.bullets) {
      bullet.life -= dt;
      bullet.x += bullet.vx * dt;
      bullet.y += bullet.vy * dt;
      // A bullet in the head knocks an enemy out straight away.
      const headshot = this.enemies.find((e) => {
        if (!e.alive) return false;
        const head = figureJoints(e.pose).head;
        const hx = e.x + e.facing * head.x * e.size;
        const hy = e.y + head.y * e.size;
        return Math.hypot(bullet.x - hx, bullet.y - hy) < 5.5 * e.size;
      });
      const target = this.enemies.find(
        (e) => e.alive && Math.abs(bullet.x - e.x) < 6 * e.size && bullet.y > e.y - 30 * e.size && bullet.y < e.y,
      );
      if (headshot) {
        this.hurt(headshot, this.player, { ...GUNSHOT, damage: headshot.hp, knockback: 220 });
        this.popups.push({ x: headshot.x, y: headshot.y - 46 * headshot.size, text: 'HEADSHOT!', life: 1 });
        this.headshots += 1;
        bullet.life = 0;
      } else if (target) {
        this.hurt(target, this.player, GUNSHOT);
        bullet.life = 0;
      } else if (this.solidAt(bullet.x, bullet.y)) {
        // Bullets smash doors, and leave holes in floors and walls.
        const solid = this.solidAt(bullet.x, bullet.y)!;
        const hx = bullet.x;
        const hy = bullet.y;
        if (solid.kind === 'door') this.breakDoor(solid, Math.sign(bullet.vx));
        else this.addStain(hx, hy, 1.4, 1.4, '#0b0c14');
        for (let i = 0; i < 4; i++) {
          this.sparks.push({ x: hx, y: hy, vx: (Math.random() - 0.5) * 140, vy: -40 - Math.random() * 100, life: 0.15 + Math.random() * 0.1 });
        }
        bullet.life = 0;
      }
    }
    this.bullets = this.bullets.filter((bullet) => bullet.life > 0);
    for (const bit of this.splinters) {
      bit.life -= dt;
      bit.vy += 700 * dt;
      bit.x += bit.vx * dt;
      bit.y += bit.vy * dt;
      bit.angle += bit.spin * dt;
      const floor = this.solidAt(bit.x, bit.y);
      if (floor && floor.kind !== 'door') {
        bit.y = floor.y;
        bit.vx *= 0.5;
        bit.vy = 0;
        bit.spin = 0;
      }
    }
    this.splinters = this.splinters.filter((bit) => bit.life > 0);
    for (const popup of this.popups) {
      popup.life -= dt;
      popup.y -= 18 * dt;
    }
    this.popups = this.popups.filter((popup) => popup.life > 0);
  }

  /** A punch hit the player's guard: a little shove and some sparks, but no damage. */
  private blocked(target: Fighter, attacker: Fighter): void {
    target.vx = attacker.facing * 60;
    for (let i = 0; i < 8; i++) {
      this.sparks.push({
        x: target.x + target.facing * 8,
        y: target.y - 30 + (Math.random() - 0.5) * 6,
        vx: target.facing * (20 + Math.random() * 90) * -1 + (Math.random() - 0.5) * 60,
        vy: -30 - Math.random() * 120,
        life: 0.25 + Math.random() * 0.15,
      });
    }
  }

  /** Spray drops of the stickman's colour away from the hit. */
  private splat(target: Fighter, dir: number, count: number, size = 1): void {
    for (let i = 0; i < count; i++) {
      this.drops.push({
        x: target.x + (Math.random() - 0.5) * 6,
        y: target.y - 22 - Math.random() * 10,
        vx: (dir * (50 + Math.random() * 220) + (Math.random() - 0.5) * 80) * (size > 1 ? 1.4 : 1),
        vy: (-40 - Math.random() * 240) * (size > 1 ? 1.3 : 1),
        size: (1 + Math.random() * 1.8) * (size > 1 ? 0.7 + Math.random() * size * 0.6 : 1),
        color: target.color,
        wallAt: Math.random() < 0.3 ? 0.08 + Math.random() * 0.3 : null,
        age: 0,
      });
    }
  }

  private updateDrops(dt: number): void {
    for (const spark of this.sparks) {
      spark.life -= dt;
      spark.vy += 500 * dt;
      spark.x += spark.vx * dt;
      spark.y += spark.vy * dt;
    }
    this.sparks = this.sparks.filter((spark) => spark.life > 0);
    const kept: Drop[] = [];
    for (const drop of this.drops) {
      drop.age += dt;
      drop.vy += 900 * dt;
      drop.x += drop.vx * dt;
      drop.y += drop.vy * dt;
      if (drop.wallAt !== null && drop.age >= drop.wallAt) {
        this.addStain(drop.x, drop.y, drop.size * 1.3, drop.size * 1.3, drop.color);
      } else if (this.solidAt(drop.x, drop.y)) {
        const solid = this.solidAt(drop.x, drop.y)!;
        const fromAbove = drop.y - drop.vy * dt <= solid.y + 1;
        if (fromAbove) this.addStain(drop.x, solid.y + 1, drop.size * 2.6, drop.size * 0.7, drop.color);
        else this.addStain(drop.vx > 0 ? solid.x - 1 : solid.x + solid.w + 1, drop.y, drop.size * 0.8, drop.size * 2, drop.color);
      } else if (drop.age > 4) {
        // Fell out of the world.
      } else {
        kept.push(drop);
      }
    }
    this.drops = kept;
  }

  /** The solid part of the building at this point, if any. */
  private solidAt(x: number, y: number): Solid | null {
    for (const solid of this.building.solids) {
      if (solid.broken) continue;
      if (x >= solid.x && x <= solid.x + solid.w && y >= solid.y && y <= solid.y + solid.h) return solid;
    }
    return null;
  }

  private addStain(x: number, y: number, rx: number, ry: number, color: string): void {
    this.stains.push({ x, y, rx, ry, color });
    if (this.stains.length > MAX_STAINS) this.stains.shift();
  }

  /** Pick the pose for what this stickman is doing, and blend towards it. */
  private animate(f: Fighter, dt: number): void {
    let target: Pose;
    let rate = 14;
    const progress = f.actionLength > 0 ? Math.min(1, f.actionTime / f.actionLength) : 0;
    if (f.action === 'dead') {
      target = knockedOutPose();
      rate = 10;
    } else if (f.action === 'roll') {
      target = tuckPose();
      rate = 30;
    } else if (f.action === 'punch') {
      const windup = f.attack ? f.attack.activeFrom / f.attack.length : 0.25;
      target = punchPose(progress, windup);
      rate = f.isPlayer ? 34 : 22;
    } else if (f.action === 'shoot') {
      const aiming = f.actionTime >= 0.06 && f.actionTime < SHOT_PUT_AWAY_AT;
      target = aiming ? shootPose() : holsterPose();
      rate = 45;
    } else if (f.action === 'sword') {
      const t = f.actionTime;
      target = swordPose(t < 0.1 ? 'grab' : t < SWORD.activeFrom ? 'raise' : t < 0.45 ? (t < SWORD.activeTo ? 'slash' : 'follow') : 'grab');
      rate = t >= SWORD.activeFrom && t < SWORD.activeTo ? 55 : 35;
    } else if (f.action === 'dropkick') {
      target = dropKickPose();
      rate = 30;
    } else if (f.action === 'kick') {
      target = kickPose(progress);
      rate = 30;
    } else if (f.action === 'climb') {
      f.runPhase += dt * CLIMB_SPEED * 0.06;
      target = runPose(f.runPhase);
      rate = 22;
    } else if (f.blocking) {
      target = blockPose();
      rate = 30;
    } else if (f.action === 'hurt') {
      target = hurtPose();
      rate = 24;
    } else if (!f.onGround) {
      target = jumpPose(f.vy);
      rate = 12;
    } else if (Math.abs(f.vx) > 15) {
      f.runPhase += dt * Math.abs(f.vx) * 0.075;
      target = runPose(f.runPhase);
      rate = 22;
    } else {
      target = idlePose(this.time + f.speed);
      rate = 10;
    }
    if (f.slide) {
      // Sliding: low legs and leaning back. Arms keep doing whatever attack is going on.
      const slide = slidePose();
      target = isAttack(f.action)
        ? { ...target, lean: slide.lean, legFu: slide.legFu, legFl: slide.legFl, legBu: slide.legBu, legBl: slide.legBl }
        : slide;
      rate = Math.max(rate, 30);
    } else if (isAttack(f.action) && f.action !== 'kick' && f.onGround && Math.abs(f.vx) > 15) {
      // Moving while attacking: the legs keep walking under the attack.
      f.runPhase += dt * Math.abs(f.vx) * 0.075;
      const walk = runPose(f.runPhase);
      target = { ...target, legFu: walk.legFu, legFl: walk.legFl, legBu: walk.legBu, legBl: walk.legBl };
    }
    blendPose(f.pose, target, rate, dt);

    // Remember where the sword tip has been, for the swoosh.
    if (f.action === 'sword' && f.actionTime >= SWORD.activeFrom - 0.03 && f.actionTime < SWORD.activeTo + 0.05) {
      const tip = swordTip(figureJoints(f.pose));
      f.trail.push({ x: f.x + f.facing * tip.x * f.size, y: f.y + tip.y * f.size });
      if (f.trail.length > 8) f.trail.shift();
    } else if (f.trail.length > 0) {
      f.trail.shift();
    }
  }

  // ---------- drawing ----------

  private draw(): void {
    const ctx = this.ctx;
    this.followCamera();
    ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
    drawSky(ctx, VIEW_W, VIEW_H, this.camera.x);
    ctx.save();
    ctx.translate(-Math.round(this.camera.x * 4) / 4, -Math.round(this.camera.y * 4) / 4);
    drawBuilding(ctx, this.building, { x: this.camera.x, y: this.camera.y, w: VIEW_W, h: VIEW_H }, this.time);
    for (const stain of this.stains) {
      ctx.fillStyle = stain.color;
      ctx.globalAlpha = 0.85;
      ctx.beginPath();
      ctx.ellipse(stain.x, stain.y, stain.rx, stain.ry, 0, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalAlpha = 1;

    const fighters = [...this.enemies.filter((e) => !e.alive), ...this.enemies.filter((e) => e.alive), this.player];
    for (const f of fighters) this.drawFighter(f);

    for (const drop of this.drops) {
      ctx.fillStyle = drop.color;
      ctx.beginPath();
      ctx.arc(drop.x, drop.y, drop.size, 0, Math.PI * 2);
      ctx.fill();
    }
    // Bullets leave a short bright streak.
    ctx.strokeStyle = '#fff2b0';
    ctx.lineWidth = 1.2;
    for (const bullet of this.bullets) {
      ctx.beginPath();
      ctx.moveTo(bullet.x, bullet.y);
      ctx.lineTo(bullet.x - bullet.vx * 0.012, bullet.y - bullet.vy * 0.012);
      ctx.stroke();
    }
    for (const spark of this.sparks) {
      ctx.globalAlpha = Math.min(1, spark.life * 4) * (spark.dust ? 0.5 : 1);
      ctx.fillStyle = spark.dust ? '#9a9aa6' : '#fff6c8';
      ctx.fillRect(spark.x - 1, spark.y - 1, 2, 2);
    }
    ctx.globalAlpha = 1;
    // Bits of smashed doors.
    ctx.fillStyle = '#8a5f3c';
    for (const bit of this.splinters) {
      ctx.save();
      ctx.globalAlpha = Math.min(1, bit.life * 2);
      ctx.translate(bit.x, bit.y);
      ctx.rotate(bit.angle);
      ctx.fillRect(-3, -0.8, 6, 1.6);
      ctx.restore();
    }
    ctx.globalAlpha = 1;
    this.drawStairsHint();
    for (const popup of this.popups) {
      ctx.globalAlpha = Math.min(1, popup.life * 2);
      ctx.textAlign = 'center';
      ctx.font = 'bold 11px system-ui, sans-serif';
      ctx.fillStyle = '#12131c';
      ctx.fillText(popup.text, popup.x + 1, popup.y + 1);
      ctx.fillStyle = '#ffd23f';
      ctx.fillText(popup.text, popup.x, popup.y);
    }
    ctx.globalAlpha = 1;
    ctx.restore();
    this.drawHud();
  }

  /** Show a little ↑ or ↓ when the player can use the stairs. */
  private drawStairsHint(): void {
    const p = this.player;
    if (p.action !== 'none' || !p.alive) return;
    const up = this.stairsHere(p, 'bottom');
    const down = this.stairsHere(p, 'top');
    if (!up && !down) return;
    const ctx = this.ctx;
    const bob = Math.sin(this.time * 6) * 1.5;
    ctx.fillStyle = '#ffd23f';
    ctx.font = 'bold 12px system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(up ? '↑' : '↓', p.x, p.y - 46 + bob);
  }

  private cameraGoal(): { x: number; y: number } {
    const p = this.player;
    return {
      x: Math.max(WORLD_LEFT - 20, Math.min(WORLD_RIGHT + 20 - VIEW_W, p.x - VIEW_W / 2)),
      y: Math.max(ROOF_Y - 60, Math.min(GROUND_Y + 30 - VIEW_H, p.y - VIEW_H * 0.62)),
    };
  }

  private snapCamera(): void {
    this.camera = this.cameraGoal();
  }

  private followCamera(): void {
    const goal = this.cameraGoal();
    this.camera.x += (goal.x - this.camera.x) * 0.12;
    this.camera.y += (goal.y - this.camera.y) * 0.12;
  }

  private drawFighter(f: Fighter): void {
    const progress = f.actionLength > 0 ? Math.min(1, f.actionTime / f.actionLength) : 0;
    const spin = f.action === 'roll' ? progress * Math.PI * 2 : 0;
    const topple = f.action === 'dead' ? -Math.min(1, f.deadTime * 3.5) * (Math.PI / 2) : 0;
    const alpha = f.action === 'dead' && !f.isPlayer ? Math.max(0, Math.min(1, 2.5 - f.deadTime)) : 1;
    const minHip = f.action === 'roll' ? 15 : 6;
    drawFigure(this.ctx, f.x, f.y, f.facing, f.pose, {
      color: f.color,
      spin,
      minHip,
      topple,
      alpha,
      flash: f.flash,
      size: f.size,
      gear: f.isPlayer ? (ctx, joints, layer) => this.drawGear(f, ctx, joints, layer) : undefined,
    });
    if (f.isPlayer) this.drawSwordTrail(f);
  }

  /** Draw the player's holster, revolver and sword in the right place on his body. */
  private drawGear(f: Fighter, ctx: CanvasRenderingContext2D, joints: ReturnType<typeof figureJoints>, layer: 'behind' | 'front'): void {
    const t = f.actionTime;
    const gunOut = f.action === 'shoot' && t >= 0.05 && t < SHOT_PUT_AWAY_AT + 0.08;
    const swordOut = f.action === 'sword' && t >= 0.07 && t < SWORD.length - 0.04;
    if (layer === 'behind') {
      if (!swordOut) drawSwordOnBack(ctx, joints);
      return;
    }
    drawHolster(ctx, joints, !gunOut);
    if (gunOut) drawRevolverInHand(ctx, joints, f.fired && t < SHOT_FIRE_AT + 0.06);
    if (swordOut) drawSwordInHand(ctx, joints);
  }

  /** A swoosh following the sword tip during the slash. */
  private drawSwordTrail(f: Fighter): void {
    if (f.action !== 'sword' || f.trail.length < 2) return;
    const ctx = this.ctx;
    ctx.lineCap = 'round';
    for (let i = 1; i < f.trail.length; i++) {
      ctx.strokeStyle = `rgba(230, 240, 255, ${(i / f.trail.length) * 0.6})`;
      ctx.lineWidth = 1 + (i / f.trail.length) * 3;
      ctx.beginPath();
      ctx.moveTo(f.trail[i - 1].x, f.trail[i - 1].y);
      ctx.lineTo(f.trail[i].x, f.trail[i].y);
      ctx.stroke();
    }
  }

  private drawHud(): void {
    const ctx = this.ctx;
    const p = this.player;
    // Health bar.
    ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
    ctx.fillRect(10, 10, 124, 14);
    const hp = Math.max(0, p.hp);
    const part = hp / PLAYER_HP;
    ctx.fillStyle = part > 0.3 ? PLAYER_COLOR : '#ff5a4f';
    ctx.fillRect(12, 12, 120 * part, 10);
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold 9px system-ui, sans-serif';
    ctx.textAlign = 'left';
    ctx.fillText(`HP ${Number.isInteger(hp) ? hp : hp.toFixed(1)} / ${PLAYER_HP}`, 16, 20.5);

    ctx.textAlign = 'left';
    ctx.font = 'bold 12px system-ui, sans-serif';
    ctx.fillText(`KOs: ${this.knockouts}`, 146, 21.5);
    ctx.fillStyle = '#ffd23f';
    ctx.fillText(`Headshots: ${this.headshots}`, 216, 21.5);
    ctx.fillStyle = '#ffffff';
    const where = p.climb ? (p.climb.stairs.style === 'fire-escape' ? 'Fire escape' : 'Stairs') : this.playerSurface?.place ?? '';
    ctx.font = 'bold 10px system-ui, sans-serif';
    ctx.fillText(where, 312, 21);

    if (this.defeatTimer > 0) {
      ctx.fillStyle = 'rgba(10, 8, 16, 0.75)';
      ctx.fillRect(0, VIEW_H / 2 - 32, VIEW_W, 64);
      ctx.textAlign = 'center';
      ctx.fillStyle = '#ff5a4f';
      ctx.font = 'bold 24px system-ui, sans-serif';
      ctx.fillText('DEFEATED!', VIEW_W / 2, VIEW_H / 2 - 4);
      ctx.fillStyle = '#ffffff';
      ctx.font = '12px system-ui, sans-serif';
      ctx.fillText(
        `You knocked out ${this.knockouts} stickmen with ${this.headshots} headshots. Starting again...`,
        VIEW_W / 2,
        VIEW_H / 2 + 18,
      );
    }
  }
}

function isAttack(action: Action): boolean {
  return action === 'punch' || action === 'kick' || action === 'shoot' || action === 'sword';
}

function approach(value: number, target: number, step: number): number {
  if (value < target) return Math.min(target, value + step);
  return Math.max(target, value - step);
}

/** Any bright colour except green (green is the player's colour). */
function randomEnemyColor(): string {
  let hue = Math.floor(Math.random() * 260);
  if (hue >= 70) hue += 100; // skip the greens (70 to 170)
  const saturation = 70 + Math.floor(Math.random() * 20);
  const lightness = 52 + Math.floor(Math.random() * 12);
  return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}