Back to Stick Runner
SOURCE / PINNED RELEASE

Made of little things.

Stick Runner

Release
068f2c4fd180…
Author-recorded commit
2f0b589e2e75…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-938fb0aa7b7

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

src/game/render2d.ts
import { CLOTHES, EXPLOSION_TIME, FIREBALL_RADIUS, type Explosion, type Fireball, type GameView, type LineState, type WorldView } from './game.js';
import { SAW_RADIUS, SPIKE_HEIGHT, SPRING_HEIGHT, SPRING_WIDTH, VIEW_H, VIEW_W, lineHeightAt, roofHeightAt, sawCenter, type Block, type Ice, type Saw, type Spikes, type Spring } from './level.js';
import type { Pose } from './stickman.js';

/**
 * The 2D (side view) look of Stick Runner: flat sunset rooftops drawn on a canvas.
 * The 3D first-person look is in render3d.ts; view-mode.ts picks which one is used.
 */
export class Renderer2D implements WorldView {
  private ctx: CanvasRenderingContext2D;
  private scale = 1;
  private cameraX = 0;
  /** The frame being drawn. */
  private v!: GameView;

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

  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;
  }

  render(view: GameView): void {
    this.v = view;
    // The camera slides along to keep Rog a third of the way across the screen.
    const goal = Math.max(0, Math.min(view.level.end - VIEW_W, view.x - VIEW_W * 0.33));
    if (view.phase === 'running' && Math.abs(goal - this.cameraX) > VIEW_W) this.cameraX = goal;
    this.cameraX += (goal - this.cameraX) * 0.15;

    const ctx = this.ctx;
    ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
    this.drawSky();
    this.drawFarCity();

    ctx.save();
    ctx.translate(-Math.round(this.cameraX * 4) / 4, 0);
    this.drawPitFog();
    // Saws first, so the roof hides the bottom of each blade.
    for (const saw of view.level.saws) this.drawSaw(saw);
    for (const block of view.level.blocks) this.drawBlock(block);
    for (const saw of view.level.saws) this.drawSawSlot(saw);
    for (const patch of view.level.spikes) this.drawSpikes(patch);
    for (const spring of view.level.springs) this.drawSpring(spring);
    for (const patch of view.level.ice) this.drawIce(patch);
    for (const state of view.lines) this.drawClothesline(state);
    this.drawFinish();
    drawStickman(ctx, view.x, view.y, view.facing, view.pose, view.cycle, view.vy);
    if (view.dragon) this.drawDragon(view.dragon.x, view.dragon.y, view.dragon.warn);
    for (const ball of view.fireballs) this.drawFireball(ball);
    for (const blast of view.explosions) this.drawExplosion(blast);
    ctx.restore();
  }

  private drawSky(): void {
    const ctx = this.ctx;
    const sky = ctx.createLinearGradient(0, 0, 0, VIEW_H);
    const { scenery } = this.v.level;
    sky.addColorStop(0, scenery.sky[0]);
    sky.addColorStop(0.55, scenery.sky[1]);
    sky.addColorStop(1, scenery.sky[2]);
    ctx.fillStyle = sky;
    ctx.fillRect(0, 0, VIEW_W, VIEW_H);
    ctx.fillStyle = scenery.sun;
    ctx.beginPath();
    ctx.arc(360 - this.cameraX * 0.05, 92, 30, 0, Math.PI * 2);
    ctx.fill();
  }

  /** Faraway buildings that slide slowly, so the city feels deep. */
  private drawFarCity(): void {
    const ctx = this.ctx;
    const layers = [
      { speed: 0.2, color: this.v.level.scenery.farCity[0], base: 150, spread: 60, width: 46 },
      { speed: 0.4, color: this.v.level.scenery.farCity[1], base: 175, spread: 50, width: 58 },
    ];
    for (const layer of layers) {
      ctx.fillStyle = layer.color;
      const offset = this.cameraX * layer.speed;
      const first = Math.floor(offset / layer.width) - 1;
      for (let i = first; i < first + VIEW_W / layer.width + 3; i++) {
        const top = layer.base - noise(i * 7.3 + layer.speed * 100 + this.v.levelIndex * 31) * layer.spread;
        ctx.fillRect(i * layer.width - offset, top, layer.width - 4, VIEW_H - top);
      }
    }
  }

  /** Darkness at the bottom of the gaps, so the pits look deep and scary. */
  private drawPitFog(): void {
    const ctx = this.ctx;
    const fog = ctx.createLinearGradient(0, VIEW_H - 130, 0, VIEW_H);
    fog.addColorStop(0, 'rgba(29, 23, 36, 0)');
    fog.addColorStop(0.6, 'rgba(29, 23, 36, 0.85)');
    fog.addColorStop(1, 'rgba(10, 6, 12, 1)');
    ctx.fillStyle = fog;
    ctx.fillRect(this.cameraX - 10, VIEW_H - 130, VIEW_W + 20, 130);
  }

  private drawBlock(block: Block): void {
    const ctx = this.ctx;
    if (block.x > this.cameraX + VIEW_W || block.x + block.w < this.cameraX) return;
    // Springs are drawn by drawSpring, so they can stretch.
    if (block.kind === 'spring') return;
    if (block.kind === 'roof') {
      ctx.fillStyle = '#4a3f55';
      ctx.fillRect(block.x, block.y, block.w, block.h);
      // Windows, some lit and some dark.
      for (let wy = block.y + 22; wy < VIEW_H; wy += 26) {
        for (let wx = block.x + 14; wx < block.x + block.w - 18; wx += 26) {
          ctx.fillStyle = noise(wx * 0.37 + wy * 1.7) > 0.55 ? '#ffd27a' : '#3a3044';
          ctx.fillRect(wx, wy, 10, 13);
        }
      }
      // Roof ledge.
      ctx.fillStyle = '#6e6280';
      ctx.fillRect(block.x - 3, block.y, block.w + 6, 6);
      // Warning stripes at the edges of each pit.
      this.drawEdgeStripe(block.x - 3, block.y);
      this.drawEdgeStripe(block.x + block.w - 9, block.y);
    } else if (block.kind === 'chimney') {
      ctx.fillStyle = '#8a4b3c';
      ctx.fillRect(block.x, block.y, block.w, block.h);
      ctx.fillStyle = '#6b3a2f';
      ctx.fillRect(block.x - 2, block.y, block.w + 4, 5);
    } else if (block.kind === 'vent') {
      ctx.fillStyle = '#8f9aa6';
      ctx.fillRect(block.x, block.y, block.w, block.h);
      ctx.fillStyle = '#6d7782';
      for (let i = 4; i < block.h - 2; i += 5) ctx.fillRect(block.x + 4, block.y + i, block.w - 8, 2);
    } else {
      ctx.fillStyle = '#7b5a3f';
      ctx.fillRect(block.x, block.y, block.w, block.h);
      ctx.fillStyle = '#5e432e';
      ctx.fillRect(block.x, block.y + block.h * 0.33, block.w, 3);
      ctx.fillRect(block.x, block.y + block.h * 0.66, block.w, 3);
    }
  }

  /** A spinning silver blade with sharp teeth and a red middle. */
  private drawSaw(saw: Saw): void {
    const ctx = this.ctx;
    const { x, y } = sawCenter(saw, this.v.levelTime);
    const spin = this.v.clock * 14;
    const teeth = 12;
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(spin);
    ctx.fillStyle = '#c9d1d9';
    ctx.strokeStyle = '#6d7782';
    ctx.lineWidth = 1;
    ctx.beginPath();
    for (let i = 0; i < teeth; i++) {
      const a = (i / teeth) * Math.PI * 2;
      const b = ((i + 0.6) / teeth) * Math.PI * 2;
      ctx.lineTo(Math.cos(a) * (SAW_RADIUS - 4), Math.sin(a) * (SAW_RADIUS - 4));
      ctx.lineTo(Math.cos(b) * SAW_RADIUS, Math.sin(b) * SAW_RADIUS);
    }
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    ctx.fillStyle = '#9aa3ad';
    ctx.beginPath();
    ctx.arc(0, 0, SAW_RADIUS * 0.55, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#e5484d';
    ctx.beginPath();
    ctx.arc(0, 0, 3.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  /**
   * A red dragon flying to the right: shaded scales, bat wings with bones and skin,
   * spikes down its back, horns, claws and a spade-tipped tail. Before it breathes fire
   * its jaw opens, its mouth glows and smoke puffs out of its nose.
   */
  private drawDragon(x: number, y: number, warn: number): void {
    const ctx = this.ctx;
    const clock = this.v.clock;
    const flap = Math.sin(clock * 7);
    ctx.save();
    ctx.translate(x, y);

    this.drawDragonWing(flap * 0.8 - 0.15, '#5e1520', '#3e0d15', -3);

    // Tail: thick at the body, tapering to a spade, swaying as it flies.
    const sway = Math.sin(clock * 3) * 4;
    const tail: { x: number; y: number; w: number }[] = [];
    for (let i = 0; i <= 12; i++) {
      const t = i / 12;
      const px = -14 - t * 40;
      const py = 3 + Math.sin(t * Math.PI) * 6 - t * 8 + sway * t * t;
      tail.push({ x: px, y: py, w: 6 * (1 - t) + 0.8 });
    }
    ctx.fillStyle = '#a52f37';
    ctx.beginPath();
    tail.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y - p.w) : ctx.lineTo(p.x, p.y - p.w)));
    for (let i = tail.length - 1; i >= 0; i--) ctx.lineTo(tail[i].x, tail[i].y + tail[i].w * 0.8);
    ctx.fill();
    const tip = tail[tail.length - 1];
    ctx.fillStyle = '#7a1f2b';
    ctx.beginPath();
    ctx.moveTo(tip.x + 3, tip.y);
    ctx.lineTo(tip.x - 4, tip.y - 6);
    ctx.lineTo(tip.x - 9, tip.y);
    ctx.lineTo(tip.x - 4, tip.y + 6);
    ctx.fill();

    // Back leg, tucked up with claws.
    ctx.fillStyle = '#9b2c37';
    ctx.beginPath();
    ctx.ellipse(-8, 6, 7, 5, 0.5, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = '#9b2c37';
    ctx.lineWidth = 3.5;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(-8, 9);
    ctx.lineTo(-14, 15);
    ctx.stroke();
    this.drawClaws(-14, 15);

    // Body with shading: dark along the back, bright in the middle.
    const body = ctx.createLinearGradient(0, -11, 0, 11);
    body.addColorStop(0, '#7e1b26');
    body.addColorStop(0.45, '#c43d3d');
    body.addColorStop(1, '#a52f37');
    ctx.fillStyle = body;
    ctx.beginPath();
    ctx.ellipse(0, 0, 19, 10.5, -0.05, 0, Math.PI * 2);
    ctx.fill();
    // Belly plates.
    ctx.save();
    ctx.beginPath();
    ctx.ellipse(1, 5.5, 14, 4.5, 0, 0, Math.PI * 2);
    ctx.clip();
    ctx.fillStyle = '#e8a83e';
    ctx.fillRect(-14, 0, 30, 12);
    ctx.strokeStyle = 'rgba(122, 60, 20, 0.6)';
    ctx.lineWidth = 0.7;
    for (let px = -12; px <= 14; px += 3.5) {
      ctx.beginPath();
      ctx.moveTo(px, 1);
      ctx.lineTo(px + 1, 10);
      ctx.stroke();
    }
    ctx.restore();
    // Little scales.
    ctx.strokeStyle = 'rgba(70, 10, 20, 0.35)';
    ctx.lineWidth = 0.6;
    for (let row = -6; row <= 2; row += 3) {
      for (let px = -15 + (row % 2) * 1.5; px <= 15; px += 3.5) {
        if ((px / 19) ** 2 + (row / 10.5) ** 2 > 0.8) continue;
        ctx.beginPath();
        ctx.arc(px, row, 1.6, 0.2, Math.PI - 0.2);
        ctx.stroke();
      }
    }
    // Spikes down its back, from neck to tail.
    ctx.fillStyle = '#5e1520';
    for (let px = 14; px >= -16; px -= 5) {
      const top = -10.5 * Math.sqrt(Math.max(0, 1 - (px / 19) ** 2));
      ctx.beginPath();
      ctx.moveTo(px + 2.2, top + 1);
      ctx.lineTo(px - 1.5, top - 4.5);
      ctx.lineTo(px - 2.2, top + 1);
      ctx.fill();
    }

    // Front leg with claws.
    ctx.strokeStyle = '#a52f37';
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(11, 6);
    ctx.lineTo(15, 13);
    ctx.stroke();
    this.drawClaws(15, 13);

    // Neck, curving up to the head.
    ctx.fillStyle = '#b8343c';
    ctx.beginPath();
    ctx.moveTo(10, -8);
    ctx.quadraticCurveTo(20, -14, 28, -9);
    ctx.lineTo(29, -1);
    ctx.quadraticCurveTo(20, -2, 13, 5);
    ctx.fill();
    ctx.fillStyle = '#5e1520';
    for (const [px, py] of [[16, -12], [21, -13], [26, -12]]) {
      ctx.beginPath();
      ctx.moveTo(px + 2, py + 1);
      ctx.lineTo(px - 1, py - 3.5);
      ctx.lineTo(px - 2, py + 1.5);
      ctx.fill();
    }

    // Lower jaw: it drops open while the dragon gets ready to breathe fire.
    const jaw = warn * 0.45;
    ctx.save();
    ctx.translate(29, 0);
    ctx.rotate(jaw);
    ctx.fillStyle = '#9b2c37';
    ctx.beginPath();
    ctx.moveTo(0, -1);
    ctx.lineTo(13, 1);
    ctx.lineTo(12, 3.5);
    ctx.lineTo(0, 4);
    ctx.fill();
    ctx.fillStyle = '#f7f1e3';
    for (let px = 3; px <= 11; px += 2.6) {
      ctx.beginPath();
      ctx.moveTo(px, 0.5);
      ctx.lineTo(px + 1, -1.6);
      ctx.lineTo(px + 2, 0.7);
      ctx.fill();
    }
    ctx.restore();
    // Fire glowing inside the open mouth.
    if (warn > 0) {
      const glow = ctx.createRadialGradient(36, 1, 0, 36, 1, 4 + warn * 12);
      glow.addColorStop(0, `rgba(255, 245, 190, ${0.5 + warn * 0.5})`);
      glow.addColorStop(0.4, `rgba(255, 170, 40, ${0.4 + warn * 0.4})`);
      glow.addColorStop(1, 'rgba(255, 60, 10, 0)');
      ctx.fillStyle = glow;
      ctx.beginPath();
      ctx.arc(36, 1, 4 + warn * 12, 0, Math.PI * 2);
      ctx.fill();
    }

    // Head and long snout, with teeth along the top jaw.
    const head = ctx.createLinearGradient(0, -12, 0, 2);
    head.addColorStop(0, '#8e2230');
    head.addColorStop(1, '#c43d3d');
    ctx.fillStyle = head;
    ctx.beginPath();
    ctx.moveTo(23, -6);
    ctx.quadraticCurveTo(27, -12, 33, -9);
    ctx.lineTo(42, -4);
    ctx.quadraticCurveTo(44, -1, 42, 0.5);
    ctx.lineTo(29, 0.5);
    ctx.quadraticCurveTo(24, 0, 23, -6);
    ctx.fill();
    ctx.fillStyle = '#f7f1e3';
    for (let px = 31; px <= 41; px += 2.6) {
      ctx.beginPath();
      ctx.moveTo(px, 0.3);
      ctx.lineTo(px + 1, 2.4);
      ctx.lineTo(px + 2, 0.3);
      ctx.fill();
    }
    // Nostril, brow and a fierce yellow eye with a slit pupil.
    ctx.fillStyle = '#3e0d15';
    ctx.beginPath();
    ctx.ellipse(40.5, -3.2, 1.1, 0.7, 0.3, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#ffd23a';
    ctx.beginPath();
    ctx.ellipse(30, -6, 2.6, 1.8, -0.2, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#1d1724';
    ctx.beginPath();
    ctx.ellipse(30.4, -6, 0.6, 1.6, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = '#5e1520';
    ctx.lineWidth = 1.4;
    ctx.beginPath();
    ctx.moveTo(26.5, -8.6);
    ctx.lineTo(33.5, -8);
    ctx.stroke();
    // Two curved horns sweeping back.
    for (const [ox, color] of [[-2, '#cfc6b4'], [0, '#f2ede4']] as const) {
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.moveTo(26 + ox, -9);
      ctx.quadraticCurveTo(21 + ox, -14, 15 + ox, -15);
      ctx.quadraticCurveTo(21 + ox, -11, 24 + ox, -6.5);
      ctx.fill();
    }
    // Smoke puffing out of its nose as it gets ready.
    if (warn > 0) {
      for (let i = 0; i < 3; i++) {
        const age = (clock * 1.5 + i / 3) % 1;
        ctx.fillStyle = `rgba(90, 80, 90, ${0.45 * warn * (1 - age)})`;
        ctx.beginPath();
        ctx.arc(42 + age * 6, -5 - age * 12, 1.5 + age * 4, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    this.drawDragonWing(flap, '#9b2c37', '#5e1520', 0);
    ctx.restore();
  }

  /** A bat-like wing: bony arm and fingers with skin stretched between them, scalloped at the edge. */
  private drawDragonWing(flap: number, skin: string, bone: string, shift: number): void {
    const ctx = this.ctx;
    const lift = flap * 14;
    const shoulder = { x: 3 + shift, y: -7 };
    const elbow = { x: -6 + shift, y: -20 - lift * 0.6 };
    const wrist = { x: -2 + shift, y: -33 - lift };
    const tips = [
      { x: -26 + shift, y: -34 - lift * 0.9 },
      { x: -36 + shift, y: -20 - lift * 0.5 },
      { x: -30 + shift, y: -6 - lift * 0.1 },
    ];
    const gradient = ctx.createLinearGradient(wrist.x, wrist.y, shoulder.x, shoulder.y);
    gradient.addColorStop(0, skin);
    gradient.addColorStop(1, bone);
    ctx.fillStyle = gradient;
    ctx.globalAlpha = 0.95;
    ctx.beginPath();
    ctx.moveTo(shoulder.x, shoulder.y);
    ctx.lineTo(elbow.x, elbow.y);
    ctx.lineTo(wrist.x, wrist.y);
    ctx.lineTo(tips[0].x, tips[0].y);
    // Scalloped skin between the finger tips, and back to the body.
    ctx.quadraticCurveTo((tips[0].x + tips[1].x) / 2 + 5, (tips[0].y + tips[1].y) / 2 + 2, tips[1].x, tips[1].y);
    ctx.quadraticCurveTo((tips[1].x + tips[2].x) / 2 + 6, (tips[1].y + tips[2].y) / 2, tips[2].x, tips[2].y);
    ctx.quadraticCurveTo(-18 + shift, -4, -10 + shift, -5);
    ctx.closePath();
    ctx.fill();
    ctx.globalAlpha = 1;
    ctx.strokeStyle = bone;
    ctx.lineCap = 'round';
    ctx.lineWidth = 2.4;
    ctx.beginPath();
    ctx.moveTo(shoulder.x, shoulder.y);
    ctx.lineTo(elbow.x, elbow.y);
    ctx.lineTo(wrist.x, wrist.y);
    ctx.stroke();
    ctx.lineWidth = 1.2;
    for (const tip of tips) {
      ctx.beginPath();
      ctx.moveTo(wrist.x, wrist.y);
      ctx.lineTo(tip.x, tip.y);
      ctx.stroke();
    }
    // A little claw on the wing's thumb.
    ctx.fillStyle = '#f2ede4';
    ctx.beginPath();
    ctx.moveTo(wrist.x, wrist.y);
    ctx.lineTo(wrist.x + 3, wrist.y - 3);
    ctx.lineTo(wrist.x + 1.5, wrist.y + 1);
    ctx.fill();
  }

  private drawClaws(x: number, y: number): void {
    const ctx = this.ctx;
    ctx.fillStyle = '#f2ede4';
    for (const dx of [-2, 0.5, 3]) {
      ctx.beginPath();
      ctx.moveTo(x + dx - 1, y);
      ctx.lineTo(x + dx + 1.5, y + 3);
      ctx.lineTo(x + dx + 1, y - 0.5);
      ctx.fill();
    }
  }

  /**
   * A fireball: a white-hot core, flickering flame tongues streaming backwards,
   * a bright glow, sparks and a trail of smoke.
   */
  private drawFireball(ball: Fireball): void {
    const ctx = this.ctx;
    const clock = this.v.clock;
    const backAngle = Math.atan2(-ball.vy, -ball.vx);
    const seed = Math.floor(ball.vx * 7 + ball.vy * 13);
    ctx.save();
    ctx.translate(ball.x, ball.y);
    ctx.rotate(backAngle);
    // Smoke left behind, drifting and fading.
    for (let i = 1; i <= 7; i++) {
      const wobble = Math.sin(clock * 6 + i * 1.7 + seed) * (1 + i * 0.6);
      ctx.fillStyle = `rgba(70, 60, 66, ${0.22 * (1 - i / 8)})`;
      ctx.beginPath();
      ctx.arc(10 + i * 7, wobble, 2.5 + i * 0.9, 0, Math.PI * 2);
      ctx.fill();
    }
    // Soft glow all around.
    const glow = ctx.createRadialGradient(0, 0, 0, 0, 0, FIREBALL_RADIUS * 3);
    glow.addColorStop(0, 'rgba(255, 150, 40, 0.45)');
    glow.addColorStop(0.6, 'rgba(255, 90, 20, 0.15)');
    glow.addColorStop(1, 'rgba(255, 60, 10, 0)');
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(0, 0, FIREBALL_RADIUS * 3, 0, Math.PI * 2);
    ctx.fill();
    // Flame tongues licking backwards, each flickering at its own pace:
    // wide dark-red ones on the outside, then orange, then short yellow ones in the middle.
    const layers: [number, number, string, string][] = [
      [1.15, 1.25, 'rgba(214, 48, 18, 0.95)', 'rgba(140, 20, 10, 0)'],
      [0.9, 1.0, 'rgba(255, 122, 26, 1)', 'rgba(230, 60, 15, 0)'],
      [0.55, 0.6, 'rgba(255, 214, 80, 1)', 'rgba(255, 140, 30, 0)'],
    ];
    for (const [thick, reach, base, tip] of layers) {
      for (let k = 0; k < 5; k++) {
        const spread = (k - 2) * 0.26;
        const length = (12 + 8 * (0.5 + 0.5 * Math.sin(clock * 24 + k * 1.9 + seed + thick * 5))) * reach;
        const width = FIREBALL_RADIUS * thick * (0.8 - Math.abs(k - 2) * 0.12);
        const bend = Math.sin(clock * 17 + k * 2.3 + thick * 3) * 3;
        ctx.save();
        ctx.rotate(spread);
        const flame = ctx.createLinearGradient(0, 0, length + 6, 0);
        flame.addColorStop(0, base);
        flame.addColorStop(1, tip);
        ctx.fillStyle = flame;
        ctx.beginPath();
        ctx.moveTo(0, -width);
        ctx.quadraticCurveTo(length * 0.5, -width + bend, length + 6, bend * 1.5);
        ctx.quadraticCurveTo(length * 0.5, width + bend, 0, width);
        ctx.arc(0, 0, width, Math.PI / 2, Math.PI * 1.5);
        ctx.fill();
        ctx.restore();
      }
    }
    // Sparks flying off the back.
    for (let i = 0; i < 6; i++) {
      const age = (clock * 2.2 + i / 6 + (seed % 7) / 7) % 1;
      const side = Math.sin(i * 12.9 + seed) * 9 * age;
      ctx.fillStyle = `rgba(255, ${200 - age * 120}, 60, ${1 - age})`;
      ctx.beginPath();
      ctx.arc(8 + age * 34, side, 1.2 * (1 - age) + 0.4, 0, Math.PI * 2);
      ctx.fill();
    }
    // A small white-hot core.
    const core = ctx.createRadialGradient(-1, 0, 0, 0, 0, FIREBALL_RADIUS * 0.75);
    core.addColorStop(0, 'rgba(255, 255, 235, 1)');
    core.addColorStop(0.5, 'rgba(255, 238, 150, 1)');
    core.addColorStop(1, 'rgba(255, 190, 60, 0)');
    ctx.fillStyle = core;
    ctx.beginPath();
    ctx.arc(0, 0, FIREBALL_RADIUS * 0.75 * (1 + 0.1 * Math.sin(clock * 40 + seed)), 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  }

  /**
   * A fireball blowing up: a bright flash, a billowing ball of fire, a shock ring across the roof,
   * sparks flying out in arcs, smoke rising afterwards and a scorch mark left behind.
   */
  private drawExplosion(blast: Explosion): void {
    const ctx = this.ctx;
    const t = blast.age / EXPLOSION_TIME;
    const { x, y } = blast;
    const seed = Math.floor(x * 3.1);
    ctx.save();

    // Scorch mark on the roof, fading away.
    if (blast.ground) {
      ctx.fillStyle = `rgba(29, 20, 24, ${0.55 * (1 - t)})`;
      ctx.beginPath();
      ctx.ellipse(x, y + 0.5, 24, 3.5, 0, 0, Math.PI * 2);
      ctx.fill();
    }

    // Smoke billowing up once the fire dies down.
    for (let i = 0; i < 6; i++) {
      const start = 0.15 + i * 0.05;
      if (t < start) continue;
      const s = (t - start) / (1 - start);
      const drift = (noise(seed + i) - 0.5) * 30;
      ctx.fillStyle = `rgba(70, 62, 68, ${0.45 * (1 - s)})`;
      ctx.beginPath();
      ctx.arc(x + drift * s, y - 12 - s * 46 - i * 4, 7 + s * 15, 0, Math.PI * 2);
      ctx.fill();
    }

    // Ball of fire: blobs that puff outwards and upwards, from yellow to orange to dark red.
    if (t < 0.6) {
      const f = t / 0.6;
      for (let i = 0; i < 9; i++) {
        const angle = Math.PI + (i / 8) * Math.PI + (noise(seed + i * 7) - 0.5) * 0.4;
        const reach = (12 + noise(seed + i) * 20) * Math.sqrt(f);
        const bx = x + Math.cos(angle) * reach;
        const by = (blast.ground ? y - 6 : y) + Math.sin(angle) * reach - f * 12;
        const radius = (9 + noise(seed + i * 3) * 7) * (1 - f * 0.45);
        const fire = ctx.createRadialGradient(bx, by, 0, bx, by, radius);
        fire.addColorStop(0, `rgba(255, 236, 150, ${1 - f})`);
        fire.addColorStop(0.45, `rgba(255, 130, 30, ${1 - f})`);
        fire.addColorStop(0.8, `rgba(200, 45, 15, ${0.7 * (1 - f)})`);
        fire.addColorStop(1, `rgba(180, 30, 10, 0)`);
        ctx.fillStyle = fire;
        ctx.beginPath();
        ctx.arc(bx, by, radius, 0, Math.PI * 2);
        ctx.fill();
      }
    }

    // Shock ring racing out along the roof.
    if (blast.ground && t < 0.5) {
      const r = t / 0.5;
      ctx.strokeStyle = `rgba(255, 210, 120, ${0.8 * (1 - r)})`;
      ctx.lineWidth = 2.5 * (1 - r) + 0.5;
      ctx.beginPath();
      ctx.ellipse(x, y, 8 + r * 58, 2.5 + r * 6, 0, Math.PI, Math.PI * 2);
      ctx.stroke();
    }

    // Sparks and burning bits flying out in arcs.
    for (let i = 0; i < 12; i++) {
      const angle = Math.PI + 0.15 + (i / 11) * (Math.PI - 0.3) + (noise(seed + i * 5) - 0.5) * 0.3;
      const speed = 100 + noise(seed + i * 11) * 130;
      const age = blast.age;
      const px = x + Math.cos(angle) * speed * age;
      const py = y + Math.sin(angle) * speed * age + 260 * age * age;
      if (blast.ground && py > y) continue;
      const life = 1 - t;
      ctx.fillStyle = `rgba(255, ${170 + noise(seed + i) * 80}, 60, ${life})`;
      ctx.beginPath();
      ctx.arc(px, py, 2 * life + 0.5, 0, Math.PI * 2);
      ctx.fill();
    }

    // The bright flash, right at the start.
    if (t < 0.25) {
      const f = t / 0.25;
      const flash = ctx.createRadialGradient(x, y - 3, 0, x, y - 3, 14 + f * 42);
      flash.addColorStop(0, `rgba(255, 255, 235, ${1 - f})`);
      flash.addColorStop(0.4, `rgba(255, 220, 120, ${0.7 * (1 - f)})`);
      flash.addColorStop(1, 'rgba(255, 150, 40, 0)');
      ctx.fillStyle = flash;
      ctx.beginPath();
      ctx.arc(x, y - 3, 14 + f * 42, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.restore();
  }

  /**
   * A spring: a metal coil between a base plate and a red-and-yellow pad.
   * When Rog bounces on it, it shoots up and then wobbles back down.
   */
  private drawSpring(spring: Spring): void {
    const ctx = this.ctx;
    const bounce = this.v.springBounce;
    const since = bounce && bounce.x === spring.x ? this.v.clock - bounce.at : Infinity;
    // Normally squashed a little; just after a bounce it stretches tall, then wobbles back.
    const stretch = since < 0.6 ? Math.exp(-since * 7) * Math.cos(since * 30) : 0;
    const height = SPRING_HEIGHT - 4 + Math.max(-5, stretch * 18);
    const { x, roofY } = spring;
    const w = SPRING_WIDTH;
    // Base plate.
    ctx.fillStyle = '#3a3044';
    ctx.fillRect(x, roofY - 2, w, 2);
    // Zig-zag coil.
    ctx.strokeStyle = '#9aa3ad';
    ctx.lineWidth = 2.4;
    ctx.beginPath();
    const turns = 5;
    for (let i = 0; i <= turns * 2; i++) {
      const px = x + 4 + (i % 2) * (w - 8);
      const py = roofY - 2 - (i / (turns * 2)) * (height - 2);
      if (i === 0) ctx.moveTo(px, py);
      else ctx.lineTo(px, py);
    }
    ctx.stroke();
    // Pad on top: red with a yellow stripe, so it stands out.
    const top = roofY - height - 4;
    ctx.fillStyle = '#e5484d';
    ctx.fillRect(x - 2, top, w + 4, 5);
    ctx.fillStyle = '#ffcc33';
    ctx.fillRect(x + 4, top + 1.5, w - 8, 2);
  }

  /** A row of sharp metal spikes on a dark base plate. */
  private drawSpikes(patch: Spikes): void {
    const ctx = this.ctx;
    const count = Math.max(2, Math.round(patch.w / 7));
    const width = patch.w / count;
    ctx.fillStyle = '#3a3044';
    ctx.fillRect(patch.x, patch.roofY - 2, patch.w, 2);
    for (let i = 0; i < count; i++) {
      const left = patch.x + i * width;
      // Each spike is lit on one side and shaded on the other.
      ctx.fillStyle = '#c9d1d9';
      ctx.beginPath();
      ctx.moveTo(left, patch.roofY - 2);
      ctx.lineTo(left + width / 2, patch.roofY - SPIKE_HEIGHT);
      ctx.lineTo(left + width / 2, patch.roofY - 2);
      ctx.fill();
      ctx.fillStyle = '#7d8793';
      ctx.beginPath();
      ctx.moveTo(left + width / 2, patch.roofY - SPIKE_HEIGHT);
      ctx.lineTo(left + width, patch.roofY - 2);
      ctx.lineTo(left + width / 2, patch.roofY - 2);
      ctx.fill();
    }
  }

  /** The dark slot in the roof that a saw slides along. */
  private drawSawSlot(saw: Saw): void {
    const ctx = this.ctx;
    ctx.fillStyle = '#1d1724';
    ctx.fillRect(saw.x1 - SAW_RADIUS, saw.roofY, saw.x2 - saw.x1 + SAW_RADIUS * 2, 3);
  }

  private drawIce(patch: Ice): void {
    const ctx = this.ctx;
    if (patch.x > this.cameraX + VIEW_W || patch.x + patch.w + 10 < this.cameraX) return;
    const left = patch.x;
    const right = patch.x + patch.w + 3;
    const top = patch.y - 3;
    const bottom = patch.y + 6;

    // Icicles hanging under the roof ledge, longer near the edge where the water dripped.
    for (let ix = left + 4; ix < right - 2; ix += 3 + noise(ix * 0.37) * 6) {
      const nearEdge = (ix - left) / (right - left);
      const length = 3 + noise(ix * 0.71) * 9 * (0.4 + nearEdge);
      const icicle = ctx.createLinearGradient(0, bottom, 0, bottom + length);
      icicle.addColorStop(0, 'rgba(225, 245, 255, 0.95)');
      icicle.addColorStop(1, 'rgba(160, 215, 245, 0.35)');
      ctx.fillStyle = icicle;
      ctx.beginPath();
      ctx.moveTo(ix - 2, bottom);
      ctx.lineTo(ix + 2, bottom);
      ctx.lineTo(ix + 0.3, bottom + length);
      ctx.closePath();
      ctx.fill();
    }

    // The ice slab: clear and pale on top, deeper blue underneath, with the roof showing through.
    const slab = ctx.createLinearGradient(0, top, 0, bottom);
    slab.addColorStop(0, 'rgba(240, 252, 255, 0.95)');
    slab.addColorStop(0.35, 'rgba(180, 228, 250, 0.8)');
    slab.addColorStop(1, 'rgba(95, 160, 210, 0.75)');
    ctx.fillStyle = slab;
    ctx.beginPath();
    // Lumpy left end where the ice starts, like a frozen puddle.
    ctx.moveTo(left + 6, top);
    ctx.lineTo(right, top);
    ctx.lineTo(right, bottom);
    ctx.lineTo(left, bottom);
    ctx.quadraticCurveTo(left - 3, top + 3, left + 6, top);
    ctx.fill();

    // Bright edge along the top where light hits it.
    ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
    ctx.fillRect(left + 5, top, patch.w - 2, 1);

    // Cracks frozen inside the ice.
    ctx.strokeStyle = 'rgba(70, 130, 180, 0.55)';
    ctx.lineWidth = 0.6;
    for (let cx = left + 12; cx < right - 10; cx += 23) {
      const wiggle = noise(cx * 1.3) * 6;
      ctx.beginPath();
      ctx.moveTo(cx, top + 1);
      ctx.lineTo(cx + 3 + wiggle * 0.5, top + 4);
      ctx.lineTo(cx + 1 + wiggle, bottom - 1);
      ctx.moveTo(cx + 3 + wiggle * 0.5, top + 4);
      ctx.lineTo(cx + 8, top + 5);
      ctx.stroke();
    }

    // A glare of light that slowly slides across the ice.
    const glareX = left + ((this.v.clock * 25) % (patch.w + 40)) - 20;
    ctx.save();
    ctx.beginPath();
    ctx.rect(left, top, right - left, bottom - top);
    ctx.clip();
    ctx.fillStyle = 'rgba(255, 255, 255, 0.55)';
    ctx.beginPath();
    ctx.moveTo(glareX, bottom);
    ctx.lineTo(glareX + 6, top);
    ctx.lineTo(glareX + 10, top);
    ctx.lineTo(glareX + 4, bottom);
    ctx.fill();
    ctx.restore();

    // Little star sparkles that twinkle on and off.
    ctx.fillStyle = '#ffffff';
    for (let sx = left + 9; sx < right - 4; sx += 17) {
      const twinkle = Math.sin(this.v.clock * 2.5 + noise(sx) * 10);
      if (twinkle < 0.6) continue;
      const size = (twinkle - 0.6) * 6;
      const sy = top + 1 + noise(sx * 2.1) * 3;
      ctx.fillRect(sx - size, sy - 0.4, size * 2, 0.8);
      ctx.fillRect(sx - 0.4, sy - size, 0.8, size * 2);
    }

    this.drawEdgeStripe(patch.x + patch.w - 9, patch.y);
  }

  private drawClothesline(state: LineState): void {
    const ctx = this.ctx;
    const { line } = state;
    if (line.x1 > this.cameraX + VIEW_W || line.x2 < this.cameraX) return;

    // Metal hooks on the walls.
    ctx.fillStyle = '#9aa3ad';
    ctx.fillRect(line.x1 - 4, line.y1 - 2, 5, 4);
    ctx.fillRect(line.x2 - 1, line.y2 - 2, 5, 4);

    ctx.strokeStyle = '#f2ede4';
    ctx.lineWidth = 1.4;
    ctx.lineCap = 'round';

    if (state.snappedAt === null) {
      // Once someone is on it, it shakes like it's about to give.
      const shake = state.held === null ? 0 : Math.sin(this.v.clock * 70) * (0.4 + state.held * 2);
      const pointAt = (t: number) => {
        const x = line.x1 + (line.x2 - line.x1) * t;
        return { x, y: lineHeightAt(line, x) + shake * Math.sin(Math.PI * t) };
      };
      ctx.beginPath();
      for (let i = 0; i <= 24; i++) {
        const p = pointAt(i / 24);
        if (i === 0) ctx.moveTo(p.x, p.y);
        else ctx.lineTo(p.x, p.y);
      }
      ctx.stroke();
      for (const [t, kind, color] of CLOTHES) {
        const p = pointAt(t);
        this.drawClothing(kind, color, p.x, p.y);
      }
      return;
    }

    // Snapped: each half swings down and hangs from its hook, and the clothes fall.
    const age = this.v.clock - state.snappedAt;
    const drop = Math.min(1, age * 4);
    const swing = Math.sin(age * 6) * Math.exp(-age * 1.5) * 8;
    const halfLength = Math.min(70, (line.x2 - line.x1) / 2);
    const halves: [number, number, number][] = [
      [line.x1, line.y1, 1],
      [line.x2, line.y2, -1],
    ];
    for (const [hx, hy, dir] of halves) {
      const endX = hx + dir * halfLength * (1 - drop) + swing * dir;
      const endY = hy + halfLength * drop;
      ctx.beginPath();
      ctx.moveTo(hx, hy);
      ctx.quadraticCurveTo(hx + dir * 6, (hy + endY) / 2, endX, endY);
      ctx.stroke();
    }
    const fall = 0.5 * 900 * age * age;
    for (const [t, kind, color] of CLOTHES) {
      const x = line.x1 + (line.x2 - line.x1) * t + Math.sin(age * 5 + t * 9) * 6 * age;
      const y = lineHeightAt(line, x) + fall;
      if (y < VIEW_H + 20) this.drawClothing(kind, color, x, y);
    }
  }

  /** A piece of laundry hanging with its top at (x, y). */
  private drawClothing(kind: 'shirt' | 'sock' | 'pants', color: string, x: number, y: number): void {
    const ctx = this.ctx;
    ctx.fillStyle = color;
    ctx.strokeStyle = 'rgba(29, 23, 36, 0.5)';
    ctx.lineWidth = 0.8;
    ctx.beginPath();
    if (kind === 'shirt') {
      ctx.moveTo(x - 6, y);
      ctx.lineTo(x + 6, y);
      ctx.lineTo(x + 10, y + 5);
      ctx.lineTo(x + 6, y + 6);
      ctx.lineTo(x + 6, y + 15);
      ctx.lineTo(x - 6, y + 15);
      ctx.lineTo(x - 6, y + 6);
      ctx.lineTo(x - 10, y + 5);
    } else if (kind === 'pants') {
      ctx.moveTo(x - 6, y);
      ctx.lineTo(x + 6, y);
      ctx.lineTo(x + 7, y + 17);
      ctx.lineTo(x + 2, y + 17);
      ctx.lineTo(x, y + 6);
      ctx.lineTo(x - 2, y + 17);
      ctx.lineTo(x - 7, y + 17);
    } else {
      ctx.moveTo(x - 2, y);
      ctx.lineTo(x + 2, y);
      ctx.lineTo(x + 2, y + 8);
      ctx.lineTo(x + 5, y + 10);
      ctx.lineTo(x + 4, y + 12);
      ctx.lineTo(x - 2, y + 11);
    }
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    // Clothes peg.
    ctx.fillStyle = '#c98b4a';
    ctx.fillRect(x - 0.8, y - 2.5, 1.6, 4);
  }

  private drawEdgeStripe(x: number, y: number): void {
    const ctx = this.ctx;
    ctx.fillStyle = '#ffcc33';
    ctx.fillRect(x, y, 12, 6);
    ctx.fillStyle = '#2b2233';
    ctx.fillRect(x + 4, y, 4, 6);
  }

  private drawFinish(): void {
    const ctx = this.ctx;
    const flagX = this.v.level.finishX;
    const ground = roofHeightAt(this.v.level.blocks, flagX);
    const top = ground - 70;
    const wave = Math.sin(this.v.clock * 6) * 2;
    if (this.v.level.flag === 'stairs') {
      // A little stairwell hut with an open dark doorway and a sign pointing down.
      ctx.fillStyle = '#6e6280';
      ctx.fillRect(flagX - 4, ground - 40, 40, 40);
      ctx.fillStyle = '#4a3f55';
      ctx.fillRect(flagX - 7, ground - 44, 46, 5);
      ctx.fillStyle = '#1d1724';
      ctx.fillRect(flagX + 2, ground - 32, 22, 32);
      // Top steps going down inside the doorway.
      ctx.fillStyle = '#8a7a90';
      for (let i = 0; i < 3; i++) ctx.fillRect(flagX + 2 + i * 7, ground - 9 + i * 3, 22 - i * 7, 2);
      ctx.fillStyle = '#ffcc33';
      ctx.fillRect(flagX - 6, top + 6, 50, 15);
      ctx.fillStyle = '#1d1724';
      ctx.font = 'bold 9px system-ui, sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText('↓ STAIRS', flagX + 19, top + 17);
    } else if (this.v.level.flag === 'checkered') {
      ctx.fillStyle = '#2b2233';
      ctx.fillRect(flagX - 2, top, 4, 70);
      // Checkered flag that waves a little.
      for (let row = 0; row < 3; row++) {
        for (let col = 0; col < 5; col++) {
          ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#1d1724';
          ctx.fillRect(flagX + 2 + col * 6, top + row * 6 + (col / 4) * wave, 6, 6);
        }
      }
    } else {
      // Level 2's flag: a big gold flag with a star on a shiny pole.
      ctx.fillStyle = '#f2ede4';
      ctx.fillRect(flagX - 2, top - 6, 4, 76);
      ctx.fillStyle = '#ffcc33';
      ctx.beginPath();
      ctx.arc(flagX, top - 7, 3.5, 0, Math.PI * 2);
      ctx.fill();
      ctx.fillStyle = '#ffb000';
      ctx.strokeStyle = '#a86a00';
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(flagX + 2, top);
      ctx.quadraticCurveTo(flagX + 18, top - 3 + wave, flagX + 36, top + 2 + wave);
      ctx.lineTo(flagX + 36, top + 22 + wave);
      ctx.quadraticCurveTo(flagX + 18, top + 17 + wave, flagX + 2, top + 22);
      ctx.closePath();
      ctx.fill();
      ctx.stroke();
      this.drawStar(flagX + 19, top + 11 + wave * 0.6, 6);
      // Sparkles around it.
      ctx.fillStyle = '#ffffff';
      for (let i = 0; i < 3; i++) {
        const twinkle = Math.sin(this.v.clock * 4 + i * 2.1);
        if (twinkle < 0.3) continue;
        const sx = flagX + 8 + i * 14;
        const sy = top - 8 + (i % 2) * 34;
        const size = twinkle * 2.5;
        ctx.fillRect(sx - size, sy - 0.4, size * 2, 0.8);
        ctx.fillRect(sx - 0.4, sy - size, 0.8, size * 2);
      }
    }
    ctx.fillStyle = '#ffffff';
    ctx.font = 'bold 10px system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(this.v.level.name.toUpperCase(), flagX, top - 14);
  }

  private drawStar(x: number, y: number, radius: number): void {
    const ctx = this.ctx;
    ctx.fillStyle = '#fff6d6';
    ctx.beginPath();
    for (let i = 0; i < 10; i++) {
      const r = i % 2 === 0 ? radius : radius * 0.45;
      const angle = -Math.PI / 2 + (i * Math.PI) / 5;
      ctx.lineTo(x + Math.cos(angle) * r, y + Math.sin(angle) * r);
    }
    ctx.closePath();
    ctx.fill();
  }
}

/** A repeatable "random" number from 0 to 1, so the city looks the same every time. */
function noise(seed: number): number {
  const value = Math.sin(seed * 12.9898) * 43758.5453;
  return value - Math.floor(value);
}

// ---------- Rog, drawn flat ----------

const INK_2D = '#1d1724';
const BANDANA = '#2fa84f';
const BANDANA_DARK = '#1e7a37';

/**
 * Draw the stickman with his feet at (x, y).
 * `cycle` moves the legs and arms while running, or is how far through a roll he is (0 to 1).
 */
export function drawStickman(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  facing: number,
  pose: Pose,
  cycle: number,
  vy: number,
): void {
  ctx.save();
  ctx.translate(x, y);
  ctx.scale(facing, 1);
  ctx.strokeStyle = INK_2D;
  ctx.fillStyle = INK_2D;
  ctx.lineWidth = 2.6;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';

  if (pose === 'roll') {
    drawRoll(ctx, cycle);
    ctx.restore();
    return;
  }

  const hip = { x: 0, y: -16 };
  let lean = 0;
  let legs: [number, number][] = [];
  let arms: [number, number][] = [];

  if (pose === 'run') {
    const swing = Math.sin(cycle);
    lean = 3;
    legs = [limb(hip, 0.2 + swing * 0.8, 16), limb(hip, 0.2 - swing * 0.8, 16)];
    arms = [[-swing * 7, 7], [swing * 7, 7]];
  } else if (pose === 'jump') {
    const rising = vy < 0;
    lean = 2;
    legs = rising ? [[7, -6], [-5, -2]] : [[5, -1], [-4, 0]];
    arms = rising ? [[6, -8], [-6, -6]] : [[8, -2], [-8, -3]];
  } else if (pose === 'slide') {
    // Surfing on the ice: feet apart, leaning back, arms out for balance.
    const wobble = Math.sin(cycle * 3) * 2;
    lean = -3;
    legs = [[9, 0], [-7, 0]];
    arms = [[10, -4 + wobble], [-10, -6 - wobble]];
  } else if (pose === 'fall') {
    legs = [[-7, -4], [7, -2]];
    arms = [[-9, -12], [9, -12]];
  } else if (pose === 'cheer') {
    const bounce = Math.abs(Math.sin(cycle)) * 3;
    ctx.translate(0, -bounce);
    legs = [[-5, 0], [5, 0]];
    arms = [[-7, -14], [7, -14]];
  } else {
    legs = [[-4, 0], [4, 0]];
    arms = [[-4, 9], [4, 9]];
  }

  const shoulder = { x: lean, y: -29 };
  // Body.
  line(ctx, hip.x, hip.y, shoulder.x, shoulder.y);
  // Head, with Rog's green bandana.
  const headX = shoulder.x + lean * 0.4;
  const headY = shoulder.y - 6;
  ctx.beginPath();
  ctx.arc(headX, headY, 5, 0, Math.PI * 2);
  ctx.fill();
  drawBandana(ctx, headX, headY, 5, pose !== 'stand');
  // Legs (knee halfway, pushed forward a little).
  for (const [fx, fy] of legs) {
    const kx = (hip.x + fx) / 2 + 3;
    const ky = (hip.y + fy) / 2;
    polyline(ctx, [hip.x, hip.y, kx, ky, fx, fy]);
  }
  // Arms.
  for (const [hx, hy] of arms) {
    const ex = shoulder.x + hx * 0.5 + (pose === 'run' ? -2 : 0);
    const ey = shoulder.y + hy * 0.5 + 2;
    polyline(ctx, [shoulder.x, shoulder.y + 1, ex, ey, shoulder.x + hx, shoulder.y + hy]);
  }
  ctx.restore();
}

/** Curled up in a ball, spinning forward once. */
function drawRoll(ctx: CanvasRenderingContext2D, progress: number): void {
  // Squash down at the start and stand back up at the end.
  const tuck = Math.sin(progress * Math.PI);
  ctx.translate(0, -10 - (1 - tuck) * 4);
  ctx.rotate(progress * Math.PI * 2);
  // Curved back.
  ctx.beginPath();
  ctx.arc(0, 0, 8, Math.PI * 0.75, Math.PI * 2.1);
  ctx.stroke();
  // Head tucked in.
  ctx.beginPath();
  ctx.arc(6, -5, 4.5, 0, Math.PI * 2);
  ctx.fill();
  drawBandana(ctx, 6, -5, 4.5, true);
  // Knees pulled up, arms hugging them.
  polyline(ctx, [-6, 5, 2, -1, 6, 6]);
  polyline(ctx, [-5, 6, 4, 3, 7, 8]);
  polyline(ctx, [3, -4, 4, 2, 8, 1]);
}

/** Rog's green bandana: a band around his forehead, tied at the back with two tails that flap when he moves. */
function drawBandana(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, moving: boolean): void {
  ctx.save();
  // The band, wrapped around the top of the head.
  ctx.beginPath();
  ctx.arc(x, y, r + 0.3, 0, Math.PI * 2);
  ctx.clip();
  ctx.fillStyle = BANDANA;
  ctx.fillRect(x - r - 1, y - r * 0.8, r * 2 + 2, r * 0.65);
  ctx.restore();

  // The knot at the back of the head.
  const knotX = x - r * 0.9;
  const knotY = y - r * 0.45;
  ctx.fillStyle = BANDANA_DARK;
  ctx.beginPath();
  ctx.arc(knotX, knotY, 1.3, 0, Math.PI * 2);
  ctx.fill();

  // Two tails: streaming out behind when moving, hanging down when standing still.
  const flap = Math.sin(performance.now() / 70);
  const tails: [number, number][] = moving
    ? [
        [knotX - 7, knotY - 1 + flap * 1.5],
        [knotX - 6, knotY + 2 - flap * 1.5],
      ]
    : [
        [knotX - 2, knotY + 6],
        [knotX - 0.5, knotY + 7],
      ];
  ctx.strokeStyle = BANDANA;
  ctx.lineWidth = 1.6;
  for (const [tx, ty] of tails) {
    ctx.beginPath();
    ctx.moveTo(knotX, knotY);
    ctx.quadraticCurveTo((knotX + tx) / 2, (knotY + ty) / 2 + (moving ? flap : 0), tx, ty);
    ctx.stroke();
  }
  ctx.strokeStyle = INK_2D;
  ctx.lineWidth = 2.6;
}

function limb(from: { x: number; y: number }, angle: number, length: number): [number, number] {
  return [from.x + Math.sin(angle) * length, Math.min(0, from.y + Math.cos(angle) * length)];
}

function line(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number): void {
  ctx.beginPath();
  ctx.moveTo(x1, y1);
  ctx.lineTo(x2, y2);
  ctx.stroke();
}

function polyline(ctx: CanvasRenderingContext2D, points: number[]): void {
  ctx.beginPath();
  ctx.moveTo(points[0], points[1]);
  for (let i = 2; i < points.length; i += 2) ctx.lineTo(points[i], points[i + 1]);
  ctx.stroke();
}