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/render3d.ts
import {
  AdditiveBlending,
  BoxGeometry,
  CanvasTexture,
  CatmullRomCurve3,
  Color,
  ConeGeometry,
  CylinderGeometry,
  DirectionalLight,
  DoubleSide,
  Fog,
  Group,
  HemisphereLight,
  InstancedMesh,
  Mesh,
  MeshBasicMaterial,
  MeshStandardMaterial,
  Object3D,
  PCFSoftShadowMap,
  PerspectiveCamera,
  PlaneGeometry,
  PointLight,
  Quaternion,
  RepeatWrapping,
  SRGBColorSpace,
  Scene,
  Shape,
  ShapeGeometry,
  SphereGeometry,
  Sprite,
  SpriteMaterial,
  TorusGeometry,
  TubeGeometry,
  Vector3,
  WebGLRenderer,
  type Material,
  type Texture,
} from 'three';
import { CLOTHES, EXPLOSION_TIME, FIREBALL_RADIUS, type Explosion, type GameView, type LineState, type WorldView } from './game.js';
import { SAW_RADIUS, SPIKE_HEIGHT, SPRING_HEIGHT, SPRING_WIDTH, lineHeightAt, roofHeightAt, sawCenter, type Block, type Clothesline, type Ice, type Level, type Saw, type Spikes, type Spring } from './level.js';
import { stickmanShape, type Point, type Pose } from './stickman.js';
import { BossCave3D } from './boss3d.js';
import type { BossFight } from './boss.js';
import { VIEW_H, VIEW_W } from './level.js';

/**
 * The 3D views of Stick Runner. The game still happens on a flat side-on line
 * (x across, y down); here every roof becomes a real building with depth.
 * 'first' person sees through Rog's eyes; 'third' person follows behind him.
 *
 * In 3D, x is the same, up is -y, and z comes out of the screen towards you.
 */

/** How deep (front to back) the buildings are. Rog runs along the middle (z = 0). */
const DEPTH = 120;
/** The dragon flies a little off to one side, so it never blocks the view of Rog. */
const DRAGON_Z = -40;
/** Buildings reach this far down so the pits look deep. */
const BUILDING_BASE = 1000;
const INK = '#1d1724';
const BANDANA = '#2fa84f';
const LIMB_RADIUS = 1.4;

const UP = new Vector3(0, 1, 0);

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

/** Make a texture by drawing on a little canvas. */
function paint(width: number, height: number, draw: (ctx: CanvasRenderingContext2D) => void): CanvasTexture {
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  draw(canvas.getContext('2d')!);
  const texture = new CanvasTexture(canvas);
  texture.colorSpace = SRGBColorSpace;
  texture.anisotropy = 4;
  return texture;
}

export class Renderer3D implements WorldView {
  private renderer: WebGLRenderer;
  private scene = new Scene();
  private camera = new PerspectiveCamera(72, 16 / 9, 0.5, 6000);
  private sun = new DirectionalLight('#fff1d6', 2.2);
  private sky = new HemisphereLight('#ffd9a0', '#3a3044', 1.3);
  private levelGroup = new Group();
  private builtLevel: Level | null = null;
  private builtLines: LineState[] | null = null;
  private rog = new Group();
  private bones: Mesh[] = [];
  private joints: Mesh[] = [];
  private head: Mesh;
  private bandana = new Group();
  private tails: Mesh[] = [];
  private lines: { state: LineState; whole: Group; halves: Group[]; clothes: Mesh[] }[] = [];
  private flagCloth: Mesh | null = null;
  private saws: { saw: Saw; blade: Group }[] = [];
  private springs: { spring: Spring; coil: Mesh; pad: Group }[] = [];
  private dragon: {
    body: Group;
    wings: Group[];
    jaw: Group;
    glow: Mesh;
    mouthLight: PointLight;
    smoke: Sprite[];
    tail: Group;
  } | null = null;
  private fireTextures: { glow: Texture; flame: Texture; smoke: Texture } | null = null;
  private fireballs: { group: Group; flames: Sprite[]; smoke: Sprite[]; shell: Mesh }[] = [];
  private blasts: { group: Group; flash: Sprite; fire: Sprite[]; smoke: Sprite[]; sparks: Mesh[]; light: PointLight }[] = [];
  /** How far to the side each explosion happened, remembered from when it first appeared. */
  private blastSide = new WeakMap<Explosion, number>();
  private sunSprite: Sprite | null = null;
  private iceMaterials: MeshStandardMaterial[] = [];
  /** Turns smoothly between looking right (1) and looking left (-1). */
  private lookDir = 1;
  /** Tips the view down when falling, smoothly. */
  private lookDown = 0;
  private mode: 'first' | 'third' = 'first';
  /** Rog's real look, kept so his body can be hidden in first person and shown in third. */
  private rogMaterials = new Map<Mesh, Material>();
  private invisible = new MeshStandardMaterial({ colorWrite: false, depthWrite: false });
  private chase = new Vector3();
  private chaseReady = false;
  /** The boss cave, built the first time the fight is shown in 3D. */
  private bossCave: BossCave3D | null = null;
  private inBoss = false;
  /** Which way the camera looks in the boss fight: towards the spider (1 = right, -1 = left). */
  private bossLook = 1;

  constructor(canvas: HTMLCanvasElement) {
    this.renderer = new WebGLRenderer({ canvas, antialias: true });
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    this.renderer.shadowMap.enabled = true;
    this.renderer.shadowMap.type = PCFSoftShadowMap;

    this.scene.add(this.sky, this.sun, this.sun.target, this.levelGroup, this.rog);
    this.sun.castShadow = true;
    this.sun.shadow.mapSize.set(1024, 1024);
    const shadow = this.sun.shadow.camera;
    shadow.left = -260;
    shadow.right = 260;
    shadow.top = 160;
    shadow.bottom = -160;
    shadow.near = 10;
    shadow.far = 900;

    // Rog: bones are thin dark cylinders, with little balls at the joints so they bend smoothly.
    const ink = new MeshStandardMaterial({ color: INK, roughness: 0.6 });
    const boneGeometry = new CylinderGeometry(LIMB_RADIUS, LIMB_RADIUS, 1, 8);
    const jointGeometry = new SphereGeometry(LIMB_RADIUS, 8, 6);
    for (let i = 0; i < 16; i++) {
      const bone = new Mesh(boneGeometry, ink);
      const joint = new Mesh(jointGeometry, ink);
      bone.castShadow = joint.castShadow = true;
      this.bones.push(bone);
      this.joints.push(joint);
      this.rog.add(bone, joint);
    }
    this.head = new Mesh(new SphereGeometry(1, 16, 12), ink);
    this.head.castShadow = true;
    this.rog.add(this.head);
    // Green bandana: a band round the forehead and two tails at the back.
    const green = new MeshStandardMaterial({ color: BANDANA, roughness: 0.7 });
    const band = new Mesh(new TorusGeometry(1.0, 0.22, 6, 20), green);
    band.rotation.x = Math.PI / 2;
    band.position.y = 0.45;
    band.scale.set(1, 1, 1.6);
    this.bandana.add(band);
    for (let i = 0; i < 2; i++) {
      const tail = new Mesh(new BoxGeometry(1, 0.32, 0.12), green);
      this.tails.push(tail);
      this.bandana.add(tail);
    }
    this.rog.add(this.bandana);
    this.rog.traverse((part) => {
      if (part instanceof Mesh) {
        this.rogMaterials.set(part, part.material as Material);
        part.castShadow = true;
      }
    });
    this.setMode('first');
  }

  /** First person hides Rog's body (we're inside his head, but his shadow still shows); third person shows him. */
  setMode(mode: 'first' | 'third'): void {
    this.mode = mode;
    this.chaseReady = false;
    for (const [mesh, material] of this.rogMaterials) mesh.material = mode === 'first' ? this.invisible : material;
    this.camera.fov = mode === 'first' ? 72 : 60;
    this.camera.updateProjectionMatrix();
  }

  resize(cssWidth: number, cssHeight: number): void {
    this.renderer.setSize(cssWidth, cssHeight, false);
    this.camera.aspect = cssWidth / Math.max(1, cssHeight);
    this.camera.updateProjectionMatrix();
  }

  render(view: GameView): void {
    if (this.inBoss) this.leaveBoss();
    // A new level (or a fresh start of the same one) gets a freshly built world.
    if (view.level !== this.builtLevel || view.lines !== this.builtLines) {
      this.buildLevel(view.level);
      this.builtLines = view.lines;
    }
    this.placeRog(view);
    this.animateLines(view);
    this.animateDetails(view);
    if (this.mode === 'first') this.lookThroughEyes(view);
    else this.followBehind(view);
    this.renderer.render(this.scene, this.camera);
  }

  // ---------- the boss fight ----------

  /**
   * Draw the spider fight in the cave, from Rog's eyes or from behind him, always looking towards
   * the spider. Returns a way to find where a point of the fight is on screen (for damage numbers).
   */
  renderBoss(fight: BossFight): (x: number, y: number) => { x: number; y: number } | null {
    if (!this.bossCave) {
      this.bossCave = new BossCave3D();
      this.scene.add(this.bossCave.group);
    }
    if (!this.inBoss) {
      // Swap the rooftops for the dark cave.
      this.inBoss = true;
      this.levelGroup.visible = false;
      this.bossCave.group.visible = true;
      this.scene.background = new Color('#2e2333');
      this.scene.fog = new Fog('#2e2333', 380, 1100);
      this.sky.intensity = 1.4;
      this.sun.intensity = 1.6;
      this.sun.color.set('#c8b8d8');
      this.chaseReady = false;
      this.bossLook = Math.sign(fight.spider.x - fight.rog.x) || 1;
      // The rooftops get built again afterwards.
      this.builtLevel = null;
    }
    this.bossCave.update(fight);

    // Rog: same poses as the flat fight, flickering while hurt.
    const { rog, spider } = fight;
    const lost = fight.result === 'lost';
    const pose: Pose = lost ? 'fall' : !rog.onGround ? 'jump' : Math.abs(rog.vx) > 10 ? 'run' : fight.result === 'won' ? 'cheer' : 'stand';
    const cycle = rog.cycle + fight.clock * (pose === 'cheer' ? 6 : 0);
    this.placeRog({ shape: stickmanShape(pose, cycle, rog.vy), x: rog.x, y: rog.y, facing: rog.facing, clock: fight.clock });
    this.rog.visible = !(rog.hurt > 0 && Math.floor(rog.hurt * 14) % 2 === 0);

    // Look towards the spider, turning round smoothly when Rog runs past it.
    const towards = Math.sign(spider.x - rog.x) || Math.sign(this.bossLook) || 1;
    this.bossLook += (towards - this.bossLook) * 0.08;
    const turn = ((1 - this.bossLook) / 2) * Math.PI;
    const ahead = new Vector3(Math.cos(turn), 0, -Math.sin(turn));
    const side = new Vector3(Math.sin(turn), 0, Math.cos(turn));
    const feet = new Vector3(rog.x, -rog.y, 0);
    if (this.mode === 'first') {
      // Through Rog's eyes, looking up a little at the giant spider.
      const eye = feet.clone().add(new Vector3(0, 31, 0)).addScaledVector(ahead, 2);
      this.camera.position.copy(eye);
      this.camera.lookAt(eye.clone().addScaledVector(ahead, 100).add(new Vector3(0, 32, 0)));
    } else {
      // Behind Rog and well off to the side (wider than the spider's body), so the camera never ends up inside it.
      const goal = new Vector3(rog.x, -238 + 48, 0).addScaledVector(ahead, -85).addScaledVector(side, 75);
      if (!this.chaseReady) {
        this.chase.copy(goal);
        this.chaseReady = true;
      }
      this.chase.lerp(goal, 0.12);
      this.camera.position.copy(this.chase);
      this.camera.lookAt(new Vector3(rog.x, -238 + 55, 0).addScaledVector(ahead, 110));
    }
    // The cave shakes after a big stomp.
    if (fight.shake > 0) {
      const s = fight.shake * 3;
      this.camera.position.add(new Vector3((Math.random() - 0.5) * s, (Math.random() - 0.5) * s, 0));
    }
    this.sun.position.set(240, 40, 140);
    this.sun.target.position.set(240, -238, 0);
    this.renderer.render(this.scene, this.camera);

    const camera = this.camera;
    return (x, y) => {
      const p = new Vector3(x, -y, 0).project(camera);
      if (p.z > 1) return null;
      return { x: ((p.x + 1) / 2) * VIEW_W, y: ((1 - p.y) / 2) * VIEW_H };
    };
  }

  /** Back to the rooftops after the fight (or when switching to 2D halfway through). */
  private leaveBoss(): void {
    this.inBoss = false;
    this.levelGroup.visible = true;
    if (this.bossCave) this.bossCave.group.visible = false;
    this.rog.visible = true;
    this.sky.intensity = 1.3;
    this.sun.intensity = 2.2;
    this.chaseReady = false;
  }

  // ---------- the camera ----------

  /** First-person: the camera is Rog's eyes. */
  private lookThroughEyes(view: GameView): void {
    // Turn round smoothly when he changes direction.
    // The camera always looks ahead, towards the finish. Running back (↓) doesn't turn it round.
    this.lookDir += (1 - this.lookDir) * 0.15;
    const turn = ((1 - this.lookDir) / 2) * Math.PI;
    // His eyes are in his head, so running bobs the view and rolling flips it.
    const head = view.shape.head;
    this.camera.position.set(view.x + view.facing * head.x, -view.y - head.y + 1.5, 0);
    const falling = view.phase === 'fell' ? 1 : 0;
    this.lookDown += (falling - this.lookDown) * 0.08;
    const roll = view.pose === 'roll' ? view.cycle * Math.PI * 2 : 0;
    const slideTilt = view.pose === 'slide' ? 0.06 : 0;
    this.camera.rotation.order = 'YXZ';
    this.camera.rotation.set(-0.16 - this.lookDown * 1.1 - roll, -Math.PI / 2 + turn, slideTilt * view.facing);
    // The sun hangs low in the sky ahead.
    this.sunSprite?.position.set(view.x + this.lookDir * 3200, 380, -900);
    // The sun's shadows follow Rog around.
    this.sun.position.set(view.x - 160, -view.y + 300, 260);
    this.sun.target.position.set(view.x, -view.y, 0);
  }

  /** Third-person: the camera follows behind Rog, a little above and to one side. */
  private followBehind(view: GameView): void {
    this.lookDir += (1 - this.lookDir) * 0.1;
    const turn = ((1 - this.lookDir) / 2) * Math.PI;
    const ahead = new Vector3(Math.cos(turn), 0, -Math.sin(turn));
    const side = new Vector3(Math.sin(turn), 0, Math.cos(turn));
    const rog = new Vector3(view.x, -view.y, 0);
    // Stay up at roof height when he falls, and look down at him instead.
    const falling = view.phase === 'fell';
    const anchor = new Vector3(view.x, -Math.min(view.y, falling ? 250 : view.y), 0);
    const goal = anchor.clone().addScaledVector(ahead, -95).addScaledVector(side, 18).add(new Vector3(0, 46, 0));
    if (!this.chaseReady) {
      this.chase.copy(goal);
      this.chaseReady = true;
    }
    this.chase.lerp(goal, 0.15);
    this.camera.position.copy(this.chase);
    const target = falling ? rog : rog.clone().addScaledVector(ahead, 120).add(new Vector3(0, 8, 0));
    this.camera.lookAt(target);
    this.sunSprite?.position.set(view.x + this.lookDir * 3200, 380, -900);
    this.sun.position.set(view.x - 160, -view.y + 300, 260);
    this.sun.target.position.set(view.x, -view.y, 0);
  }

  // ---------- Rog ----------

  private placeRog(view: Pick<GameView, 'shape' | 'x' | 'y' | 'facing' | 'clock'>): void {
    const { shape } = view;
    this.rog.position.set(view.x, -view.y, 0);
    this.rog.scale.set(view.facing, 1, 1);
    const toWorld = (p: Point, z: number) => new Vector3(p.x, -p.y, z);
    shape.bones.forEach((bone, i) => {
      const z = bone.side === 'near' ? 3 : bone.side === 'far' ? -3 : 0;
      this.setBone(this.bones[i], toWorld(bone.a, z), toWorld(bone.b, z));
      this.joints[i].position.copy(toWorld(bone.b, z));
      this.bones[i].visible = this.joints[i].visible = true;
    });
    for (let i = shape.bones.length; i < this.bones.length; i++) this.bones[i].visible = this.joints[i].visible = false;

    const r = shape.headRadius;
    this.head.position.copy(toWorld(shape.head, 0));
    this.head.scale.setScalar(r);
    this.bandana.position.copy(this.head.position);
    this.bandana.scale.setScalar(r);
    // Tails stream out behind when moving, and hang down when standing still.
    const flap = Math.sin(view.clock * 14);
    this.tails.forEach((tail, i) => {
      const angle = shape.moving ? Math.PI + 0.15 + (i === 0 ? flap : -flap) * 0.18 - i * 0.3 : -Math.PI / 2 - 0.25 + i * 0.2;
      tail.position.set(-0.95 + Math.cos(angle) * 0.75, 0.45 + Math.sin(angle) * 0.75, i === 0 ? 0.3 : -0.3);
      tail.rotation.z = angle;
      tail.scale.x = 1.5;
    });
  }

  /** Stretch a cylinder from a to b. */
  private setBone(mesh: Mesh, a: Vector3, b: Vector3): void {
    const direction = b.clone().sub(a);
    const length = direction.length() || 0.001;
    mesh.position.copy(a).addScaledVector(direction, 0.5);
    mesh.quaternion.copy(new Quaternion().setFromUnitVectors(UP, direction.divideScalar(length)));
    mesh.scale.set(1, length, 1);
  }

  // ---------- building the level ----------

  private buildLevel(level: Level): void {
    this.clearLevel();
    this.builtLevel = level;
    const { scenery } = level;

    // Sky, haze and light colours for this level.
    this.scene.background = paint(4, 256, (ctx) => {
      const gradient = ctx.createLinearGradient(0, 0, 0, 256);
      gradient.addColorStop(0, scenery.sky[0]);
      gradient.addColorStop(0.55, scenery.sky[1]);
      gradient.addColorStop(1, scenery.sky[2]);
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 4, 256);
    });
    this.scene.fog = new Fog(scenery.sky[2], 700, 2600);
    this.sky.color.set(scenery.sky[1]);
    this.sun.color.set(scenery.sun);

    this.addFarCity(level);
    this.addPitShadow(level);
    for (const block of level.blocks) {
      if (block.kind === 'roof') this.addBuilding(block);
      // Springs get their own stretchy model (addSpring).
      else if (block.kind !== 'spring') this.addProp(block);
    }
    for (const patch of level.ice) this.addIce(patch);
    for (const saw of level.saws) this.addSaw(saw);
    for (const patch of level.spikes) this.addSpikes(patch);
    for (const spring of level.springs) this.addSpring(spring);
    if (level.dragon) this.addDragon();
    this.addFlag(level);
  }

  private clearLevel(): void {
    this.levelGroup.traverse((object) => {
      if (object instanceof Mesh || object instanceof Sprite || object instanceof InstancedMesh) {
        object.geometry.dispose();
        const materials: Material[] = Array.isArray(object.material) ? object.material : [object.material];
        for (const material of materials) {
          const map = (material as MeshStandardMaterial).map as Texture | null;
          map?.dispose();
          material.dispose();
        }
      }
    });
    this.levelGroup.clear();
    this.lines = [];
    this.saws = [];
    this.springs = [];
    this.dragon = null;
    this.fireballs = [];
    this.blasts = [];
    this.iceMaterials = [];
    this.flagCloth = null;
    this.sunSprite = null;
  }

  /** Rows of faraway buildings, fading into the haze. */
  private addFarCity(level: Level): void {
    // Each row: how far back, colour, and how high the tallest tops reach (up is +).
    // Rows on both sides, since in first-person you see to the left and right.
    const rows = [
      { z: -900, color: level.scenery.farCity[0], low: -340, high: -170, width: 120, gap: 70 },
      { z: -1800, color: level.scenery.farCity[1], low: -260, high: 60, width: 180, gap: 90 },
      { z: 900, color: level.scenery.farCity[0], low: -340, high: -170, width: 120, gap: 70 },
      { z: 1800, color: level.scenery.farCity[1], low: -260, high: 60, width: 180, gap: 90 },
    ];
    rows.forEach((row, r) => {
      const count = Math.ceil((level.end + 3000) / (row.width + row.gap));
      const city = new InstancedMesh(new BoxGeometry(1, 1, 1), new MeshStandardMaterial({ color: row.color, roughness: 1 }), count);
      const dummy = new Object3D();
      for (let i = 0; i < count; i++) {
        const top = row.low + noise(i * 7.3 + r * 100) * (row.high - row.low);
        const bottom = -BUILDING_BASE;
        dummy.position.set(-1400 + i * (row.width + row.gap), (top + bottom) / 2, row.z);
        dummy.scale.set(row.width, top - bottom, 80);
        dummy.updateMatrix();
        city.setMatrixAt(i, dummy.matrix);
      }
      this.levelGroup.add(city);
    });
  }

  /** Darkness at the bottom of the pits, behind the buildings, so the gaps look deep and scary. */
  private addPitShadow(level: Level): void {
    const texture = paint(4, 256, (ctx) => {
      const gradient = ctx.createLinearGradient(0, 0, 0, 256);
      gradient.addColorStop(0, 'rgba(20, 10, 18, 0)');
      gradient.addColorStop(0.12, 'rgba(20, 10, 18, 0.9)');
      gradient.addColorStop(1, 'rgba(8, 4, 8, 1)');
      ctx.fillStyle = gradient;
      ctx.fillRect(0, 0, 4, 256);
    });
    const plane = new Mesh(
      new PlaneGeometry(level.end + 2000, 900),
      new MeshBasicMaterial({ map: texture, transparent: true, fog: false, depthWrite: false }),
    );
    plane.position.set(level.end / 2, -200 - 450, -DEPTH / 2 - 2);
    this.levelGroup.add(plane);
    // A dark floor far down at the bottom of the pits, for looking straight down into them.
    const floor = new Mesh(new PlaneGeometry(level.end + 4000, 3000), new MeshBasicMaterial({ color: '#0a0508', fog: false }));
    floor.rotation.x = -Math.PI / 2;
    floor.position.set(level.end / 2, -520, 0);
    this.levelGroup.add(floor);
    // The sun, low in the sky far behind the city.
    const sun = new Sprite(
      new SpriteMaterial({
        map: paint(128, 128, (ctx) => {
          const glow = ctx.createRadialGradient(64, 64, 20, 64, 64, 64);
          glow.addColorStop(0, level.scenery.sun);
          glow.addColorStop(0.45, level.scenery.sun);
          glow.addColorStop(0.5, 'rgba(255, 244, 220, 0.35)');
          glow.addColorStop(1, 'rgba(255, 244, 220, 0)');
          ctx.fillStyle = glow;
          ctx.fillRect(0, 0, 128, 128);
        }),
        fog: false,
        depthWrite: false,
      }),
    );
    sun.scale.set(700, 700, 1);
    sun.renderOrder = -1;
    this.sunSprite = sun;
    this.levelGroup.add(sun);
  }

  private addBuilding(block: Block): void {
    const height = BUILDING_BASE - block.y;
    const windows = this.windowTexture(block);
    windows.wrapS = windows.wrapT = RepeatWrapping;
    windows.repeat.set(block.w / 104, height / 104);
    const wall = new MeshStandardMaterial({ color: '#4a3f55', roughness: 0.9 });
    const front = new MeshStandardMaterial({ map: windows, roughness: 0.85 });
    const roof = new MeshStandardMaterial({ color: '#6e6280', roughness: 0.95 });
    // The top of the roof gets tar tiles, so you can see them rush past as you run.
    const tiles = this.roofTexture();
    tiles.wrapS = tiles.wrapT = RepeatWrapping;
    tiles.repeat.set(block.w / 40, DEPTH / 40);
    const roofTop = new MeshStandardMaterial({ map: tiles, roughness: 0.95 });
    const building = new Mesh(new BoxGeometry(block.w, height, DEPTH), [wall, wall, roofTop, wall, front, wall]);
    building.position.set(block.x + block.w / 2, -block.y - height / 2, 0);
    building.receiveShadow = true;
    building.castShadow = true;
    this.levelGroup.add(building);

    // A ledge round the top of the roof.
    const ledge = new Mesh(new BoxGeometry(block.w + 6, 5, DEPTH + 6), roof);
    ledge.position.set(block.x + block.w / 2, -block.y - 2.5, 0);
    ledge.receiveShadow = true;
    this.levelGroup.add(ledge);

    // Yellow and black warning stripes along the edges of each pit.
    for (const x of [block.x - 3, block.x + block.w - 9]) {
      const stripe = new Mesh(new BoxGeometry(12, 1, DEPTH + 6), new MeshStandardMaterial({ map: this.stripeTexture(), roughness: 0.8 }));
      stripe.position.set(x + 6, -block.y + 0.6, 0);
      stripe.receiveShadow = true;
      this.levelGroup.add(stripe);
    }
  }

  /** Four-by-four windows, some lit and some dark, that repeat across the front of a building. */
  private windowTexture(block: Block): CanvasTexture {
    return paint(128, 128, (ctx) => {
      ctx.fillStyle = '#4a3f55';
      ctx.fillRect(0, 0, 128, 128);
      for (let row = 0; row < 4; row++) {
        for (let col = 0; col < 4; col++) {
          const lit = noise(block.x * 0.37 + row * 1.7 + col * 3.1) > 0.55;
          ctx.fillStyle = lit ? '#ffd27a' : '#3a3044';
          ctx.fillRect(col * 32 + 9, row * 32 + 8, 13, 16);
        }
      }
    });
  }

  private roofTexture(): CanvasTexture {
    return paint(64, 64, (ctx) => {
      ctx.fillStyle = '#6a5e7a';
      ctx.fillRect(0, 0, 64, 64);
      // Speckles of gravel.
      for (let i = 0; i < 90; i++) {
        ctx.fillStyle = noise(i * 1.3) > 0.5 ? '#76698a' : '#5d526c';
        ctx.fillRect(noise(i * 2.7) * 64, noise(i * 4.1) * 64, 2, 2);
      }
      // Seams between the tiles.
      ctx.fillStyle = '#4f4560';
      ctx.fillRect(0, 0, 64, 2);
      ctx.fillRect(0, 0, 2, 64);
    });
  }

  private stripeTexture(): CanvasTexture {
    return paint(32, 8, (ctx) => {
      ctx.fillStyle = '#ffcc33';
      ctx.fillRect(0, 0, 32, 8);
      ctx.fillStyle = '#2b2233';
      ctx.fillRect(11, 0, 10, 8);
    });
  }

  private addProp(block: Block): void {
    let mesh: Mesh;
    const cx = block.x + block.w / 2;
    if (block.kind === 'tank') {
      mesh = new Mesh(new CylinderGeometry(block.w / 2, block.w / 2, block.h, 16), new MeshStandardMaterial({ color: '#7b5a3f', roughness: 0.9 }));
    } else if (block.kind === 'vent') {
      mesh = new Mesh(new BoxGeometry(block.w, block.h, 28), new MeshStandardMaterial({ color: '#8f9aa6', roughness: 0.5, metalness: 0.4 }));
    } else {
      mesh = new Mesh(new BoxGeometry(block.w, block.h, 22), new MeshStandardMaterial({ color: '#8a4b3c', roughness: 0.95 }));
      // A darker cap on top of the chimney.
      const cap = new Mesh(new BoxGeometry(block.w + 4, 5, 26), new MeshStandardMaterial({ color: '#6b3a2f', roughness: 0.95 }));
      cap.position.set(cx, -block.y - 2.5, 0);
      cap.castShadow = true;
      this.levelGroup.add(cap);
    }
    mesh.position.set(cx, -block.y - block.h / 2, 0);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    this.levelGroup.add(mesh);
  }

  /**
   * A red dragon: scaly body, yellow belly plates, bat wings with bones, spikes down its back,
   * a long toothy snout with a jaw that opens, slit-eyed, horned, clawed, with a spade-tipped tail.
   */
  private addDragon(): void {
    this.makeFireTextures();
    const scales = paint(128, 64, (ctx) => {
      const shade = ctx.createLinearGradient(0, 0, 0, 64);
      shade.addColorStop(0, '#7e1b26');
      shade.addColorStop(0.5, '#c43d3d');
      shade.addColorStop(1, '#a52f37');
      ctx.fillStyle = shade;
      ctx.fillRect(0, 0, 128, 64);
      ctx.strokeStyle = 'rgba(60, 8, 16, 0.45)';
      ctx.lineWidth = 1.2;
      for (let row = 0; row < 64; row += 6) {
        for (let x = (row / 6) % 2 ? 4 : 0; x < 132; x += 8) {
          ctx.beginPath();
          ctx.arc(x, row, 4, 0.15, Math.PI - 0.15);
          ctx.stroke();
        }
      }
    });
    scales.wrapS = scales.wrapT = RepeatWrapping;
    scales.repeat.set(3, 2);
    const plates = paint(64, 32, (ctx) => {
      ctx.fillStyle = '#e8a83e';
      ctx.fillRect(0, 0, 64, 32);
      ctx.fillStyle = 'rgba(122, 60, 20, 0.6)';
      for (let x = 0; x < 64; x += 8) ctx.fillRect(x, 0, 1.5, 32);
    });
    const red = new MeshStandardMaterial({ map: scales, roughness: 0.5, metalness: 0.05 });
    const redPlain = new MeshStandardMaterial({ color: '#b8343c', roughness: 0.55 });
    const darkRed = new MeshStandardMaterial({ color: '#5e1520', roughness: 0.7 });
    const bone = new MeshStandardMaterial({ color: '#f2ede4', roughness: 0.4 });
    const skin = new MeshStandardMaterial({ color: '#8e2230', roughness: 0.8, side: DoubleSide, transparent: true, opacity: 0.95 });
    const ball = (sx: number, sy: number, sz: number, material: Material, x: number, y: number, z: number) => {
      const mesh = new Mesh(new SphereGeometry(1, 18, 12), material);
      mesh.scale.set(sx, sy, sz);
      mesh.position.set(x, y, z);
      mesh.castShadow = true;
      return mesh;
    };
    const cone = (radius: number, height: number, material: Material, x: number, y: number, z: number, tilt: number) => {
      const mesh = new Mesh(new ConeGeometry(radius, height, 6), material);
      mesh.position.set(x, y, z);
      mesh.rotation.z = tilt;
      return mesh;
    };

    const body = new Group();
    body.add(ball(18, 10, 10, red, 0, 0, 0));
    body.add(ball(13, 5, 8.5, new MeshStandardMaterial({ map: plates, roughness: 0.7 }), 1, -5, 0));
    const neck = ball(9, 6, 6, red, 17, 6, 0);
    neck.rotation.z = 0.55;
    body.add(neck);
    // Spikes down the back and neck.
    for (let x = 14; x >= -16; x -= 4.5) {
      const top = 10 * Math.sqrt(Math.max(0, 1 - (x / 18) ** 2));
      body.add(cone(1.6, 5, darkRed, x, top + 1.5, 0, 0.35));
    }
    for (const [x, y] of [[15, 11.5], [20, 12.5], [24.5, 11]]) body.add(cone(1.3, 4, darkRed, x, y, 0, 0.4));

    // Head: skull, long snout, teeth, nostrils, slit eyes, brows and horns.
    const head = new Group();
    head.position.set(28, 5, 0);
    head.add(ball(7, 6, 6, red, 0, 0, 0));
    const snout = new Mesh(new CylinderGeometry(2.8, 4.6, 14, 12), redPlain);
    snout.rotation.z = -Math.PI / 2;
    snout.position.set(9, -1, 0);
    head.add(snout);
    for (let x = 5; x <= 14; x += 2.3) {
      for (const z of [-2.2, 2.2]) head.add(cone(0.5, 2, bone, x, -3.8, z, Math.PI));
    }
    for (const z of [-1.4, 1.4]) head.add(ball(0.7, 0.5, 0.5, darkRed, 15.8, 0.6, z));
    for (const z of [-4.3, 4.3]) {
      const eye = ball(1.9, 1.5, 1, new MeshStandardMaterial({ color: '#ffd23a', emissive: '#ffb000', emissiveIntensity: 0.7 }), 2.5, 2, z);
      head.add(eye);
      const pupil = new Mesh(new BoxGeometry(0.5, 2.4, 0.4), new MeshBasicMaterial({ color: '#1d1724' }));
      pupil.position.set(3.2, 2, z * 1.06);
      head.add(pupil);
      const brow = new Mesh(new BoxGeometry(6, 1.2, 1.4), darkRed);
      brow.position.set(2.5, 4, z * 0.95);
      brow.rotation.z = -0.15;
      head.add(brow);
      // Horns sweep back in two curved pieces.
      head.add(cone(1.8, 8, bone, -3, 6.5, z * 0.7, 1.0));
      head.add(cone(1.0, 6, bone, -8.5, 9.5, z * 0.7, 1.6));
    }
    // The lower jaw hinges open before it breathes fire.
    const jaw = new Group();
    jaw.position.set(1, -3.5, 0);
    const jawBone = new Mesh(new CylinderGeometry(1.6, 3.4, 13, 10), darkRed);
    jawBone.rotation.z = -Math.PI / 2;
    jawBone.position.set(7, -0.5, 0);
    jaw.add(jawBone);
    for (let x = 4; x <= 12; x += 2.3) {
      for (const z of [-1.6, 1.6]) jaw.add(cone(0.45, 1.8, bone, x, 1.4, z, 0));
    }
    head.add(jaw);
    const glow = new Mesh(
      new SphereGeometry(1, 14, 10),
      new MeshBasicMaterial({ color: '#ffb02e', transparent: true, opacity: 0.9, blending: AdditiveBlending, depthWrite: false }),
    );
    glow.position.set(12, -3, 0);
    head.add(glow);
    const mouthLight = new PointLight('#ff8a2a', 0, 160, 2);
    mouthLight.position.set(14, -3, 0);
    head.add(mouthLight);
    const smoke: Sprite[] = [];
    for (let i = 0; i < 3; i++) {
      const puff = new Sprite(new SpriteMaterial({ map: this.fireTextures!.smoke, transparent: true, depthWrite: false }));
      head.add(puff);
      smoke.push(puff);
    }
    body.add(head);

    // Legs with white claws.
    for (const [x, y, size] of [[-8, -7, 1], [11, -8, 0.8]]) {
      for (const z of [-5, 5]) {
        body.add(ball(5 * size, 4 * size, 3.5 * size, red, x, y, z));
        const shin = new Mesh(new CylinderGeometry(1.4 * size, 1.1 * size, 7 * size, 8), redPlain);
        shin.position.set(x - 2, y - 5 * size, z);
        shin.rotation.z = -0.5;
        body.add(shin);
        for (const dz of [-1.2, 0, 1.2]) body.add(cone(0.5, 2.4, bone, x - 4, y - 9 * size, z + dz, Math.PI + 0.4));
      }
    }

    // Tail: tapering, curving, with a spade on the end. It sways as it flies.
    const tail = new Group();
    tail.position.set(-15, 1, 0);
    for (let i = 0; i <= 14; i++) {
      const t = i / 14;
      const radius = 5.5 * (1 - t) + 0.9;
      tail.add(ball(radius * 1.4, radius, radius, red, -t * 40, -Math.sin(t * Math.PI) * 5 + t * 8, 0));
    }
    const spade = new Mesh(new ConeGeometry(5, 9, 4), darkRed);
    spade.scale.z = 0.3;
    spade.rotation.z = Math.PI / 2;
    spade.position.set(-44, 9, 0);
    tail.add(spade);
    body.add(tail);

    // Bat wings: bony arm and fingers with skin stretched between, scalloped at the edge.
    const outline = new Shape();
    outline.moveTo(2, 0);
    outline.lineTo(-4, 16);
    outline.lineTo(-2, 30);
    outline.lineTo(-24, 33);
    outline.quadraticCurveTo(-25, 25, -34, 19);
    outline.quadraticCurveTo(-27, 12, -28, 4);
    outline.quadraticCurveTo(-18, 5, -10, 0);
    outline.closePath();
    const wingShape = new ShapeGeometry(outline);
    const wings: Group[] = [];
    for (const side of [-1, 1]) {
      const hinge = new Group();
      hinge.position.set(2, 7, side * 5);
      const wing = new Mesh(wingShape, skin);
      wing.rotation.x = (side * Math.PI) / 2;
      wing.castShadow = true;
      const bones: [number, number, number, number, number][] = [
        [2, 0, -4, 16, 1.1],
        [-4, 16, -2, 30, 1],
        [-2, 30, -24, 33, 0.6],
        [-2, 30, -34, 19, 0.6],
        [-2, 30, -28, 4, 0.6],
      ];
      for (const [x1, y1, x2, y2, r] of bones) {
        const from = new Vector3(x1, y1, 0);
        const to = new Vector3(x2, y2, 0);
        const rod = new Mesh(new CylinderGeometry(r, r * 0.6, from.distanceTo(to), 6), darkRed);
        rod.position.copy(from).add(to).multiplyScalar(0.5);
        rod.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), to.clone().sub(from).normalize());
        wing.add(rod);
      }
      hinge.add(wing);
      hinge.userData.side = side;
      body.add(hinge);
      wings.push(hinge);
    }

    this.levelGroup.add(body);
    this.dragon = { body, wings, jaw, glow, mouthLight, smoke, tail };
  }

  /** Grab enough explosions for the ones going off right now: a flash, fire, smoke, sparks and light. */
  private blastMesh(index: number) {
    while (this.blasts.length <= index) {
      const textures = this.fireTextures!;
      const group = new Group();
      const additive = (map: Texture, color = '#ffffff') =>
        new Sprite(new SpriteMaterial({ map, color, transparent: true, blending: AdditiveBlending, depthWrite: false }));
      const flash = additive(textures.glow, '#ffe8a0');
      group.add(flash);
      const fire: Sprite[] = [];
      for (let i = 0; i < 9; i++) {
        const flame = additive(textures.flame);
        group.add(flame);
        fire.push(flame);
      }
      const smoke: Sprite[] = [];
      for (let i = 0; i < 6; i++) {
        const puff = new Sprite(new SpriteMaterial({ map: textures.smoke, transparent: true, depthWrite: false }));
        group.add(puff);
        smoke.push(puff);
      }
      const sparks: Mesh[] = [];
      const sparkShape = new SphereGeometry(0.9, 6, 4);
      for (let i = 0; i < 14; i++) {
        const spark = new Mesh(sparkShape, new MeshBasicMaterial({ color: '#ffc04a', transparent: true }));
        group.add(spark);
        sparks.push(spark);
      }
      const light = new PointLight('#ff8a2a', 0, 260, 2);
      light.position.y = 10;
      group.add(light);
      this.levelGroup.add(group);
      this.blasts.push({ group, flash, fire, smoke, sparks, light });
    }
    return this.blasts[index];
  }

  private animateBlasts(view: GameView): void {
    view.explosions.forEach((blast, i) => {
      const { group, flash, fire, smoke, sparks, light } = this.blastMesh(i);
      if (!this.blastSide.has(blast)) {
        const from = view.dragon?.x ?? blast.x;
        const along = Math.max(0, Math.min(1, (blast.x - from) / Math.max(1, view.x - from)));
        this.blastSide.set(blast, DRAGON_Z * (1 - along));
      }
      const t = blast.age / EXPLOSION_TIME;
      const seed = Math.floor(blast.x * 3.1);
      group.visible = true;
      group.position.set(blast.x, -blast.y, this.blastSide.get(blast)!);
      // Bright flash and a burst of light at the start.
      const f = Math.min(1, t / 0.25);
      flash.visible = t < 0.25;
      flash.scale.setScalar(30 + f * 90);
      (flash.material as SpriteMaterial).opacity = 1 - f;
      light.intensity = 9000 * Math.max(0, 1 - t / 0.5) ** 2;
      // Fire puffs out and up, then fades.
      const g = Math.min(1, t / 0.6);
      fire.forEach((flame, k) => {
        const angle = (k / fire.length) * Math.PI * 2;
        const reach = (12 + noise(seed + k) * 20) * Math.sqrt(g);
        flame.visible = t < 0.6;
        flame.position.set(Math.cos(angle) * reach, 4 + Math.abs(Math.sin(angle)) * reach * 0.8 + g * 10, Math.sin(angle) * reach);
        flame.scale.setScalar((20 + noise(seed + k * 3) * 14) * (1 - g * 0.4));
        (flame.material as SpriteMaterial).opacity = 1 - g;
      });
      // Smoke rises afterwards.
      smoke.forEach((puff, k) => {
        const start = 0.15 + k * 0.05;
        const s = Math.max(0, (t - start) / (1 - start));
        puff.visible = t >= start;
        puff.position.set((noise(seed + k) - 0.5) * 30 * s, 8 + s * 40 + k * 3, (noise(seed + k * 9) - 0.5) * 20 * s);
        puff.scale.setScalar(14 + s * 30);
        (puff.material as SpriteMaterial).opacity = 0.9 * (1 - s);
      });
      // Sparks fly out in arcs and fall.
      sparks.forEach((spark, k) => {
        const angle = (k / sparks.length) * Math.PI * 2 + noise(seed + k) * 0.5;
        const up = 90 + noise(seed + k * 7) * 110;
        const out = 70 + noise(seed + k * 11) * 100;
        const a = blast.age;
        const y = up * a - 130 * a * a;
        spark.visible = !(blast.ground && y < 0) && t < 1;
        spark.position.set(Math.cos(angle) * out * a, y, Math.sin(angle) * out * a);
        (spark.material as MeshBasicMaterial).opacity = 1 - t;
      });
    });
    for (let i = view.explosions.length; i < this.blasts.length; i++) {
      this.blasts[i].group.visible = false;
      this.blasts[i].light.intensity = 0;
    }
  }

  /** Soft round textures for fire glow, flames and smoke. */
  private makeFireTextures(): void {
    const blob = (stops: [number, string][]) =>
      paint(64, 64, (ctx) => {
        const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
        for (const [at, color] of stops) gradient.addColorStop(at, color);
        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, 64, 64);
      });
    this.fireTextures = {
      glow: blob([[0, 'rgba(255,255,255,1)'], [0.3, 'rgba(255,255,255,0.45)'], [1, 'rgba(255,255,255,0)']]),
      flame: blob([[0, 'rgba(255,240,170,1)'], [0.35, 'rgba(255,150,40,0.85)'], [0.7, 'rgba(210,50,15,0.35)'], [1, 'rgba(150,20,10,0)']]),
      smoke: blob([[0, 'rgba(80,72,78,0.55)'], [0.6, 'rgba(80,72,78,0.25)'], [1, 'rgba(80,72,78,0)']]),
    };
  }

  /** Grab enough fireballs for the ones in the air right now: a white-hot core, flames, a glow and smoke. */
  private fireballMesh(index: number): { group: Group; flames: Sprite[]; smoke: Sprite[]; shell: Mesh } {
    while (this.fireballs.length <= index) {
      const textures = this.fireTextures!;
      const group = new Group();
      const core = new Mesh(new SphereGeometry(FIREBALL_RADIUS * 0.6, 14, 10), new MeshBasicMaterial({ color: '#fff6d0' }));
      group.add(core);
      const shell = new Mesh(
        new SphereGeometry(FIREBALL_RADIUS, 16, 12),
        new MeshBasicMaterial({ color: '#ff8a1e', transparent: true, opacity: 0.8, blending: AdditiveBlending, depthWrite: false }),
      );
      group.add(shell);
      const glow = new Sprite(
        new SpriteMaterial({ map: textures.glow, color: '#ff7a1a', transparent: true, blending: AdditiveBlending, depthWrite: false }),
      );
      glow.scale.setScalar(FIREBALL_RADIUS * 6);
      group.add(glow);
      const flames: Sprite[] = [];
      for (let i = 0; i < 6; i++) {
        const flame = new Sprite(
          new SpriteMaterial({ map: textures.flame, transparent: true, blending: AdditiveBlending, depthWrite: false }),
        );
        group.add(flame);
        flames.push(flame);
      }
      const smoke: Sprite[] = [];
      for (let i = 0; i < 6; i++) {
        const puff = new Sprite(new SpriteMaterial({ map: textures.smoke, transparent: true, depthWrite: false }));
        group.add(puff);
        smoke.push(puff);
      }
      // It lights up the roofs and Rog as it flies past.
      group.add(new PointLight('#ff8a2a', 3500, 200, 2));
      this.levelGroup.add(group);
      this.fireballs.push({ group, flames, smoke, shell });
    }
    return this.fireballs[index];
  }

  /** A little stairwell hut on the roof with a dark open doorway and a yellow sign: the way down. */
  private addStairwell(x: number, ground: number): void {
    const wall = new MeshStandardMaterial({ color: '#6e6280', roughness: 0.8 });
    const hut = new Mesh(new BoxGeometry(40, 40, 46), wall);
    hut.position.set(x + 16, -ground + 20, 0);
    hut.castShadow = true;
    this.levelGroup.add(hut);
    const roof = new Mesh(new BoxGeometry(46, 4, 52), new MeshStandardMaterial({ color: '#4a3f55', roughness: 0.8 }));
    roof.position.set(x + 16, -ground + 42, 0);
    this.levelGroup.add(roof);
    // The open doorway faces Rog as he runs up to it.
    const door = new Mesh(new PlaneGeometry(26, 32), new MeshBasicMaterial({ color: '#1d1724' }));
    door.rotation.y = -Math.PI / 2;
    door.position.set(x - 4.2, -ground + 16, 0);
    this.levelGroup.add(door);
    const sign = new Mesh(
      new PlaneGeometry(30, 14),
      new MeshBasicMaterial({
        map: paint(128, 64, (ctx) => {
          ctx.fillStyle = '#ffcc33';
          ctx.fillRect(0, 0, 128, 64);
          ctx.fillStyle = '#1d1724';
          ctx.font = 'bold 30px system-ui, sans-serif';
          ctx.textAlign = 'center';
          ctx.fillText('↓ STAIRS', 64, 43);
        }),
      }),
    );
    sign.rotation.y = -Math.PI / 2;
    sign.position.set(x - 4.3, -ground + 52, 0);
    this.levelGroup.add(sign);
  }

  /** A spring: a base plate, a shiny metal coil and a red pad with a yellow ring on top. */
  private addSpring(spring: Spring): void {
    const middle = new Vector3(spring.x + SPRING_WIDTH / 2, -spring.roofY, 0);
    const base = new Mesh(
      new CylinderGeometry(SPRING_WIDTH / 2 + 2, SPRING_WIDTH / 2 + 2, 2, 24),
      new MeshStandardMaterial({ color: '#3a3044', roughness: 0.7 }),
    );
    base.position.copy(middle).add(new Vector3(0, 1, 0));
    this.levelGroup.add(base);
    // A coil as tall as the spring rests (it stretches when Rog bounces).
    const points: Vector3[] = [];
    for (let i = 0; i <= 80; i++) {
      const a = (i / 80) * Math.PI * 2 * 4;
      points.push(new Vector3(Math.cos(a) * (SPRING_WIDTH / 2 - 3), (i / 80) * (SPRING_HEIGHT - 4), Math.sin(a) * (SPRING_WIDTH / 2 - 3)));
    }
    const coil = new Mesh(
      new TubeGeometry(new CatmullRomCurve3(points), 160, 1.2, 6),
      new MeshStandardMaterial({ color: '#c9d1d9', metalness: 0.8, roughness: 0.3 }),
    );
    coil.position.copy(middle).add(new Vector3(0, 2, 0));
    coil.castShadow = true;
    this.levelGroup.add(coil);
    const pad = new Group();
    const top = new Mesh(new CylinderGeometry(SPRING_WIDTH / 2 + 1, SPRING_WIDTH / 2 + 1, 3, 24), new MeshStandardMaterial({ color: '#e5484d', roughness: 0.5 }));
    pad.add(top);
    const ring = new Mesh(new TorusGeometry(SPRING_WIDTH / 2 - 4, 0.9, 8, 24), new MeshStandardMaterial({ color: '#ffcc33', roughness: 0.4 }));
    ring.rotation.x = Math.PI / 2;
    ring.position.y = 1.6;
    pad.add(ring);
    pad.position.copy(middle);
    this.levelGroup.add(pad);
    // The pad is wide, so it's an oval that fits across the roof.
    const fit = Math.min(1, 100 / SPRING_WIDTH);
    base.scale.z = coil.scale.z = pad.scale.z = fit;
    this.springs.push({ spring, coil, pad });
  }

  /** A bed of metal spikes across the whole roof, so there's no way round: jump it. */
  private addSpikes(patch: Spikes): void {
    const plate = new Mesh(
      new BoxGeometry(patch.w, 2, DEPTH),
      new MeshStandardMaterial({ color: '#3a3044', roughness: 0.8 }),
    );
    plate.position.set(patch.x + patch.w / 2, -patch.roofY + 1, 0);
    this.levelGroup.add(plate);
    const across = Math.max(2, Math.round(patch.w / 7));
    const deep = Math.round(DEPTH / 7);
    const spikes = new InstancedMesh(
      new ConeGeometry(3, SPIKE_HEIGHT - 2, 6),
      new MeshStandardMaterial({ color: '#c9d1d9', metalness: 0.7, roughness: 0.35 }),
      across * deep,
    );
    const place = new Object3D();
    for (let i = 0; i < across; i++) {
      for (let j = 0; j < deep; j++) {
        place.position.set(patch.x + (i + 0.5) * (patch.w / across), -patch.roofY + 2 + (SPIKE_HEIGHT - 2) / 2, -DEPTH / 2 + (j + 0.5) * (DEPTH / deep));
        place.updateMatrix();
        spikes.setMatrixAt(i * deep + j, place.matrix);
      }
    }
    spikes.castShadow = true;
    this.levelGroup.add(spikes);
  }

  /** A spinning saw blade standing up in a slot on the roof, turned to face Rog so it's easy to see coming. */
  private addSaw(saw: Saw): void {
    const slot = new Mesh(
      new BoxGeometry(saw.x2 - saw.x1 + 6, 1, SAW_RADIUS * 2 + 6),
      new MeshStandardMaterial({ color: '#1d1724', roughness: 0.9 }),
    );
    slot.position.set((saw.x1 + saw.x2) / 2, -saw.roofY + 0.6, 0);
    this.levelGroup.add(slot);

    const blade = new Group();
    const steel = new MeshStandardMaterial({ color: '#c9d1d9', metalness: 0.8, roughness: 0.3 });
    const disc = new Mesh(new CylinderGeometry(SAW_RADIUS - 3, SAW_RADIUS - 3, 2.5, 28), steel);
    disc.rotation.z = Math.PI / 2;
    disc.castShadow = true;
    blade.add(disc);
    // Sharp teeth all the way round.
    for (let i = 0; i < 12; i++) {
      const a = (i / 12) * Math.PI * 2;
      const tooth = new Mesh(new ConeGeometry(2.6, 6, 4), steel);
      tooth.position.set(0, Math.cos(a) * (SAW_RADIUS - 2), Math.sin(a) * (SAW_RADIUS - 2));
      tooth.rotation.x = a;
      blade.add(tooth);
    }
    const hub = new Mesh(new CylinderGeometry(4, 4, 4, 16), new MeshStandardMaterial({ color: '#e5484d', roughness: 0.5 }));
    hub.rotation.z = Math.PI / 2;
    blade.add(hub);
    this.levelGroup.add(blade);
    this.saws.push({ saw, blade });
  }

  private addIce(patch: Ice): void {
    // A slab of clear blue ice over the end of the roof.
    const material = new MeshStandardMaterial({
      color: '#bfe9ff',
      roughness: 0.08,
      metalness: 0.1,
      transparent: true,
      opacity: 0.85,
      emissive: '#3a6f8f',
      emissiveIntensity: 0.25,
    });
    this.iceMaterials.push(material);
    const slab = new Mesh(new BoxGeometry(patch.w + 3, 6, DEPTH + 8), material);
    slab.position.set(patch.x + patch.w / 2 + 1.5, -patch.y + 1, 0);
    slab.receiveShadow = true;
    this.levelGroup.add(slab);
    // Icicles hanging off the front edge, longer near the edge of the pit.
    const icicleMaterial = new MeshStandardMaterial({ color: '#dff4ff', roughness: 0.1, transparent: true, opacity: 0.85 });
    for (let x = patch.x + 3; x < patch.x + patch.w; x += 3 + noise(x * 0.37) * 5) {
      const nearEdge = (x - patch.x) / patch.w;
      const length = 4 + noise(x * 0.71) * 10 * (0.4 + nearEdge);
      const icicle = new Mesh(new ConeGeometry(1.3, length, 6), icicleMaterial);
      icicle.rotation.x = Math.PI;
      icicle.position.set(x, -patch.y - 5 - length / 2, DEPTH / 2 + 3);
      this.levelGroup.add(icicle);
    }
  }

  private addLines(states: LineState[]): void {
    if (this.lines.length > 0 || states.length === 0) return;
    const rope = new MeshStandardMaterial({ color: '#f2ede4', roughness: 0.9 });
    const hook = new MeshStandardMaterial({ color: '#9aa3ad', metalness: 0.6, roughness: 0.4 });
    for (const state of states) {
      const { line } = state;
      // The whole rope, sagging in the middle.
      const whole = new Group();
      const points: Vector3[] = [];
      for (let i = 0; i <= 24; i++) {
        const x = line.x1 + ((line.x2 - line.x1) * i) / 24;
        points.push(new Vector3(x, -lineHeightAt(line, x), 0));
      }
      const tube = new Mesh(new TubeGeometry(new CatmullRomCurve3(points), 48, 0.7, 6), rope);
      tube.castShadow = true;
      whole.add(tube);
      // Laundry pegged along it.
      const clothes: Mesh[] = [];
      for (const [t, kind, colour] of CLOTHES) {
        const size = kind === 'shirt' ? [14, 15] : kind === 'pants' ? [13, 17] : [4, 11];
        const cloth = new Mesh(new BoxGeometry(size[0], size[1], 1), new MeshStandardMaterial({ color: colour, roughness: 0.9 }));
        const x = line.x1 + (line.x2 - line.x1) * t;
        cloth.position.set(x, -lineHeightAt(line, x) - size[1] / 2, 0);
        cloth.userData = { x, top: lineHeightAt(line, x), height: size[1], t };
        cloth.castShadow = true;
        clothes.push(cloth);
        this.levelGroup.add(cloth);
      }
      this.levelGroup.add(whole);
      // Hooks on the walls.
      for (const [x, y] of [[line.x1, line.y1], [line.x2, line.y2]]) {
        const h = new Mesh(new BoxGeometry(4, 3, 3), hook);
        h.position.set(x, -y, 0);
        this.levelGroup.add(h);
      }
      // The two halves that hang down after it snaps (hidden until then).
      const halves = [this.halfRope(line, 'left', rope), this.halfRope(line, 'right', rope)];
      this.lines.push({ state, whole, halves, clothes });
    }
  }

  private halfRope(line: Clothesline, side: 'left' | 'right', material: Material): Group {
    const pivot = new Group();
    const length = Math.min(70, (line.x2 - line.x1) / 2);
    const piece = new Mesh(new CylinderGeometry(0.7, 0.7, length, 6), material);
    piece.position.y = -length / 2;
    pivot.add(piece);
    pivot.position.set(side === 'left' ? line.x1 : line.x2, -(side === 'left' ? line.y1 : line.y2), 0);
    pivot.visible = false;
    this.levelGroup.add(pivot);
    return pivot;
  }

  private addFlag(level: Level): void {
    const flagX = level.finishX;
    const ground = roofHeightAt(level.blocks, flagX);
    if (level.flag === 'stairs') {
      this.addStairwell(flagX, ground);
      return;
    }
    const gold = level.flag === 'gold';
    const pole = new Mesh(
      new CylinderGeometry(1.4, 1.4, 76, 10),
      new MeshStandardMaterial({ color: gold ? '#f2ede4' : '#2b2233', metalness: gold ? 0.5 : 0.1, roughness: 0.4 }),
    );
    pole.position.set(flagX, -ground + 38, 0);
    pole.castShadow = true;
    this.levelGroup.add(pole);
    if (gold) {
      const knob = new Mesh(new SphereGeometry(3, 12, 10), new MeshStandardMaterial({ color: '#ffcc33', metalness: 0.8, roughness: 0.3 }));
      knob.position.set(flagX, -ground + 77, 0);
      this.levelGroup.add(knob);
    }
    const texture = gold
      ? paint(128, 80, (ctx) => {
          ctx.fillStyle = '#ffb000';
          ctx.fillRect(0, 0, 128, 80);
          ctx.fillStyle = '#fff6d6';
          ctx.beginPath();
          for (let i = 0; i < 10; i++) {
            const r = i % 2 === 0 ? 26 : 11;
            const a = -Math.PI / 2 + (i * Math.PI) / 5;
            ctx.lineTo(64 + Math.cos(a) * r, 40 + Math.sin(a) * r);
          }
          ctx.closePath();
          ctx.fill();
        })
      : paint(100, 60, (ctx) => {
          for (let row = 0; row < 3; row++) {
            for (let col = 0; col < 5; col++) {
              ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#1d1724';
              ctx.fillRect(col * 20, row * 20, 20, 20);
            }
          }
        });
    const cloth = new Mesh(
      new PlaneGeometry(gold ? 36 : 30, gold ? 22 : 18, 12, 1),
      new MeshStandardMaterial({ map: texture, side: DoubleSide, roughness: 0.8 }),
    );
    cloth.position.set(flagX + (gold ? 19 : 16), -ground + 70 - (gold ? 11 : 9), 0);
    cloth.castShadow = true;
    this.flagCloth = cloth;
    this.levelGroup.add(cloth);
    // The level's name floating above the flag.
    const label = new Sprite(
      new SpriteMaterial({
        map: paint(256, 64, (ctx) => {
          ctx.font = 'bold 40px system-ui, sans-serif';
          ctx.textAlign = 'center';
          ctx.lineWidth = 8;
          ctx.strokeStyle = 'rgba(29, 23, 36, 0.8)';
          ctx.strokeText(level.name.toUpperCase(), 128, 46);
          ctx.fillStyle = '#ffffff';
          ctx.fillText(level.name.toUpperCase(), 128, 46);
        }),
        fog: false,
      }),
    );
    label.scale.set(56, 14, 1);
    label.position.set(flagX, -ground + 92, 0);
    this.levelGroup.add(label);
  }

  // ---------- things that move ----------

  private animateLines(view: GameView): void {
    this.addLines(view.lines);
    for (const { state, whole, halves, clothes } of this.lines) {
      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(view.clock * 70) * (0.4 + state.held * 2);
        whole.visible = true;
        whole.position.y = -shake;
        for (const half of halves) half.visible = false;
        for (const cloth of clothes) {
          const d = cloth.userData as { x: number; top: number; height: number };
          cloth.position.set(d.x, -d.top - d.height / 2 - shake, 0);
          cloth.rotation.z = 0;
          cloth.visible = true;
        }
        continue;
      }
      // Snapped: each half swings down from its hook, and the laundry falls into the pit.
      const age = view.clock - state.snappedAt;
      const drop = Math.min(1, age * 4);
      const swing = Math.sin(age * 6) * Math.exp(-age * 1.5) * 0.25;
      whole.visible = false;
      halves[0].visible = halves[1].visible = true;
      halves[0].rotation.z = (1 - drop) * (Math.PI / 2) + swing;
      halves[1].rotation.z = -(1 - drop) * (Math.PI / 2) - swing;
      const fall = 0.5 * 900 * age * age;
      for (const cloth of clothes) {
        const d = cloth.userData as { x: number; top: number; height: number; t: number };
        cloth.position.set(d.x + Math.sin(age * 5 + d.t * 9) * 6 * age, -d.top - d.height / 2 - fall, Math.sin(age * 3 + d.t * 4) * 10 * age);
        cloth.rotation.z = age * 4 * (d.t - 0.5);
        cloth.visible = fall < 900;
      }
    }
  }

  private animateDetails(view: GameView): void {
    // Springs stretch up when Rog bounces, then wobble back down.
    for (const { spring, coil, pad } of this.springs) {
      const bounce = view.springBounce;
      const since = bounce && bounce.x === spring.x ? view.clock - bounce.at : Infinity;
      const stretch = since < 0.6 ? Math.exp(-since * 7) * Math.cos(since * 30) : 0;
      const height = SPRING_HEIGHT - 4 + Math.max(-5, stretch * 18);
      coil.scale.y = height / (SPRING_HEIGHT - 4);
      pad.position.y = -spring.roofY + 2 + height + 1.5;
    }
    // The dragon flaps along behind Rog; fireballs fly from its mouth over to him.
    if (this.dragon && view.dragon) {
      const { body, wings, jaw, glow, mouthLight, smoke, tail } = this.dragon;
      const warn = view.dragon.warn;
      body.position.set(view.dragon.x, -view.dragon.y, DRAGON_Z);
      body.rotation.z = Math.sin(view.clock * 7 + 1) * 0.05;
      for (const hinge of wings) hinge.rotation.x = -(hinge.userData.side as number) * Math.sin(view.clock * 7) * 0.65;
      tail.rotation.z = Math.sin(view.clock * 3) * 0.12;
      jaw.rotation.z = -warn * 0.45;
      glow.visible = warn > 0;
      glow.scale.setScalar(1.5 + warn * 4);
      mouthLight.intensity = warn * 2500;
      smoke.forEach((puff, i) => {
        const age = (view.clock * 1.5 + i / 3) % 1;
        puff.visible = warn > 0;
        puff.position.set(17 + age * 6, 1 + age * 12, 0);
        puff.scale.setScalar(3 + age * 8);
        (puff.material as SpriteMaterial).opacity = warn * (1 - age);
      });
    }
    view.fireballs.forEach((ball, i) => {
      const { group, flames, smoke, shell } = this.fireballMesh(i);
      const from = view.dragon?.x ?? ball.x;
      const along = Math.max(0, Math.min(1, (ball.x - from) / Math.max(1, view.x - from)));
      group.position.set(ball.x, -ball.y, DRAGON_Z * (1 - along));
      group.visible = true;
      shell.scale.setScalar(1 + Math.sin(view.clock * 30 + i) * 0.1);
      // Flames stream out behind it and flicker; smoke drifts further back.
      const speed = Math.hypot(ball.vx, ball.vy) || 1;
      const back = new Vector3(-ball.vx / speed, ball.vy / speed, 0);
      flames.forEach((flame, k) => {
        const flicker = 0.5 + 0.5 * Math.sin(view.clock * 26 + k * 1.9 + i);
        const wobble = Math.sin(view.clock * 17 + k * 2.3) * 2;
        flame.position.copy(back).multiplyScalar(3 + k * 4.5).add(new Vector3(0, wobble, Math.cos(view.clock * 13 + k) * 2));
        flame.scale.setScalar((FIREBALL_RADIUS * 2.6 - k * 1.6) * (0.85 + flicker * 0.3));
      });
      smoke.forEach((puff, k) => {
        puff.position.copy(back).multiplyScalar(24 + k * 8).add(new Vector3(0, Math.sin(view.clock * 5 + k) * (1 + k), 0));
        puff.scale.setScalar(6 + k * 3);
        (puff.material as SpriteMaterial).opacity = 0.7 * (1 - k / 6);
      });
    });
    for (let i = view.fireballs.length; i < this.fireballs.length; i++) this.fireballs[i].group.visible = false;
    if (this.fireTextures) this.animateBlasts(view);
    // Saws slide back and forth and spin.
    for (const { saw, blade } of this.saws) {
      const { x, y } = sawCenter(saw, view.levelTime);
      blade.position.set(x, -y, 0);
      blade.rotation.x = view.clock * 14;
    }
    // The flag waves, and the ice glints.
    if (this.flagCloth) this.flagCloth.rotation.y = Math.sin(view.clock * 4) * 0.25;
    for (const [i, material] of this.iceMaterials.entries()) {
      material.emissiveIntensity = 0.2 + 0.12 * (0.5 + 0.5 * Math.sin(view.clock * 2.5 + i));
    }
  }
}

export function createWorldView(canvas: HTMLCanvasElement): Renderer3D | null {
  try {
    return new Renderer3D(canvas);
  } catch {
    return null;
  }
}