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/game.ts
import {
  LEVELS,
  PIT_DEPTH,
  SAW_RADIUS,
  SPIKE_HEIGHT,
  SPRING_HEIGHT,
  SPRING_WIDTH,
  VIEW_H,
  VIEW_W,
  lineHeightAt,
  roofHeightAt,
  sawCenter,
  type Block,
  type Spring,
  type Clothesline,
  type Ice,
  type Level,
} from './level.js';
import { stickmanShape, type Pose, type Shape } from './stickman.js';
import { BossFight } from './boss.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;

/** The dragon: how far behind Rog it flies, how often it breathes fire, and how fast fireballs go. */
const DRAGON_BEHIND = 130;
const DRAGON_ABOVE = 95;
const FIRE_EVERY = 5;
/** Its mouth glows this long before each fireball, as a warning. */
const FIRE_WARNING = 0.8;
const FIREBALL_SPEED = 330;
export const FIREBALL_RADIUS = 7;
/** How long a fireball's explosion lasts, in seconds. */
export const EXPLOSION_TIME = 1.1;

/** How fast a spring launches Rog upwards: about twice as high as a normal jump. */
const SPRING_SPEED = 850;
/** Holding a direction when you bounce on a spring flings Rog that way this fast, to fly over wide gaps. */
const SPRING_PUSH = 280;

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

export 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.
export 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' | 'stairs' | 'boss';

/** How long the trip down the staircase (ending on the BOSS LEVEL screen) lasts, in seconds. */
export const STAIRS_TIME = 10;
/**
 * What happens when Rog loses (falls in a pit, gets chopped, burned...):
 * easy = back to the start of the last building he stood on, normal = restart the level,
 * hard = back one level.
 */
export type Difficulty = 'easy' | 'normal' | 'hard';
type KnockOut = 'pit' | 'saw' | 'spikes' | 'fire';

export interface Dragon {
  x: number;
  y: number;
  /** Seconds since its last fireball. */
  timer: number;
}

/** Where a fireball blew up, and how long ago. `ground` is true if it hit the top of a roof. */
export interface Explosion {
  x: number;
  y: number;
  age: number;
  ground: boolean;
}

export interface Fireball {
  x: number;
  y: number;
  vx: number;
  vy: number;
  age: number;
}

/** Everything the 3D view needs to draw one frame. */
export interface GameView {
  level: Level;
  levelIndex: number;
  x: number;
  y: number;
  facing: number;
  shape: Shape;
  pose: Pose;
  /** How far through the running step, or through a roll (0 to 1). */
  cycle: number;
  /** Going up (negative) or down (positive). */
  vy: number;
  lines: LineState[];
  clock: number;
  /** Seconds since this level (re)started; the saws move along with it. */
  levelTime: number;
  /** The spring Rog last bounced on, and when (game clock), so it can boing. */
  springBounce: { x: number; at: number } | null;
  /** The dragon, if this level has one, and how close it is to breathing fire (0 to 1). */
  dragon: (Dragon & { warn: number }) | null;
  fireballs: Fireball[];
  explosions: Explosion[];
  phase: Phase;
}

/** Draws the world (in 3D). The game itself only draws the scores on top. */
export interface WorldView {
  render(view: GameView): void;
  /** Only the 3D views draw the boss fight themselves; the flat view leaves it to the HUD. */
  renderBoss?(fight: BossFight): (x: number, y: number) => { x: number; y: number } | null;
  resize(cssWidth: number, cssHeight: number): void;
}

export interface Controls {
  left: boolean;
  right: boolean;
  jump: boolean;
  /** Only used in the boss fight. */
  punch: boolean;
}

export class Game {
  controls: Controls = { left: false, right: false, jump: false, punch: false };
  paused = true;
  difficulty: Difficulty = 'normal';
  /** Rog's parkour roll when he lands. On in 2D, off in first-person 3D (see view-mode.ts). */
  rollsEnabled = true;

  private cssSize = { w: 1, h: 1 };
  private notice: { text: string; until: number } | null = null;
  /** Draws the trip down the staircase over the whole screen (see stairs-scene.ts). */
  stairsScene: ((ctx: CanvasRenderingContext2D, seconds: number) => void) | null = null;
  /** Draws the boss fight over the whole screen (see boss-scene.ts). */
  bossScene: ((ctx: CanvasRenderingContext2D, fight: BossFight) => void) | null = null;
  /** In the 3D views: just the health bars, messages and damage numbers on top. */
  bossHud: ((ctx: CanvasRenderingContext2D, fight: BossFight, project: (x: number, y: number) => { x: number; y: number } | null) => void) | null = null;
  /** The spider fight at the end of Level 10, while it's going on. */
  boss: BossFight | null = null;
  /** 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 hudCtx: 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';
  /** What knocked Rog out. */
  private fellBy: KnockOut = 'pit';
  /** The last roof Rog stood on, so Easy can put him back there. */
  private lastRoof: Block | null = null;
  private springBounce: { x: number; at: number } | null = null;
  /** True while Rog is flying up from a spring. */
  private springing = false;
  /** After a gap spring: the forward speed it gives, kept until he lands. */
  private springPush = 0;
  /** Where Rog flops down after being knocked out on a roof (below the screen if he was over a gap). */
  private flopY = PIT_DEPTH * 2;
  private dragon: Dragon | null = null;
  private fireballs: Fireball[] = [];
  private explosions: Explosion[] = [];
  private phaseTimer = 0;
  private time = 0;
  private falls = 0;
  private clock = 0;
  private lastFrame = 0;
  private leftover = 0;

  constructor(
    private hud: HTMLCanvasElement,
    private world: WorldView | null,
  ) {
    const ctx = hud.getContext('2d');
    if (!ctx) throw new Error('Canvas 2D is not available');
    this.hudCtx = 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;
  }

  /** True while one of the dragon's fireballs is flying through the air. */
  get fireballFlying(): boolean {
    return this.phase === 'running' && this.fireballs.length > 0;
  }

  /** True during the spider fight, when the controls work side-on whatever the view. */
  get inBoss(): boolean {
    return this.phase === 'boss';
  }

  loadLevel(index: number): void {
    this.boss = null;
    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 canvases' pixels to their on-screen size so everything stays sharp. */
  /** Swap how the world is drawn (2D, first person or third person) while playing. */
  setWorld(world: WorldView): void {
    this.world = world;
    world.resize(this.cssSize.w, this.cssSize.h);
  }

  /** Show a short message near the top of the screen for a moment. */
  showNotice(text: string): void {
    this.notice = { text, until: this.clock + 1.6 };
  }

  resize(cssWidth: number, cssHeight: number): void {
    this.cssSize = { w: cssWidth, h: cssHeight };
    const ratio = window.devicePixelRatio || 1;
    this.hud.width = Math.max(1, Math.round(cssWidth * ratio));
    this.hud.height = Math.max(1, Math.round(cssHeight * ratio));
    this.scale = this.hud.width / VIEW_W;
    this.world?.resize(cssWidth, cssHeight);
    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);
  }

  /** Put Rog on the roof at `x` (the start of the level unless told otherwise), ready to run. */
  private respawn(x = this.level.startX): void {
    this.springPush = 0;
    this.x = x;
    this.y = roofHeightAt(this.blocks, x);
    this.lastRoof = null;
    this.vx = 0;
    this.vy = 0;
    this.onGround = true;
    this.facing = 1;
    this.coyote = 0;
    this.jumpBuffer = 0;
    this.rollTimer = 0;
    for (const state of this.lines) {
      state.held = null;
      state.snappedAt = null;
    }
    // The dragon starts just off the left of the screen, behind Rog.
    this.dragon = this.level.dragon ? { x: this.x - DRAGON_BEHIND - 80, y: this.y - DRAGON_ABOVE, timer: 0 } : null;
    this.fireballs = [];
    this.explosions = [];
  }

  private update(dt: number): void {
    if (this.phase === 'stairs') {
      // Down the staircase and into the ground. Afterwards, on to the next level.
      this.phaseTimer += dt;
      if (this.phaseTimer >= STAIRS_TIME) {
        // At the bottom of the stairs: the boss fight!
        this.phase = 'boss';
        this.boss = new BossFight();
      }
      return;
    }
    if (this.phase === 'boss' && this.boss) {
      this.boss.update(dt, this.controls);
      if (this.boss.finished) {
        if (this.boss.result === 'lost') {
          // The spider won: try the fight again.
          this.boss = new BossFight();
        } else {
          this.boss = null;
          if (this.isLastLevel) this.onWin(this.time, this.falls);
          else this.loadLevel(this.levelIndex + 1);
        }
      }
      return;
    }
    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.updateExplosions(dt);
      this.phaseTimer -= dt;
      this.vy = Math.min(this.vy + GRAVITY * dt, MAX_FALL);
      this.y += this.vy * dt;
      if (this.y > this.flopY) {
        this.y = this.flopY;
        this.vy = 0;
      }
      if (this.phaseTimer <= 0) {
        if (this.difficulty === 'easy') {
          // Back to the start of the last building he stood on. The timer keeps going.
          const roof = this.lastRoof;
          this.respawn(roof && roof.x > this.level.startX ? roof.x + 20 : this.level.startX);
          this.phase = 'running';
        } else if (this.difficulty === 'normal' || this.levelIndex === 0) {
          this.restart();
        } else {
          this.loadLevel(this.levelIndex - 1);
        }
      }
      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.
      // (Not after a spring, though: there you have to keep holding the key to keep going.)
      const keepSpeed = !this.onGround && this.vx > RUN_SPEED && want >= 0 && this.springPush === 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;
      // Flying off a gap spring: while you hold the arrow key that way, Rog flies at just the speed
      // that lands him on the next roof. Let go and he slows down and drops.
      if (this.springPush !== 0 && want === Math.sign(this.springPush)) this.vx = this.springPush;
    }

    // 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 (but a spring bounce always goes all the way up).
    if (this.vy >= 0) this.springing = false;
    if (!jump && !this.springing && 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 (this.onGround) {
      // Landed: the push from a gap spring is used up.
      this.springPush = 0;
      this.lastRoof = this.blocks.find((b) => b.kind === 'roof' && this.y === b.y && this.x >= b.x && this.x <= b.x + b.w) ?? this.lastRoof;
    }
    if (this.onGround) {
      // BOING! Only landing right on top of a spring bounces Rog up.
      // (Walking into its side just bumps it, and jumping over it just goes over it.)
      const spring = this.level.springs.find(
        (s: Spring) => this.y === s.roofY - SPRING_HEIGHT && this.x + BODY_W / 2 > s.x && this.x - BODY_W / 2 < s.x + SPRING_WIDTH,
      );
      if (spring) {
        this.vy = -SPRING_SPEED;
        if (spring.overGap) {
          // Work out how long he'll be in the air, and fly him just fast enough to land on the next roof.
          const drop = this.y - spring.landY;
          const air = (SPRING_SPEED + Math.sqrt(SPRING_SPEED * SPRING_SPEED - 2 * GRAVITY * drop)) / GRAVITY;
          this.springPush = (spring.landX - this.x) / air;
          // Sideways only if you're holding the arrow key that way.
          if (want === Math.sign(this.springPush)) this.vx = this.springPush;
          else if (want === 0) this.vx = 0;
          this.facing = Math.sign(this.springPush) || 1;
        } else if (want !== 0) {
          this.vx = want * Math.max(Math.abs(this.vx), SPRING_PUSH);
        }
        this.onGround = false;
        this.coyote = 0;
        this.springBounce = { x: spring.x, at: this.clock };
        this.springing = true;
      }
    }
    if (!wasOnGround && this.onGround && landingSpeed > ROLL_LANDING_SPEED && Math.abs(this.vx) > 60 && this.rollsEnabled && 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.knockOut('pit');
      return;
    }
    if (this.hitSaw()) {
      // Chopped! Rog gets knocked up into the air and tumbles away.
      this.knockOut('saw');
      this.vy = -420;
      return;
    }
    this.updateDragon(dt);
    if (this.hitByFireball()) {
      this.knockOut('fire');
      this.vy = -420;
      return;
    }
    if (this.onSpikes()) {
      // Ouch! Rog bounces up off the spikes and tumbles away.
      this.knockOut('spikes');
      this.vy = -420;
      return;
    }
    if (this.x >= this.level.finishX && this.level.flag === 'stairs') {
      this.phase = 'stairs';
      this.phaseTimer = 0;
      this.fireballs = [];
      return;
    }
    if (this.x >= this.level.finishX) {
      this.phase = 'won';
      this.phaseTimer = LEVEL_PAUSE;
      if (this.isLastLevel) this.onWin(this.time, this.falls);
    }

  }

  private knockOut(by: KnockOut): void {
    this.phase = 'fell';
    this.fellBy = by;
    // Knocked out on a roof: he pops up and flops back down onto it, instead of falling through the building.
    const roofY = roofHeightAt(this.blocks, this.x);
    this.flopY = by !== 'pit' && this.y <= roofY && roofY < VIEW_H ? roofY : PIT_DEPTH * 2;
    this.phaseTimer = PIT_PAUSE;
    this.falls += 1;
  }

  /** Does Rog's body touch a saw blade? (A little smaller than it looks, to be fair.) */
  private hitSaw(): boolean {
    const reach = SAW_RADIUS * 0.8;
    return this.level.saws.some((saw) => {
      const c = sawCenter(saw, this.time);
      const nearX = Math.max(this.x - BODY_W / 2, Math.min(c.x, this.x + BODY_W / 2));
      const nearY = Math.max(this.y - BODY_H, Math.min(c.y, this.y));
      return Math.hypot(c.x - nearX, c.y - nearY) < reach;
    });
  }

  /** The dragon flies along behind Rog, and every few seconds shoots a fireball straight at him. */
  private updateDragon(dt: number): void {
    const dragon = this.dragon;
    if (dragon) {
      // Never further left than the edge of the screen, so you can always see it.
      const goalX = Math.max(this.x - DRAGON_BEHIND, 30);
      // Stays on screen even when Rog is up on a very tall roof.
      const goalY = Math.max(28, Math.min(this.y, VIEW_H) - DRAGON_ABOVE) + Math.sin(this.clock * 3) * 6;
      dragon.x += (goalX - dragon.x) * Math.min(1, dt * 2.5);
      dragon.y += (goalY - dragon.y) * Math.min(1, dt * 2.5);
      const before = dragon.timer;
      dragon.timer += dt;
      // Too close to Rog (at the very start of the level) to aim properly, or Rog is flying up
      // from a spring and can't dodge: it waits before winding up.
      if (this.x - dragon.x < 100 || this.springing) dragon.timer = Math.min(dragon.timer, FIRE_EVERY - FIRE_WARNING - 0.01);
      if (before < FIRE_EVERY - FIRE_WARNING && dragon.timer >= FIRE_EVERY - FIRE_WARNING) {
        this.showNotice('🔥 The dragon is about to breathe fire!');
      }
      if (dragon.timer >= FIRE_EVERY) {
        dragon.timer -= FIRE_EVERY;
        const mouth = { x: dragon.x + 40, y: dragon.y + 2 };
        // Aim where Rog will be when the fireball catches up, so running straight on doesn't dodge it.
        let flight = 0;
        for (let i = 0; i < 6; i++) {
          flight = Math.hypot(this.x + this.vx * flight - mouth.x, this.y - BODY_H / 2 - mouth.y) / FIREBALL_SPEED;
        }
        const dx = this.x + this.vx * flight - mouth.x;
        const dy = this.y - BODY_H / 2 - mouth.y;
        const length = Math.hypot(dx, dy) || 1;
        this.fireballs.push({ ...mouth, vx: (dx / length) * FIREBALL_SPEED, vy: (dy / length) * FIREBALL_SPEED, age: 0 });
      }
    }
    // Fireballs fly straight and explode when they hit a roof or a wall. Old ones just fizzle out.
    for (const ball of this.fireballs) {
      ball.x += ball.vx * dt;
      ball.y += ball.vy * dt;
      ball.age += dt;
    }
    this.fireballs = this.fireballs.filter((ball) => {
      if (ball.age >= 3) return false;
      const block = this.blocks.find((b) => ball.x > b.x && ball.x < b.x + b.w && ball.y > b.y && ball.y < b.y + b.h);
      if (!block) return true;
      // Came down onto the top: explode on the roof. Otherwise it hit the side of something.
      const onTop = ball.y - ball.vy * dt <= block.y + 1;
      this.explosions.push({ x: ball.x, y: onTop ? block.y : ball.y, age: 0, ground: onTop });
      return false;
    });
    this.updateExplosions(dt);
  }

  private updateExplosions(dt: number): void {
    for (const blast of this.explosions) blast.age += dt;
    this.explosions = this.explosions.filter((blast) => blast.age < EXPLOSION_TIME);
  }

  private hitByFireball(): boolean {
    const hit = this.fireballs.find((ball) => {
      const nearX = Math.max(this.x - BODY_W / 2, Math.min(ball.x, this.x + BODY_W / 2));
      const nearY = Math.max(this.y - BODY_H, Math.min(ball.y, this.y));
      return Math.hypot(ball.x - nearX, ball.y - nearY) < FIREBALL_RADIUS;
    });
    if (!hit) return false;
    // It explodes right on Rog.
    this.fireballs = this.fireballs.filter((ball) => ball !== hit);
    this.explosions.push({ x: hit.x, y: hit.y, age: 0, ground: false });
    return true;
  }

  /** Are Rog's feet in a spike patch? (The very edges are safe, to be fair.) */
  private onSpikes(): boolean {
    return this.level.spikes.some(
      (patch) =>
        this.y > patch.roofY - SPIKE_HEIGHT + 2 &&
        this.y <= patch.roofY &&
        this.x + BODY_W / 2 > patch.x + 3 &&
        this.x - BODY_W / 2 < patch.x + patch.w - 3,
    );
  }

  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 get view(): GameView {
    const pose = this.pose();
    const cycle = pose === 'roll' ? 1 - this.rollTimer / ROLL_TIME : this.runCycle;
    return {
      level: this.level,
      levelIndex: this.levelIndex,
      x: this.x,
      y: this.y,
      facing: this.facing,
      shape: stickmanShape(pose, cycle, this.vy),
      pose,
      cycle,
      vy: this.vy,
      lines: this.lines,
      clock: this.clock,
      levelTime: this.time,
      springBounce: this.springBounce,
      dragon: this.dragon
        ? { ...this.dragon, warn: Math.max(0, (this.dragon.timer - (FIRE_EVERY - FIRE_WARNING)) / FIRE_WARNING) }
        : null,
      fireballs: this.fireballs,
      explosions: this.explosions,
      phase: this.phase,
    };
  }

  private draw(): void {
    const ctx = this.hudCtx;
    if (this.phase === 'boss' && this.boss) {
      // In first and third person the cave is drawn in 3D; in 2D it's all drawn flat on top.
      const project = this.world?.renderBoss?.(this.boss);
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, this.hud.width, this.hud.height);
      ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
      if (project && this.bossHud) this.bossHud(ctx, this.boss, project);
      else this.bossScene?.(ctx, this.boss);
      return;
    }
    this.world?.render(this.view);
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    ctx.clearRect(0, 0, this.hud.width, this.hud.height);
    ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
    if (this.phase === 'stairs' && this.stairsScene) {
      // The staircase scene covers the whole screen, whatever the view.
      this.stairsScene(ctx, this.phaseTimer);
      return;
    }
    this.drawHud();
  }

  private drawHud(): void {
    const ctx = this.hudCtx;
    // 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';
    // Under the right end of the bar, so the Menu button never covers it.
    ctx.textAlign = 'right';
    ctx.fillText(`${this.level.name}  ${this.time.toFixed(1)} s`, barX + barW, 38);

    if (this.notice && this.clock < this.notice.until) {
      ctx.globalAlpha = Math.min(1, (this.notice.until - this.clock) * 3);
      ctx.font = 'bold 13px system-ui, sans-serif';
      const width = ctx.measureText(this.notice.text).width + 24;
      ctx.fillStyle = 'rgba(29, 23, 36, 0.8)';
      ctx.fillRect(VIEW_W / 2 - width / 2, 52, width, 24);
      ctx.fillStyle = '#ffcc33';
      ctx.textAlign = 'center';
      ctx.fillText(this.notice.text, VIEW_W / 2, 69);
      ctx.globalAlpha = 1;
    }

    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';
      const message = {
        pit: 'Rog fell in the pit!',
        saw: 'Rog was chopped by a saw!',
        spikes: 'Rog fell on spikes!',
        fire: 'Rog was hit by a fireball!',
      }[this.fellBy];
      ctx.fillText(message, VIEW_W / 2, VIEW_H / 2 - 2);
      ctx.fillStyle = '#ffffff';
      ctx.font = '12px system-ui, sans-serif';
      const next =
        this.difficulty === 'easy'
          ? 'Back to the last building...'
          : this.difficulty === 'normal' || this.levelIndex === 0
            ? 'Back to the start...'
            : `Back to ${LEVELS[this.levelIndex - 1].name}!`;
      ctx.fillText(next, VIEW_W / 2, VIEW_H / 2 + 18);
    }
  }
}

export { VIEW_W, VIEW_H };