Back to Stick Runner
SOURCE / PINNED RELEASE

Made of little things.

Stick Runner

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

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

src/game/game.ts
import {
  LEVELS,
  PIT_DEPTH,
  VIEW_H,
  VIEW_W,
  lineHeightAt,
  roofHeightAt,
  type Block,
  type Clothesline,
  type Ice,
  type Level,
} from './level.js';
import { drawStickman, type Pose } from './stickman.js';

// Movement feel, in world units per second.
const RUN_SPEED = 220;
const RUN_ACCEL = 1600;
const AIR_ACCEL = 1100;
const ICE_SPEED = 360; // sliding on ice is faster than running
const ICE_ACCEL = 2400;
const GRAVITY = 1800;
const JUMP_SPEED = 620;
const MAX_FALL = 900;
const COYOTE_TIME = 0.1; // you can still jump just after running off an edge
const JUMP_BUFFER = 0.12; // a jump pressed just before landing still counts
const STEP = 1 / 120;

const BODY_W = 14;
const BODY_H = 36;

/** How long the "into the pit" message stays before going back to the start. */
const PIT_PAUSE = 1.3;

/** How long Rog celebrates at a flag before the next level starts. */
const LEVEL_PAUSE = 2.2;

/** Landing this fast while running gives Rog a chance to do a parkour roll. */
const ROLL_LANDING_SPEED = 450;
const ROLL_CHANCE = 0.25;
const ROLL_TIME = 0.4;

/** A clothesline holds you this long after you first land on it, then it snaps. */
const LINE_HOLD_TIME = 0.5;

interface LineState {
  line: Clothesline;
  /** Seconds since the stickman first landed on it, or null if nobody has. */
  held: number | null;
  /** The game clock when it snapped, or null if it is still whole. */
  snappedAt: number | null;
}

// Clothes pegged on every line: where along the line, what it is, and its colour.
const CLOTHES: [number, 'shirt' | 'sock' | 'pants', string][] = [
  [0.18, 'shirt', '#e5484d'],
  [0.34, 'sock', '#ffffff'],
  [0.44, 'sock', '#3e7bfa'],
  [0.62, 'pants', '#3d63a8'],
  [0.82, 'shirt', '#46a758'],
];

export type Phase = 'running' | 'fell' | 'won';

export interface Controls {
  left: boolean;
  right: boolean;
  jump: boolean;
}

export class Game {
  controls: Controls = { left: false, right: false, jump: false };
  paused = true;
  /** Called once when the stickman touches the flag on the last level. */
  onWin: (seconds: number, falls: number) => void = () => {};

  private levelIndex = 0;
  private level: Level = LEVELS[0];
  private lines: LineState[] = [];

  private ctx: CanvasRenderingContext2D;
  private scale = 1;
  private x = 0;
  private y = 0;
  private vx = 0;
  private vy = 0;
  private onGround = false;
  private facing = 1;
  private coyote = 0;
  private jumpBuffer = 0;
  private jumpWasDown = false;
  private runCycle = 0;
  private rollTimer = 0;
  private phase: Phase = 'running';
  private phaseTimer = 0;
  private cameraX = 0;
  private time = 0;
  private falls = 0;
  private clock = 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.loadLevel(0);
  }

  get blocks(): Block[] {
    return this.level.blocks;
  }

  get ice(): Ice[] {
    return this.level.ice;
  }

  get isLastLevel(): boolean {
    return this.levelIndex === LEVELS.length - 1;
  }

  /** Start the whole game again from Level 1. */
  restartGame(): void {
    this.loadLevel(0);
    this.falls = 0;
  }

  loadLevel(index: number): void {
    this.levelIndex = index;
    this.level = LEVELS[index];
    this.lines = this.level.clotheslines.map((line) => ({ line, held: null, snappedAt: null }));
    this.restart();
  }

  /** Put the stickman back at the start of this level with a fresh timer. */
  restart(): void {
    this.respawn();
    this.time = 0;
    this.phase = 'running';
  }

  /** Match the canvas pixels to its on-screen size so lines stay sharp. */
  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;
      this.clock += dt;
      if (!this.paused) {
        this.leftover += dt;
        while (this.leftover >= STEP) {
          this.update(STEP);
          this.leftover -= STEP;
        }
      }
      this.draw();
      requestAnimationFrame(frame);
    };
    requestAnimationFrame(frame);
  }

  private respawn(): void {
    this.x = this.level.startX;
    this.y = roofHeightAt(this.blocks, this.level.startX);
    this.vx = 0;
    this.vy = 0;
    this.onGround = true;
    this.facing = 1;
    this.coyote = 0;
    this.jumpBuffer = 0;
    this.rollTimer = 0;
    this.cameraX = 0;
    for (const state of this.lines) {
      state.held = null;
      state.snappedAt = null;
    }
  }

  private update(dt: number): void {
    if (this.phase === 'won') {
      // Little celebration: slow to a stop on the finish roof, then on to the next level.
      this.vx *= 0.9;
      this.runCycle += dt * 4;
      this.phaseTimer -= dt;
      if (!this.isLastLevel && this.phaseTimer <= 0) this.loadLevel(this.levelIndex + 1);
      return;
    }
    if (this.phase === 'fell') {
      this.phaseTimer -= dt;
      this.vy = Math.min(this.vy + GRAVITY * dt, MAX_FALL);
      this.y += this.vy * dt;
      if (this.phaseTimer <= 0) {
        // Falling on Level 2 sends Rog all the way back to Level 1.
        if (this.levelIndex > 0) this.loadLevel(0);
        else this.restart();
      }
      return;
    }

    this.time += dt;

    // Clotheslines that have been landed on snap after half a second.
    for (const state of this.lines) {
      if (state.held === null || state.snappedAt !== null) continue;
      state.held += dt;
      if (state.held >= LINE_HOLD_TIME) state.snappedAt = this.clock;
    }
    const { left, right, jump } = this.controls;

    // Running left and right.
    const want = (right ? 1 : 0) - (left ? 1 : 0);
    if (this.onIce()) {
      // On ice you can't stop or turn: you whoosh to the end of it.
      this.vx = Math.min(ICE_SPEED, this.vx + ICE_ACCEL * dt);
      this.facing = 1;
    } else {
      const accel = this.onGround ? RUN_ACCEL : AIR_ACCEL;
      const target = want * RUN_SPEED;
      // Jumping off the ice keeps its speed, unless you push the other way.
      const keepSpeed = !this.onGround && this.vx > RUN_SPEED && want >= 0;
      if (this.vx < target) this.vx = Math.min(target, this.vx + accel * dt);
      else if (this.vx > target && !keepSpeed) this.vx = Math.max(target, this.vx - accel * dt);
      if (want !== 0) this.facing = want;
    }

    // Jumping, with a little forgiveness at roof edges.
    if (jump && !this.jumpWasDown) this.jumpBuffer = JUMP_BUFFER;
    this.jumpWasDown = jump;
    this.coyote = this.onGround ? COYOTE_TIME : Math.max(0, this.coyote - dt);
    this.jumpBuffer = Math.max(0, this.jumpBuffer - dt);
    if (this.jumpBuffer > 0 && this.coyote > 0) {
      this.vy = -JUMP_SPEED;
      this.jumpBuffer = 0;
      this.coyote = 0;
      this.onGround = false;
    }
    // Letting go of jump early makes a smaller hop.
    if (!jump && this.vy < -JUMP_SPEED * 0.45) this.vy = -JUMP_SPEED * 0.45;

    this.vy = Math.min(this.vy + GRAVITY * dt, MAX_FALL);

    const wasOnGround = this.onGround;
    const landingSpeed = this.vy;
    this.moveX(this.vx * dt);
    this.moveY(this.vy * dt);
    this.rollTimer = this.onGround ? Math.max(0, this.rollTimer - dt) : 0;
    if (!wasOnGround && this.onGround && landingSpeed > ROLL_LANDING_SPEED && Math.abs(this.vx) > 60 && Math.random() < ROLL_CHANCE) {
      this.rollTimer = ROLL_TIME;
    }
    this.x = Math.max(BODY_W / 2, Math.min(this.level.end - BODY_W / 2, this.x));

    if (this.onGround && Math.abs(this.vx) > 10) this.runCycle += dt * Math.abs(this.vx) * 0.055;

    if (this.y > PIT_DEPTH) {
      this.phase = 'fell';
      this.phaseTimer = PIT_PAUSE;
      this.falls += 1;
      return;
    }
    if (this.x >= this.level.finishX) {
      this.phase = 'won';
      this.phaseTimer = LEVEL_PAUSE;
      if (this.isLastLevel) this.onWin(this.time, this.falls);
    }

    const goal = Math.max(0, Math.min(this.level.end - VIEW_W, this.x - VIEW_W * 0.33));
    this.cameraX += (goal - this.cameraX) * Math.min(1, dt * 10);
  }

  private onIce(): boolean {
    return this.onGround && this.ice.some((patch) => this.y === patch.y && this.x >= patch.x && this.x <= patch.x + patch.w);
  }

  /** The stickman's box: x is the middle of his feet, y is where his feet are. */
  private overlaps(block: Block, x: number, y: number): boolean {
    return (
      x + BODY_W / 2 > block.x &&
      x - BODY_W / 2 < block.x + block.w &&
      y > block.y &&
      y - BODY_H < block.y + block.h
    );
  }

  private moveX(dx: number): void {
    this.x += dx;
    for (const block of this.blocks) {
      if (!this.overlaps(block, this.x, this.y)) continue;
      if (dx > 0) this.x = block.x - BODY_W / 2;
      else if (dx < 0) this.x = block.x + block.w + BODY_W / 2;
      this.vx = 0;
    }
  }

  private moveY(dy: number): void {
    this.y += dy;
    this.onGround = false;
    for (const block of this.blocks) {
      if (!this.overlaps(block, this.x, this.y)) continue;
      if (dy > 0) {
        this.y = block.y;
        this.onGround = true;
      } else if (dy < 0) {
        this.y = block.y + block.h + BODY_H;
      }
      this.vy = 0;
    }
    if (this.onGround || dy <= 0) return;
    // Land on a clothesline from above (you can jump up through it from below).
    for (const state of this.lines) {
      const { line } = state;
      if (state.snappedAt !== null || this.x < line.x1 + 2 || this.x > line.x2 - 2) continue;
      const ropeY = lineHeightAt(line, this.x);
      if (this.y >= ropeY && this.y - dy <= ropeY + 1.5) {
        this.y = ropeY;
        this.vy = 0;
        this.onGround = true;
        if (state.held === null) state.held = 0;
      }
    }
  }

  private pose(): Pose {
    if (this.phase === 'won') return 'cheer';
    if (this.phase === 'fell') return 'fall';
    if (!this.onGround) return 'jump';
    if (this.rollTimer > 0) return 'roll';
    if (this.onIce()) return 'slide';
    if (Math.abs(this.vx) > 10) return 'run';
    return 'stand';
  }

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

  private draw(): void {
    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();
    for (const block of this.blocks) this.drawBlock(block);
    for (const patch of this.ice) this.drawIce(patch);
    for (const state of this.lines) this.drawClothesline(state);
    this.drawFinish();
    const pose = this.pose();
    const cycle = pose === 'roll' ? 1 - this.rollTimer / ROLL_TIME : this.runCycle;
    drawStickman(ctx, this.x, this.y, this.facing, pose, cycle, this.vy);
    ctx.restore();

    this.drawHud();
  }

  private drawSky(): void {
    const ctx = this.ctx;
    const sky = ctx.createLinearGradient(0, 0, 0, VIEW_H);
    const { scenery } = this.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.level.scenery.farCity[0], base: 150, spread: 60, width: 46 },
      { speed: 0.4, color: this.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.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;
    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);
    }
  }

  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.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.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.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.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.level.finishX;
    const ground = roofHeightAt(this.blocks, flagX);
    const top = ground - 70;
    const wave = Math.sin(this.clock * 6) * 2;
    if (this.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.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.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();
  }

  private drawHud(): void {
    const ctx = this.ctx;
    // Progress bar along the top so you can see how far the finish is.
    const barX = 150;
    const barW = VIEW_W - 170;
    const { startX, finishX } = this.level;
    const progress = Math.max(0, Math.min(1, (this.x - startX) / (finishX - startX)));
    ctx.fillStyle = 'rgba(43, 34, 51, 0.35)';
    ctx.fillRect(barX, 14, barW, 6);
    ctx.fillStyle = '#2b2233';
    ctx.fillRect(barX, 14, barW * progress, 6);
    ctx.fillRect(barX + barW - 2, 9, 3, 16);

    ctx.fillStyle = '#2b2233';
    ctx.font = 'bold 12px system-ui, sans-serif';
    ctx.textAlign = 'right';
    ctx.fillText(`${this.level.name}  ${this.time.toFixed(1)} s`, barX - 10, 22);

    if (this.phase === 'won' && !this.isLastLevel) {
      ctx.fillStyle = 'rgba(29, 23, 36, 0.75)';
      ctx.fillRect(0, VIEW_H / 2 - 30, VIEW_W, 60);
      ctx.fillStyle = '#ffcc33';
      ctx.textAlign = 'center';
      ctx.font = 'bold 22px system-ui, sans-serif';
      ctx.fillText(`${this.level.name} complete!`, VIEW_W / 2, VIEW_H / 2 - 2);
      ctx.fillStyle = '#ffffff';
      ctx.font = '12px system-ui, sans-serif';
      ctx.fillText(`Get ready for ${LEVELS[this.levelIndex + 1].name}...`, VIEW_W / 2, VIEW_H / 2 + 18);
    }

    if (this.phase === 'fell') {
      ctx.fillStyle = 'rgba(29, 23, 36, 0.75)';
      ctx.fillRect(0, VIEW_H / 2 - 30, VIEW_W, 60);
      ctx.fillStyle = '#ffcc33';
      ctx.textAlign = 'center';
      ctx.font = 'bold 22px system-ui, sans-serif';
      ctx.fillText('Rog fell in the pit!', VIEW_W / 2, VIEW_H / 2 - 2);
      ctx.fillStyle = '#ffffff';
      ctx.font = '12px system-ui, sans-serif';
      ctx.fillText(this.levelIndex > 0 ? 'All the way back to Level 1!' : 'Back to the start...', VIEW_W / 2, VIEW_H / 2 + 18);
    }
  }
}

/** 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);
}

export { VIEW_W, VIEW_H };