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/boss-scene.ts
import {
  ARENA_H,
  ARENA_W,
  FLOOR,
  PUNCH_TIME,
  ROG_MAX_HP,
  SPIDER_MAX_HP,
  STOMP_SLAM,
  STOMP_WINDUP,
  GOO_RADIUS,
  GOO_WINDUP,
  LEG_SPREAD,
  LAUNCH_WINDUP,
  LITTLE_CROUCH,
  LITTLE_DEATH,
  LITTLE_MAX_HP,
  spiderSpinneret,
  SPLAT_TIME,
  type BossFight,
} from './boss.js';
import { drawStickman } from './render2d.js';

/**
 * Draws the boss fight: a dark cave with stalactites, webs and glowing mushrooms,
 * a giant hairy spider, Rog, health bars and the red warning circle before a stomp.
 */


function noise(seed: number): number {
  const value = Math.sin(seed * 12.9898) * 43758.5453;
  return value - Math.floor(value);
}

const ease = (t: number) => t * t * (3 - 2 * t);

export function drawBossScene(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  ctx.save();
  // The whole cave shakes after a big stomp.
  if (fight.shake > 0) {
    const s = fight.shake * 4;
    ctx.translate((noise(fight.clock * 60) - 0.5) * s, (noise(fight.clock * 60 + 3) - 0.5) * s);
  }
  drawCave(ctx, fight.clock);
  drawSplats(ctx, fight, false);
  drawSpider(ctx, fight);
  drawLittle(ctx, fight);
  drawRog(ctx, fight);
  drawSplats(ctx, fight, true);
  drawGoo(ctx, fight);
  drawDust(ctx, fight);
  for (const pop of fight.pops) {
    ctx.globalAlpha = 1 - pop.age / 0.9;
    ctx.font = 'bold 14px system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#1d1724';
    ctx.strokeText(pop.text, pop.x, pop.y - pop.age * 26);
    ctx.fillStyle = pop.color;
    ctx.fillText(pop.text, pop.x, pop.y - pop.age * 26);
  }
  ctx.globalAlpha = 1;
  ctx.restore();
  drawBars(ctx, fight);
  drawMessages(ctx, fight);
}

function drawCave(ctx: CanvasRenderingContext2D, clock: number): void {
  const air = ctx.createLinearGradient(0, 0, 0, ARENA_H);
  air.addColorStop(0, '#2e2333');
  air.addColorStop(1, '#4a3842');
  ctx.fillStyle = air;
  ctx.fillRect(0, 0, ARENA_W, ARENA_H);
  // Bumpy rock wall in the background.
  for (let i = 0; i < 26; i++) {
    ctx.fillStyle = `rgba(70, 52, 60, ${0.25 + noise(i) * 0.3})`;
    ctx.beginPath();
    ctx.ellipse(noise(i * 3.1) * ARENA_W, 40 + noise(i * 5.7) * 170, 20 + noise(i * 2) * 40, 12 + noise(i * 4) * 22, noise(i * 7), 0, Math.PI * 2);
    ctx.fill();
  }
  // Stalactites hanging from the ceiling.
  ctx.fillStyle = '#3b2c33';
  for (let i = 0; i < 14; i++) {
    const x = 10 + i * 35 + noise(i * 9) * 15;
    const length = 14 + noise(i * 13) * 30;
    ctx.beginPath();
    ctx.moveTo(x - 9, 0);
    ctx.lineTo(x + 9, 0);
    ctx.lineTo(x, length);
    ctx.fill();
  }
  // Spider webs in the top corners.
  ctx.strokeStyle = 'rgba(230, 225, 235, 0.35)';
  ctx.lineWidth = 0.8;
  for (const [cx, dir] of [[0, 1], [ARENA_W, -1]] as const) {
    for (let a = 0; a <= 4; a++) {
      const angle = (a / 4) * (Math.PI / 2);
      ctx.beginPath();
      ctx.moveTo(cx, 0);
      ctx.lineTo(cx + dir * Math.cos(angle) * 70, Math.sin(angle) * 70);
      ctx.stroke();
    }
    for (let r = 15; r <= 70; r += 14) {
      ctx.beginPath();
      for (let a = 0; a <= 4; a++) {
        const angle = (a / 4) * (Math.PI / 2);
        const px = cx + dir * Math.cos(angle) * r;
        const py = Math.sin(angle) * r;
        if (a === 0) ctx.moveTo(px, py);
        else ctx.quadraticCurveTo(cx + dir * Math.cos(angle - 0.2) * r * 0.85, Math.sin(angle - 0.2) * r * 0.85, px, py);
      }
      ctx.stroke();
    }
  }
  // Rocky side walls.
  ctx.fillStyle = '#2f2228';
  ctx.fillRect(0, 0, 18, ARENA_H);
  ctx.fillRect(ARENA_W - 18, 0, 18, ARENA_H);
  // The floor, with pebbles.
  ctx.fillStyle = '#58454e';
  ctx.fillRect(0, FLOOR, ARENA_W, ARENA_H - FLOOR);
  ctx.fillStyle = '#6e5862';
  ctx.fillRect(0, FLOOR, ARENA_W, 3);
  for (let i = 0; i < 30; i++) {
    ctx.fillStyle = '#56434c';
    ctx.beginPath();
    ctx.ellipse(noise(i * 1.7) * ARENA_W, FLOOR + 8 + noise(i * 2.9) * 24, 2 + noise(i) * 4, 1.5 + noise(i * 3) * 2, 0, 0, Math.PI * 2);
    ctx.fill();
  }
  // Glowing mushrooms by the walls.
  for (const [x, size] of [[34, 1], [52, 0.7], [430, 0.9], [448, 1.2]] as const) {
    const pulse = 0.6 + 0.4 * Math.sin(clock * 2 + x);
    const glow = ctx.createRadialGradient(x, FLOOR - 8 * size, 0, x, FLOOR - 8 * size, 26 * size);
    glow.addColorStop(0, `rgba(120, 255, 200, ${0.35 * pulse})`);
    glow.addColorStop(1, 'rgba(120, 255, 200, 0)');
    ctx.fillStyle = glow;
    ctx.fillRect(x - 30, FLOOR - 40, 60, 40);
    ctx.fillStyle = '#d8f5e9';
    ctx.fillRect(x - 1.5 * size, FLOOR - 8 * size, 3 * size, 8 * size);
    ctx.fillStyle = '#5fe6b4';
    ctx.beginPath();
    ctx.ellipse(x, FLOOR - 8 * size, 7 * size, 4 * size, 0, Math.PI, Math.PI * 2);
    ctx.fill();
  }
}

/** Short hairs sticking out along a line, so legs and body look hairy. */
function hairs(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, count: number, length: number, seed: number): void {
  const dx = x2 - x1;
  const dy = y2 - y1;
  const len = Math.hypot(dx, dy) || 1;
  const nx = -dy / len;
  const ny = dx / len;
  ctx.beginPath();
  for (let i = 0; i < count; i++) {
    const t = (i + 0.5) / count;
    const px = x1 + dx * t;
    const py = y1 + dy * t;
    const side = i % 2 === 0 ? 1 : -1;
    const l = length * (0.6 + noise(seed + i) * 0.7);
    ctx.moveTo(px, py);
    ctx.lineTo(px + nx * side * l + dx / len * l * 0.4, py + ny * side * l + dy / len * l * 0.4);
  }
  ctx.stroke();
}

function drawSpider(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  const { spider } = fight;
  const f = spider.facing;
  const dead = fight.result === 'won' ? ease(Math.min(1, spider.deadTime / 0.8)) : 0;
  const breathe = Math.sin(fight.clock * 2.5) * 2;
  // The body bobs a little as the legs step.
  const stepping = spider.steps.reduce((bob, step) => (step ? Math.max(bob, Math.sin(step.t * Math.PI)) : bob), 0);
  const bodyY = 168 + breathe - stepping * 2.5 + dead * (FLOOR - 40 - 168);
  const x = spider.x;

  // Legs: hip on the body, knee up high, foot planted on the floor (or lifted mid-step).
  const legs = LEG_SPREAD.map((_spread, k) => {
    const hip = { x: x + f * 22 + (k - 3.5) * 6, y: bodyY + 8 };
    const step = spider.steps[k];
    let foot = step
      ? { x: step.from + (step.to - step.from) * ease(Math.min(1, step.t)), y: FLOOR - Math.sin(Math.min(1, step.t) * Math.PI) * 12 }
      : { x: spider.feet[k], y: FLOOR };
    if (k === spider.stomper) {
      // The stomping leg flicks up over Rog and slams down.
      const high = { x: spider.target, y: FLOOR - 110 };
      if (spider.state === 'windup') {
        const t = ease(Math.min(1, spider.stateTime / STOMP_WINDUP));
        foot = { x: foot.x + (high.x - foot.x) * t, y: foot.y + (high.y - foot.y) * t };
      } else if (spider.state === 'slam') {
        const t = Math.min(1, spider.stateTime / STOMP_SLAM);
        foot = { x: high.x, y: high.y + (FLOOR - high.y) * t * t };
      } else {
        foot = { x: spider.target, y: FLOOR };
      }
    }
    if (dead > 0) {
      // Legs curl up when it's beaten.
      foot = { x: foot.x + (hip.x + (foot.x - hip.x) * 0.25 - foot.x) * dead, y: foot.y + (bodyY - 45 - foot.y) * dead };
    }
    const reach = Math.abs(foot.x - hip.x);
    const knee = {
      x: (hip.x + foot.x) / 2 + (foot.x - hip.x) * 0.12,
      y: Math.min(hip.y, foot.y) - 55 - reach * 0.1 + dead * 30,
    };
    return { hip, knee, foot };
  });

  const furDark = '#24150d';
  const fur = '#3d2616';
  // Legs on the far side first (darker), then the body, then the near legs.
  const drawLeg = (leg: (typeof legs)[number], k: number, near: boolean) => {
    ctx.strokeStyle = near ? fur : furDark;
    ctx.lineCap = 'round';
    ctx.lineWidth = 7;
    ctx.beginPath();
    ctx.moveTo(leg.hip.x, leg.hip.y);
    ctx.lineTo(leg.knee.x, leg.knee.y);
    ctx.stroke();
    ctx.lineWidth = 4.5;
    ctx.beginPath();
    ctx.moveTo(leg.knee.x, leg.knee.y);
    ctx.lineTo(leg.foot.x, leg.foot.y);
    ctx.stroke();
    // Orange bands at the knees, like a tarantula.
    ctx.fillStyle = '#b8642a';
    ctx.beginPath();
    ctx.arc(leg.knee.x, leg.knee.y, 4, 0, Math.PI * 2);
    ctx.fill();
    ctx.strokeStyle = near ? '#5a3a24' : '#3a2416';
    ctx.lineWidth = 1;
    hairs(ctx, leg.hip.x, leg.hip.y, leg.knee.x, leg.knee.y, 10, 5, k * 31);
    hairs(ctx, leg.knee.x, leg.knee.y, leg.foot.x, leg.foot.y, 12, 4, k * 47);
  };
  legs.forEach((leg, k) => k % 2 === 1 && drawLeg(leg, k, false));

  const flash = spider.hurt > 0;
  ctx.save();
  if (dead > 0) {
    ctx.translate(x, bodyY);
    ctx.rotate(dead * Math.PI * f * 0.08);
    ctx.translate(-x, -bodyY);
  }
  // Big hairy abdomen behind, with orange markings. It lifts and wiggles before launching a little spider.
  const lift = spider.state === 'launch' ? ease(Math.min(1, spider.stateTime / LAUNCH_WINDUP)) : 0;
  const ax = x - f * (42 + lift * 8) + Math.sin(fight.clock * 30) * lift * 1.5;
  const ay = bodyY - 10 - lift * 14;
  const abdomen = ctx.createRadialGradient(ax - f * 10, ay - 18, 4, ax, ay, 64);
  abdomen.addColorStop(0, '#5a3a24');
  abdomen.addColorStop(1, '#1e120c');
  ctx.fillStyle = abdomen;
  ctx.beginPath();
  ctx.ellipse(ax, ay, 62, 46, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = 'rgba(184, 100, 42, 0.7)';
  for (let i = 0; i < 3; i++) {
    ctx.beginPath();
    ctx.moveTo(ax - f * (24 - i * 16), ay - 30 + i * 4);
    ctx.lineTo(ax - f * (14 - i * 16), ay - 18 + i * 4);
    ctx.lineTo(ax - f * (4 - i * 16), ay - 30 + i * 4);
    ctx.fill();
  }
  // Hair all around the outside.
  ctx.strokeStyle = '#2e1c11';
  ctx.lineWidth = 1.2;
  ctx.beginPath();
  for (let i = 0; i < 90; i++) {
    const a = (i / 90) * Math.PI * 2;
    const px = ax + Math.cos(a) * 60;
    const py = ay + Math.sin(a) * 44;
    const l = 5 + noise(i * 1.3) * 7 + Math.sin(fight.clock * 3 + i) * 0.8;
    ctx.moveTo(px, py);
    ctx.lineTo(px + Math.cos(a) * l, py + Math.sin(a) * l);
  }
  ctx.stroke();
  // Head-and-chest in front.
  // (no warning before spitting: the head just lunges forward as the goo comes out)
  let rear = 0;
  // Lunges its head forward while spraying goo.
  if (spider.state === 'gooWindup') rear = ease(Math.min(1, spider.stateTime / GOO_WINDUP));
  if (spider.state === 'goo') rear = -0.7;
  const hx = x + f * (30 - rear * 7);
  const hy = bodyY + 6 - rear * 8;
  const head = ctx.createRadialGradient(hx, hy - 12, 3, hx, hy, 40);
  head.addColorStop(0, '#5a3a24');
  head.addColorStop(1, '#24150d');
  ctx.fillStyle = head;
  ctx.beginPath();
  ctx.ellipse(hx, hy, 38, 29, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = '#2e1c11';
  ctx.beginPath();
  for (let i = 0; i < 50; i++) {
    const a = (i / 50) * Math.PI * 2;
    const px = hx + Math.cos(a) * 37;
    const py = hy + Math.sin(a) * 28;
    const l = 4 + noise(i * 2.3) * 5;
    ctx.moveTo(px, py);
    ctx.lineTo(px + Math.cos(a) * l, py + Math.sin(a) * l);
  }
  ctx.stroke();
  // Big curved fangs.
  ctx.fillStyle = '#1a0f0a';
  for (const side of [-1, 1]) {
    const fx = hx + f * 30 + side * 5;
    ctx.beginPath();
    ctx.moveTo(fx - 4, hy + 14);
    ctx.quadraticCurveTo(fx + f * 6, hy + 30, fx + f * 1, hy + 38);
    ctx.lineTo(fx + 4, hy + 16);
    ctx.fill();
  }
  // A cluster of glowing red eyes (they turn into Xs when it's beaten).
  const eyes: [number, number, number][] = [[18, -14, 4.5], [27, -10, 4.5], [12, -6, 3], [31, -2, 3], [22, -20, 2.5], [33, -16, 2.5], [8, -16, 2], [36, -8, 2]];
  for (const [ex, ey, r] of eyes) {
    const px = hx + f * ex;
    const py = hy + ey;
    if (dead > 0.5) {
      ctx.strokeStyle = '#ff6a6a';
      ctx.lineWidth = 1.5;
      ctx.beginPath();
      ctx.moveTo(px - r, py - r);
      ctx.lineTo(px + r, py + r);
      ctx.moveTo(px + r, py - r);
      ctx.lineTo(px - r, py + r);
      ctx.stroke();
      continue;
    }
    const glow = ctx.createRadialGradient(px, py, 0, px, py, r * 2.4);
    glow.addColorStop(0, 'rgba(255, 60, 60, 0.6)');
    glow.addColorStop(1, 'rgba(255, 60, 60, 0)');
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(px, py, r * 2.4, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#d61f2a';
    ctx.beginPath();
    ctx.arc(px, py, r, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = '#ffd0d0';
    ctx.beginPath();
    ctx.arc(px - r * 0.3, py - r * 0.3, r * 0.3, 0, Math.PI * 2);
    ctx.fill();
  }
  // The goo warning: a glowing green mouth with goo dripping from the fangs.
  if (spider.state === 'gooWindup' && dead === 0) {
    const t = Math.min(1, spider.stateTime / GOO_WINDUP);
    const mx = hx + f * 32;
    const my = hy + 22;
    const glow = ctx.createRadialGradient(mx, my, 0, mx, my, 8 + t * 16);
    glow.addColorStop(0, `rgba(170, 255, 120, ${0.5 + t * 0.5})`);
    glow.addColorStop(1, 'rgba(90, 220, 60, 0)');
    ctx.fillStyle = glow;
    ctx.beginPath();
    ctx.arc(mx, my, 8 + t * 16, 0, Math.PI * 2);
    ctx.fill();
    for (let i = 0; i < 3; i++) {
      const drip = (fight.clock * 2.5 + i / 3) % 1;
      ctx.fillStyle = `rgba(110, 230, 70, ${1 - drip})`;
      ctx.beginPath();
      ctx.ellipse(mx + (i - 1) * 5, my + 6 + drip * 26, 2, 3 + drip * 2, 0, 0, Math.PI * 2);
      ctx.fill();
    }
  }
  // Flashes white when punched.
  if (flash) {
    ctx.globalCompositeOperation = 'lighter';
    ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
    ctx.beginPath();
    ctx.ellipse(ax, ay, 62, 46, 0, 0, Math.PI * 2);
    ctx.ellipse(hx, hy, 38, 29, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalCompositeOperation = 'source-over';
  }
  ctx.restore();
  legs.forEach((leg, k) => k % 2 === 0 && drawLeg(leg, k, true));
}

function drawRog(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  const { rog } = fight;
  // Flickers while hurt.
  if (rog.hurt > 0 && Math.floor(rog.hurt * 14) % 2 === 0) return;
  // A soft light around Rog so he stands out in the dark cave.
  const light = ctx.createRadialGradient(rog.x, rog.y - 18, 0, rog.x, rog.y - 18, 46);
  light.addColorStop(0, 'rgba(255, 236, 190, 0.45)');
  light.addColorStop(1, 'rgba(255, 236, 190, 0)');
  ctx.fillStyle = light;
  ctx.beginPath();
  ctx.arc(rog.x, rog.y - 18, 46, 0, Math.PI * 2);
  ctx.fill();
  const lost = fight.result === 'lost';
  const pose = lost ? 'fall' : !rog.onGround ? 'jump' : Math.abs(rog.vx) > 10 ? 'run' : fight.result === 'won' ? 'cheer' : 'stand';
  drawStickman(ctx, rog.x, rog.y, rog.facing, pose, rog.cycle + fight.clock * (pose === 'cheer' ? 6 : 0), rog.vy);
  if (rog.punch > 0) {
    // A fast punch: the arm shoots out and comes back.
    const out = Math.sin(Math.PI * (1 - rog.punch / PUNCH_TIME));
    const sx = rog.x + rog.facing * 2;
    const sy = rog.y - 28;
    const fx = rog.x + rog.facing * (8 + 16 * out);
    ctx.strokeStyle = '#1d1724';
    ctx.lineWidth = 2.6;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(sx, sy);
    ctx.lineTo(fx, sy - 1);
    ctx.stroke();
    ctx.fillStyle = '#1d1724';
    ctx.beginPath();
    ctx.arc(fx, sy - 1, 3.4, 0, Math.PI * 2);
    ctx.fill();
    if (out > 0.6) {
      // Whoosh lines.
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
      ctx.lineWidth = 1;
      for (const dy of [-4, 0, 4]) {
        ctx.beginPath();
        ctx.moveTo(fx - rog.facing * 14, sy - 1 + dy);
        ctx.lineTo(fx - rog.facing * 6, sy - 1 + dy);
        ctx.stroke();
      }
    }
  }
}

/** A little hairy spider: hops at Rog, with a tiny health bar over its head. */
function drawLittle(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  const little = fight.little;
  if (!little) return;
  const { x, y } = little;
  // A silk thread back to the big spider while it's flying out.
  if (little.state === 'flying') {
    const from = spiderSpinneret(fight.spider);
    ctx.strokeStyle = 'rgba(235, 230, 240, 0.7)';
    ctx.lineWidth = 0.8;
    ctx.beginPath();
    ctx.moveTo(from.x, from.y);
    ctx.lineTo(x, y - 8);
    ctx.stroke();
  }
  const dying = little.state === 'dead' ? Math.min(1, little.stateTime / LITTLE_DEATH) : 0;
  const squash = little.state === 'crouch' ? Math.min(1, little.stateTime / LITTLE_CROUCH) : 0;
  ctx.save();
  ctx.globalAlpha = 1 - dying;
  ctx.translate(x, y);
  ctx.scale(1.35, 1.35);
  if (dying > 0) ctx.rotate(dying * Math.PI);
  const bodyY = -9 + squash * 3;
  // Eight hairy little legs.
  ctx.strokeStyle = '#2e1c11';
  ctx.lineCap = 'round';
  for (let k = 0; k < 8; k++) {
    const side = k < 4 ? -1 : 1;
    const i = k % 4;
    const step = Math.sin(little.walk * 0.25 + k * 1.7) * 2;
    const footX = side * (6 + i * 4) + (k % 2 ? step : -step);
    const kneeX = side * (4 + i * 3);
    const kneeY = bodyY - 7 - (i % 2) * 2 + squash * 2;
    ctx.lineWidth = 1.8;
    ctx.beginPath();
    ctx.moveTo(side * 2, bodyY);
    ctx.lineTo(kneeX, kneeY);
    ctx.lineTo(footX, little.state === 'flying' || little.state === 'hop' ? bodyY + 6 : 0);
    ctx.stroke();
  }
  // Fuzzy body and head.
  const f = little.facing;
  ctx.fillStyle = '#3d2616';
  ctx.beginPath();
  ctx.ellipse(-f * 5, bodyY - 1, 8, 6.5, 0, 0, Math.PI * 2);
  ctx.ellipse(f * 5, bodyY + 1, 5, 4.5, 0, 0, Math.PI * 2);
  ctx.fill();
  ctx.strokeStyle = '#2e1c11';
  ctx.lineWidth = 0.7;
  ctx.beginPath();
  for (let i = 0; i < 24; i++) {
    const a = (i / 24) * Math.PI * 2;
    ctx.moveTo(-f * 5 + Math.cos(a) * 7.5, bodyY - 1 + Math.sin(a) * 6);
    ctx.lineTo(-f * 5 + Math.cos(a) * 10, bodyY - 1 + Math.sin(a) * 8);
  }
  ctx.stroke();
  ctx.fillStyle = '#b8642a';
  ctx.fillRect(-f * 5 - 2, bodyY - 5, 4, 2);
  // Red eyes.
  ctx.fillStyle = '#ff3c3c';
  for (const [ex, ey] of [[7, 0], [8.5, 2], [6, 2]]) {
    ctx.beginPath();
    ctx.arc(f * ex, bodyY + ey - 1, 1, 0, Math.PI * 2);
    ctx.fill();
  }
  if (little.hurt > 0) {
    ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
    ctx.beginPath();
    ctx.ellipse(0, bodyY, 12, 8, 0, 0, Math.PI * 2);
    ctx.fill();
  }
  ctx.restore();
  // A poof of dust when it's beaten.
  if (dying > 0) {
    for (let i = 0; i < 6; i++) {
      const a = (i / 6) * Math.PI * 2;
      ctx.fillStyle = `rgba(200, 190, 180, ${0.6 * (1 - dying)})`;
      ctx.beginPath();
      ctx.arc(x + Math.cos(a) * dying * 18, y - 9 + Math.sin(a) * dying * 12, 3 + dying * 4, 0, Math.PI * 2);
      ctx.fill();
    }
    return;
  }
  // Tiny health bar: 5 little blocks.
  for (let i = 0; i < LITTLE_MAX_HP; i++) {
    ctx.fillStyle = i < little.hp ? '#e5484d' : '#3a2b33';
    ctx.fillRect(x - 12 + i * 5, y - 36, 4, 3);
  }
}

/** Globs of green goo flying through the air, with a few drops trailing behind. */
function drawGoo(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  for (const glob of fight.goo) {
    const speed = Math.hypot(glob.vx, glob.vy) || 1;
    for (let i = 1; i <= 3; i++) {
      ctx.fillStyle = `rgba(110, 230, 70, ${0.6 - i * 0.15})`;
      ctx.beginPath();
      ctx.arc(glob.x - (glob.vx / speed) * i * 6, glob.y - (glob.vy / speed) * i * 6, GOO_RADIUS - i * 1.5, 0, Math.PI * 2);
      ctx.fill();
    }
    // A wobbly, shiny blob.
    const wobble = Math.sin(fight.clock * 25) * 0.15;
    const goo = ctx.createRadialGradient(glob.x - 2, glob.y - 2, 0, glob.x, glob.y, GOO_RADIUS + 1);
    goo.addColorStop(0, '#d8ff9a');
    goo.addColorStop(0.5, '#6be33f');
    goo.addColorStop(1, '#2f8f1e');
    ctx.fillStyle = goo;
    ctx.beginPath();
    ctx.ellipse(glob.x, glob.y, (GOO_RADIUS + 1) * (1 + wobble), (GOO_RADIUS + 1) * (1 - wobble), Math.atan2(glob.vy, glob.vx), 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
    ctx.beginPath();
    ctx.arc(glob.x - 2.5, glob.y - 2.5, 1.6, 0, Math.PI * 2);
    ctx.fill();
  }
}

/** Splats of goo: puddles on the floor, or a slimy splash on Rog. */
function drawSplats(ctx: CanvasRenderingContext2D, fight: BossFight, onRog: boolean): void {
  for (const splat of fight.splats) {
    if (splat.onRog !== onRog) continue;
    const t = splat.age / SPLAT_TIME;
    const fade = Math.min(1, (1 - t) * 3);
    if (onRog) {
      // Splashes out from where it hit, and some sticks to Rog and drips off.
      if (splat.age > 1) continue;
      const a = splat.age;
      for (let i = 0; i < 8; i++) {
        const angle = (i / 8) * Math.PI * 2;
        const r = 4 + a * 40;
        ctx.fillStyle = `rgba(110, 230, 70, ${1 - a})`;
        ctx.beginPath();
        ctx.arc(splat.x + Math.cos(angle) * r, splat.y + Math.sin(angle) * r + a * a * 60, 2.5 * (1 - a) + 0.5, 0, Math.PI * 2);
        ctx.fill();
      }
      const { rog } = fight;
      ctx.fillStyle = `rgba(110, 230, 70, ${0.8 * (1 - a)})`;
      ctx.beginPath();
      ctx.ellipse(rog.x, rog.y - 20, 8, 10, 0, 0, Math.PI * 2);
      ctx.fill();
      continue;
    }
    // A puddle that spreads out, with a splash at first.
    const spread = Math.min(1, splat.age * 6);
    ctx.fillStyle = `rgba(90, 210, 60, ${0.8 * fade})`;
    ctx.beginPath();
    ctx.ellipse(splat.x, FLOOR + 1, 6 + spread * 16, 2 + spread * 2.5, 0, 0, Math.PI * 2);
    ctx.fill();
    ctx.fillStyle = `rgba(200, 255, 150, ${0.6 * fade})`;
    ctx.beginPath();
    ctx.ellipse(splat.x - 4, FLOOR, 3 + spread * 4, 1, 0, 0, Math.PI * 2);
    ctx.fill();
    if (splat.age < 0.5) {
      const a = splat.age / 0.5;
      for (let i = 0; i < 7; i++) {
        const angle = Math.PI + (i / 6) * Math.PI;
        const r = 6 + a * 26;
        ctx.fillStyle = `rgba(110, 230, 70, ${1 - a})`;
        ctx.beginPath();
        ctx.arc(splat.x + Math.cos(angle) * r, FLOOR + Math.sin(angle) * r * 0.8 + a * a * 30, 2.2 * (1 - a) + 0.5, 0, Math.PI * 2);
        ctx.fill();
      }
    }
  }
}

/** Dust puffing up where the stomp landed. */
function drawDust(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  if (fight.shake <= 0) return;
  const t = 1 - fight.shake;
  for (let i = 0; i < 8; i++) {
    const dir = i % 2 === 0 ? 1 : -1;
    const x = fight.spider.target + dir * (6 + t * (20 + noise(i) * 30));
    const y = FLOOR - 4 - t * (6 + noise(i * 3) * 18);
    ctx.fillStyle = `rgba(150, 130, 120, ${0.5 * fight.shake})`;
    ctx.beginPath();
    ctx.arc(x, y, 4 + t * 8, 0, Math.PI * 2);
    ctx.fill();
  }
}

/**
 * For the 3D views: the cave is drawn in 3D, so only the health bars, messages, damage numbers
 * and the little spider's health go on top. `project` finds where a point of the fight is on screen.
 */
export function drawBossHud(
  ctx: CanvasRenderingContext2D,
  fight: BossFight,
  project: (x: number, y: number) => { x: number; y: number } | null,
): void {
  for (const pop of fight.pops) {
    const p = project(pop.x, pop.y);
    if (!p) continue;
    ctx.globalAlpha = 1 - pop.age / 0.9;
    ctx.font = 'bold 14px system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#1d1724';
    ctx.strokeText(pop.text, p.x, p.y - pop.age * 26);
    ctx.fillStyle = pop.color;
    ctx.fillText(pop.text, p.x, p.y - pop.age * 26);
  }
  ctx.globalAlpha = 1;
  const little = fight.little;
  if (little && little.state !== 'dead') {
    const p = project(little.x, little.y - 34);
    if (p) {
      for (let i = 0; i < LITTLE_MAX_HP; i++) {
        ctx.fillStyle = i < little.hp ? '#e5484d' : '#3a2b33';
        ctx.fillRect(p.x - 12 + i * 5, p.y, 4, 3);
      }
    }
  }
  drawBars(ctx, fight);
  drawMessages(ctx, fight, true);
}

function drawBars(ctx: CanvasRenderingContext2D, fight: BossFight): void {
  const bar = (x: number, label: string, hp: number, max: number, color: string, alignRight: boolean) => {
    const w = 150;
    ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
    ctx.fillRect(x - 2, 30, w + 4, 14);
    ctx.fillStyle = '#3a2b33';
    ctx.fillRect(x, 32, w, 10);
    ctx.fillStyle = color;
    const filled = (w * hp) / max;
    ctx.fillRect(alignRight ? x + w - filled : x, 32, filled, 10);
    ctx.font = 'bold 11px system-ui, sans-serif';
    ctx.textAlign = alignRight ? 'right' : 'left';
    ctx.fillStyle = '#ffffff';
    ctx.fillText(`${label}  ${hp}/${max} HP`, alignRight ? x + w : x, 26);
  };
  bar(150, 'ROG', fight.rog.hp, ROG_MAX_HP, '#46c96b', false);
  bar(ARENA_W - 168, 'GIANT SPIDER', fight.spider.hp, SPIDER_MAX_HP, '#e5484d', true);
}

function drawMessages(ctx: CanvasRenderingContext2D, fight: BossFight, threeD = false): void {
  ctx.textAlign = 'center';
  if (fight.result === 'fighting' && fight.clock < 4) {
    ctx.globalAlpha = Math.min(1, (4 - fight.clock) * 2);
    ctx.font = 'bold 13px system-ui, sans-serif';
    ctx.fillStyle = '#ffcc33';
    ctx.fillText(
      threeD ? '↑ ↓ to move, 1 to jump, Space to PUNCH: jump up to punch the spider!' : 'Space to PUNCH, ↑ or 1 to jump: jump up to punch the spider!',
      ARENA_W / 2,
      66,
    );
    ctx.globalAlpha = 1;
  }
  if (fight.result === 'fighting' && fight.little && fight.little.state !== 'dead' && fight.clock >= 4) {
    ctx.font = 'bold 12px system-ui, sans-serif';
    ctx.fillStyle = '#ffcc33';
    ctx.fillText('The giant spider is waiting... beat the little spider!', ARENA_W / 2, 66);
  }
  if (fight.result === 'fighting' && fight.spider.state === 'gooWindup') {
    // The goo warning, big and green in the middle of the screen.
    const pulse = 1 + Math.sin(fight.clock * 25) * 0.06;
    ctx.save();
    ctx.translate(ARENA_W / 2, 104);
    ctx.scale(pulse, pulse);
    ctx.font = '900 22px system-ui, sans-serif';
    ctx.lineWidth = 5;
    ctx.strokeStyle = '#123d0c';
    ctx.strokeText('⚠ GOO INCOMING!', 0, 0);
    ctx.fillStyle = '#8dff5a';
    ctx.fillText('⚠ GOO INCOMING!', 0, 0);
    ctx.restore();
  }
  if (fight.result === 'fighting') return;
  const won = fight.result === 'won';
  ctx.fillStyle = 'rgba(10, 6, 12, 0.7)';
  ctx.fillRect(0, ARENA_H / 2 - 32, ARENA_W, 60);
  ctx.font = '900 22px system-ui, sans-serif';
  ctx.fillStyle = won ? '#ffcc33' : '#ff6a6a';
  ctx.fillText(won ? 'YOU BEAT THE GIANT SPIDER!' : 'The spider got Rog!', ARENA_W / 2, ARENA_H / 2 - 4);
  ctx.font = '12px system-ui, sans-serif';
  ctx.fillStyle = '#ffffff';
  ctx.fillText(won ? 'On to Level 1...' : 'Try again...', ARENA_W / 2, ARENA_H / 2 + 16);
}