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/boss3d.ts
import {
  CanvasTexture,
  CircleGeometry,
  ConeGeometry,
  CylinderGeometry,
  Group,
  InstancedMesh,
  Mesh,
  MeshStandardMaterial,
  Object3D,
  PlaneGeometry,
  PointLight,
  Quaternion,
  RepeatWrapping,
  SRGBColorSpace,
  SphereGeometry,
  Sprite,
  SpriteMaterial,
  Vector3,
  type Material,
} from 'three';
import {
  ARENA_W,
  FLOOR,
  GOO_RADIUS,
  GOO_WINDUP,
  LEG_SPREAD,
  LITTLE_CROUCH,
  LITTLE_DEATH,
  PUNCH_TIME,
  SPLAT_TIME,
  STOMP_SLAM,
  STOMP_WINDUP,
  type BossFight,
} from './boss.js';

/**
 * The boss fight in 3D (first and third person): a dark cave with stalactites and glowing
 * mushrooms, the giant hairy spider walking on eight real legs, little spiders, goo and dust.
 * It follows exactly the same fight as the flat view (boss.ts); this only draws it.
 * Fight units are used directly: x across, y down (so the 3D height is -y), z is depth.
 */

const UP = new Vector3(0, 1, 0);
/** In 3D the spider's body is held a bit higher than in the flat view, so Rog fits underneath it. */
const BODY_LIFT = 25;
/** How far apart the left and right legs are planted. */
const FOOT_Z = 62;

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

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

/** A point in the fight (x, y down) at depth z, as a 3D position. */
const at = (x: number, y: number, z = 0) => new Vector3(x, -y, z);

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

/** Stretch a cylinder (1 unit tall) from a to b. */
function 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);
}

export class BossCave3D {
  readonly group = new Group();
  private body = new Group();
  private head = new Group();
  private bodyMaterials: MeshStandardMaterial[] = [];
  private eyeMaterial: MeshStandardMaterial;
  private legs: { upper: Mesh; lower: Mesh; knee: Mesh }[] = [];
  private little = new Group();
  private littleLegs: Mesh[] = [];
  private goo: Mesh[] = [];
  private splats: Mesh[] = [];
  private rogSplat: Mesh;
  private dust: Sprite[] = [];
  private fist: Mesh;
  /** A warm light that follows Rog, so he's easy to see in the dark. */
  readonly rogLight = new PointLight('#ffe8c0', 2600, 220, 2);
  private eyeLight = new PointLight('#ff3030', 900, 160, 2);
  /** The goo warning: a green glow in the spider's mouth. */
  private gooGlow: Mesh;
  private gooLight = new PointLight('#8dff5a', 0, 160, 2);

  constructor() {
    this.buildCave();
    this.buildSpider();
    this.buildLittle();
    const gooMaterial = new MeshStandardMaterial({ color: '#6be33f', emissive: '#2f8f1e', emissiveIntensity: 0.6, roughness: 0.2 });
    for (let i = 0; i < 40; i++) {
      const glob = new Mesh(new SphereGeometry(GOO_RADIUS + 0.5, 10, 8), gooMaterial);
      glob.visible = false;
      this.goo.push(glob);
      this.group.add(glob);
    }
    for (let i = 0; i < 40; i++) {
      const splat = new Mesh(
        new CircleGeometry(1, 16),
        new MeshStandardMaterial({ color: '#5ad23c', emissive: '#2a7a1a', emissiveIntensity: 0.4, transparent: true, roughness: 0.2 }),
      );
      splat.rotation.x = -Math.PI / 2;
      splat.visible = false;
      this.splats.push(splat);
      this.group.add(splat);
    }
    this.rogSplat = new Mesh(
      new SphereGeometry(1, 12, 8),
      new MeshStandardMaterial({ color: '#6be33f', transparent: true, opacity: 0.7, roughness: 0.2 }),
    );
    this.group.add(this.rogSplat);
    const dustTexture = paint(64, 64, (ctx) => {
      const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
      g.addColorStop(0, 'rgba(160,140,130,0.8)');
      g.addColorStop(1, 'rgba(160,140,130,0)');
      ctx.fillStyle = g;
      ctx.fillRect(0, 0, 64, 64);
    });
    for (let i = 0; i < 8; i++) {
      const puff = new Sprite(new SpriteMaterial({ map: dustTexture, transparent: true, depthWrite: false }));
      puff.visible = false;
      this.dust.push(puff);
      this.group.add(puff);
    }
    this.fist = new Mesh(new SphereGeometry(3.4, 10, 8), new MeshStandardMaterial({ color: '#1d1724', roughness: 0.6 }));
    this.fist.visible = false;
    this.group.add(this.fist);
    this.group.add(this.rogLight, this.eyeLight);
    this.gooGlow = new Mesh(
      new SphereGeometry(1, 14, 10),
      new MeshStandardMaterial({ color: '#8dff5a', emissive: '#6be33f', emissiveIntensity: 2, transparent: true, opacity: 0.85 }),
    );
    this.gooGlow.position.set(36, -16, 0);
    this.gooGlow.visible = false;
    this.head.add(this.gooGlow);
    this.gooLight.position.set(40, -16, 0);
    this.head.add(this.gooLight);
    this.eyeMaterial = new MeshStandardMaterial({ color: '#d61f2a', emissive: '#ff2020', emissiveIntensity: 1.4 });
    this.head.traverse((part) => {
      if (part instanceof Mesh && part.userData.eye) part.material = this.eyeMaterial;
    });
  }

  // ---------- building ----------

  private buildCave(): void {
    const rock = paint(256, 256, (ctx) => {
      ctx.fillStyle = '#3b2c33';
      ctx.fillRect(0, 0, 256, 256);
      for (let i = 0; i < 160; i++) {
        ctx.fillStyle = noise(i) > 0.5 ? 'rgba(86, 67, 76, 0.7)' : 'rgba(40, 28, 34, 0.7)';
        ctx.beginPath();
        ctx.ellipse(noise(i * 3.1) * 256, noise(i * 7.7) * 256, 4 + noise(i * 2) * 14, 3 + noise(i * 5) * 8, noise(i * 9) * 3, 0, Math.PI * 2);
        ctx.fill();
      }
    });
    rock.wrapS = rock.wrapT = RepeatWrapping;
    const floorTexture = rock.clone();
    floorTexture.repeat.set(5, 4);
    floorTexture.needsUpdate = true;
    const floor = new Mesh(new PlaneGeometry(700, 500), new MeshStandardMaterial({ map: floorTexture, roughness: 0.95 }));
    floor.rotation.x = -Math.PI / 2;
    floor.position.set(ARENA_W / 2, -FLOOR, 0);
    floor.receiveShadow = true;
    this.group.add(floor);
    const wallTexture = rock.clone();
    wallTexture.repeat.set(4, 2);
    wallTexture.needsUpdate = true;
    const wallMaterial = new MeshStandardMaterial({ map: wallTexture, roughness: 1, color: '#a8949e' });
    const wall = (w: number, h: number, x: number, z: number, turn: number) => {
      const mesh = new Mesh(new PlaneGeometry(w, h), wallMaterial);
      mesh.position.set(x, -FLOOR + h / 2, z);
      mesh.rotation.y = turn;
      mesh.receiveShadow = true;
      this.group.add(mesh);
    };
    wall(700, 300, ARENA_W / 2, -170, 0);
    wall(700, 300, ARENA_W / 2, 170, Math.PI);
    wall(400, 300, 0, 0, Math.PI / 2);
    wall(400, 300, ARENA_W, 0, -Math.PI / 2);
    const ceiling = new Mesh(new PlaneGeometry(700, 400), wallMaterial);
    ceiling.rotation.x = Math.PI / 2;
    ceiling.position.set(ARENA_W / 2, -FLOOR + 260, 0);
    this.group.add(ceiling);
    // Stalactites hanging from the ceiling, and boulders round the edges.
    const stoneMaterial = new MeshStandardMaterial({ color: '#4a3840', roughness: 0.95 });
    for (let i = 0; i < 40; i++) {
      const length = 20 + noise(i * 13) * 50;
      const tip = new Mesh(new ConeGeometry(6 + noise(i) * 8, length, 7), stoneMaterial);
      tip.rotation.x = Math.PI;
      tip.position.set(noise(i * 3.3) * ARENA_W, -FLOOR + 260 - length / 2, (noise(i * 5.1) - 0.5) * 320);
      this.group.add(tip);
    }
    for (let i = 0; i < 24; i++) {
      const size = 8 + noise(i * 4) * 20;
      const boulder = new Mesh(new SphereGeometry(size, 9, 7), stoneMaterial);
      const side = i % 2 ? 1 : -1;
      boulder.scale.y = 0.6;
      boulder.position.set(noise(i * 6.6) * ARENA_W, -FLOOR + size * 0.3, side * (120 + noise(i * 2.2) * 40));
      boulder.castShadow = boulder.receiveShadow = true;
      this.group.add(boulder);
    }
    // Glowing mushrooms in the corners, lighting the cave a little green.
    const stem = new MeshStandardMaterial({ color: '#d8f5e9', emissive: '#7fd6b4', emissiveIntensity: 0.4 });
    const capMaterial = new MeshStandardMaterial({ color: '#5fe6b4', emissive: '#3fd69a', emissiveIntensity: 1.2 });
    for (const [x, z, size] of [[34, -110, 1], [52, -90, 0.7], [430, 110, 0.9], [448, -120, 1.2], [240, -150, 1], [300, 140, 0.8]]) {
      const s = new Mesh(new CylinderGeometry(1.6 * size, 2 * size, 9 * size, 8), stem);
      s.position.set(x, -FLOOR + 4.5 * size, z);
      const cap = new Mesh(new SphereGeometry(7 * size, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2), capMaterial);
      cap.position.set(x, -FLOOR + 9 * size, z);
      this.group.add(s, cap);
    }
    for (const [x, z] of [[40, -100], [440, 0]]) {
      const glow = new PointLight('#7dffcf', 1800, 260, 2);
      glow.position.set(x, -FLOOR + 30, z);
      this.group.add(glow);
    }
    // A dim purple glow from up high, so the spider's hairy body shows up in the dark.
    const fill = new PointLight('#e0d0f0', 40000, 900, 2);
    fill.position.set(ARENA_W / 2, -FLOOR + 220, 80);
    this.group.add(fill);
  }

  private buildSpider(): void {
    const fur = paint(128, 64, (ctx) => {
      const shade = ctx.createLinearGradient(0, 0, 0, 64);
      shade.addColorStop(0, '#4a2e1b');
      shade.addColorStop(0.5, '#7a4e2e');
      shade.addColorStop(1, '#3d2616');
      ctx.fillStyle = shade;
      ctx.fillRect(0, 0, 128, 64);
      ctx.strokeStyle = 'rgba(20, 10, 5, 0.6)';
      ctx.lineWidth = 1;
      for (let i = 0; i < 500; i++) {
        const x = noise(i * 1.7) * 128;
        const y = noise(i * 3.9) * 64;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x + (noise(i) - 0.5) * 6, y + 3 + noise(i * 2) * 4);
        ctx.stroke();
      }
      // Orange tarantula markings.
      ctx.fillStyle = 'rgba(184, 100, 42, 0.8)';
      for (let i = 0; i < 3; i++) {
        ctx.beginPath();
        ctx.moveTo(30 + i * 30, 10);
        ctx.lineTo(40 + i * 30, 24);
        ctx.lineTo(50 + i * 30, 10);
        ctx.fill();
      }
    });
    const furMaterial = new MeshStandardMaterial({ map: fur, roughness: 0.9, emissive: '#ffffff', emissiveIntensity: 0 });
    const headMaterial = new MeshStandardMaterial({ color: '#5a3a24', roughness: 0.85, emissive: '#ffffff', emissiveIntensity: 0 });
    this.bodyMaterials.push(furMaterial, headMaterial);
    const abdomen = new Mesh(new SphereGeometry(1, 24, 18), furMaterial);
    abdomen.scale.set(62, 46, 52);
    abdomen.position.set(-42, 0, 0);
    abdomen.castShadow = true;
    this.body.add(abdomen);
    // Lots of bristly hairs sticking out of the abdomen.
    const hairs = new InstancedMesh(new ConeGeometry(0.9, 9, 4), new MeshStandardMaterial({ color: '#3d2616', roughness: 1 }), 220);
    const place = new Object3D();
    for (let i = 0; i < 220; i++) {
      const u = noise(i * 1.3) * Math.PI * 2;
      const v = Math.acos(2 * noise(i * 7.1) - 1);
      const n = new Vector3(Math.sin(v) * Math.cos(u), Math.cos(v), Math.sin(v) * Math.sin(u));
      place.position.set(-42 + n.x * 62, n.y * 46, n.z * 52);
      place.quaternion.setFromUnitVectors(UP, n);
      place.updateMatrix();
      hairs.setMatrixAt(i, place.matrix);
    }
    this.body.add(hairs);
    // Head and chest, with fangs and a cluster of glowing eyes.
    const headBall = new Mesh(new SphereGeometry(1, 20, 14), headMaterial);
    headBall.scale.set(38, 29, 34);
    headBall.castShadow = true;
    this.head.add(headBall);
    const fangMaterial = new MeshStandardMaterial({ color: '#1a0f0a', roughness: 0.4 });
    for (const z of [-7, 7]) {
      const fang = new Mesh(new ConeGeometry(3, 20, 8), fangMaterial);
      fang.rotation.x = Math.PI;
      fang.rotation.z = -0.3;
      fang.position.set(30, -24, z);
      this.head.add(fang);
    }
    const eyes: [number, number, number, number][] = [
      [34, 12, -9, 4.5], [34, 12, 9, 4.5], [36, 4, -14, 3], [36, 4, 14, 3],
      [30, 20, -5, 2.5], [30, 20, 5, 2.5], [28, 16, -15, 2], [28, 16, 15, 2],
    ];
    for (const [ex, ey, ez, r] of eyes) {
      const eye = new Mesh(new SphereGeometry(r, 10, 8));
      eye.userData.eye = true;
      eye.position.set(ex, ey, ez);
      this.head.add(eye);
    }
    // (The goo warning glow is made in the constructor, once the head exists.)
    this.body.add(this.head);
    this.group.add(this.body);
    this.eyeLight.position.set(40, 10, 0);
    // Legs: two hairy segments with an orange knee, set every frame.
    const legMaterial = new MeshStandardMaterial({ color: '#4a2e1b', roughness: 0.95 });
    const kneeMaterial = new MeshStandardMaterial({ color: '#b8642a', roughness: 0.8 });
    for (let k = 0; k < 8; k++) {
      const upper = new Mesh(new CylinderGeometry(3.6, 3, 1, 8), legMaterial);
      const lower = new Mesh(new CylinderGeometry(2.6, 1, 1, 8), legMaterial);
      const knee = new Mesh(new SphereGeometry(4.2, 10, 8), kneeMaterial);
      upper.castShadow = lower.castShadow = true;
      this.legs.push({ upper, lower, knee });
      this.group.add(upper, lower, knee);
    }
  }

  private buildLittle(): void {
    const fur = new MeshStandardMaterial({ color: '#3d2616', roughness: 0.9 });
    const body = new Mesh(new SphereGeometry(1, 14, 10), fur);
    body.scale.set(10, 8, 9);
    body.position.set(-6, 0, 0);
    const head = new Mesh(new SphereGeometry(1, 12, 8), fur);
    head.scale.set(6.5, 5.5, 6);
    head.position.set(7, 1, 0);
    this.little.add(body, head);
    const eye = new MeshStandardMaterial({ color: '#ff3c3c', emissive: '#ff2020', emissiveIntensity: 1.2 });
    for (const z of [-2.5, 2.5]) {
      const e = new Mesh(new SphereGeometry(1.4, 8, 6), eye);
      e.position.set(12.5, 3, z);
      this.little.add(e);
    }
    const leg = new MeshStandardMaterial({ color: '#2e1c11', roughness: 0.95 });
    for (let k = 0; k < 8; k++) {
      const piece = new Mesh(new CylinderGeometry(1, 0.6, 1, 6), leg);
      this.littleLegs.push(piece);
      this.little.add(piece);
    }
    this.little.visible = false;
    this.group.add(this.little);
  }

  // ---------- every frame ----------

  update(fight: BossFight): void {
    this.updateSpider(fight);
    this.updateLittle(fight);
    this.updateGoo(fight);
    this.updateDust(fight);
    this.updateRogBits(fight);
  }

  private updateSpider(fight: BossFight): void {
    const { spider } = fight;
    const f = spider.facing;
    const dead = fight.result === 'won' ? ease(Math.min(1, spider.deadTime / 0.8)) : 0;
    const breathe = Math.sin(fight.clock * 2.5) * 2;
    const stepping = spider.steps.reduce((bob, step) => (step ? Math.max(bob, Math.sin(step.t * Math.PI)) : bob), 0);
    const bodyY = 168 - BODY_LIFT + breathe - stepping * 2.5 + dead * (FLOOR - 40 - 168 + BODY_LIFT);
    const x = spider.x;
    this.body.position.copy(at(x, bodyY));
    this.body.rotation.set(dead * 0.15 * f, f > 0 ? 0 : Math.PI, dead * 0.25);
    // Lunges its head forward while spraying goo.
    // Pulls its head back with a glowing green mouth before the goo (the warning), then lunges as it sprays.
    const windup = spider.state === 'gooWindup' ? ease(Math.min(1, spider.stateTime / GOO_WINDUP)) : 0;
    this.head.position.set(spider.state === 'goo' ? 36 : 30 - windup * 8, -6 + windup * 6, 0);
    this.gooGlow.visible = windup > 0 && dead === 0;
    this.gooGlow.scale.setScalar(2 + windup * 6 + Math.sin(fight.clock * 30) * 0.6);
    this.gooLight.intensity = windup * 3000;
    for (const material of this.bodyMaterials) material.emissiveIntensity = spider.hurt > 0 ? 0.5 : 0;
    this.eyeMaterial.emissiveIntensity = dead > 0.5 ? 0.1 : 1.4;
    this.eyeLight.intensity = dead > 0.5 ? 0 : 900;
    this.eyeLight.position.copy(at(x + f * 60, bodyY - 10, 0));

    LEG_SPREAD.forEach((spread, k) => {
      const side = k % 2 === 0 ? 1 : -1;
      const hip = at(x + f * 22 + (k - 3.5) * 5, bodyY + 8, side * 14);
      const step = spider.steps[k];
      let footX = step ? step.from + (step.to - step.from) * ease(Math.min(1, step.t)) : spider.feet[k];
      let footY = step ? FLOOR - Math.sin(Math.min(1, step.t) * Math.PI) * 12 : FLOOR;
      let footZ = side * (FOOT_Z + (Math.abs(spread) / 170) * 20);
      if (k === spider.stomper) {
        // The stomping leg flicks up over Rog (in the middle, where he is) and slams down.
        const highY = FLOOR - 110;
        if (spider.state === 'windup') {
          const t = ease(Math.min(1, spider.stateTime / STOMP_WINDUP));
          footX += (spider.target - footX) * t;
          footY += (highY - footY) * t;
          footZ *= 1 - t;
        } else if (spider.state === 'slam') {
          const t = Math.min(1, spider.stateTime / STOMP_SLAM);
          footX = spider.target;
          footY = highY + (FLOOR - highY) * t * t;
          footZ = 0;
        } else {
          footX = spider.target;
          footY = FLOOR;
          footZ = 0;
        }
      }
      let foot = at(footX, footY, footZ);
      if (dead > 0) {
        // Legs curl up when it's beaten.
        foot = foot.clone().lerp(at(x + spread * 0.25, bodyY - 45, side * 20), dead);
      }
      const reach = Math.abs(footX - hip.x);
      const knee = new Vector3((hip.x + foot.x) / 2 + (foot.x - hip.x) * 0.12, Math.max(hip.y, foot.y) + 55 + reach * 0.1 - dead * 30, (hip.z + foot.z) * 0.62);
      const leg = this.legs[k];
      setBone(leg.upper, hip, knee);
      setBone(leg.lower, knee, foot);
      leg.knee.position.copy(knee);
    });
  }

  private updateLittle(fight: BossFight): void {
    const little = fight.little;
    this.little.visible = !!little;
    if (!little) return;
    const dying = little.state === 'dead' ? Math.min(1, little.stateTime / LITTLE_DEATH) : 0;
    const squash = little.state === 'crouch' ? Math.min(1, little.stateTime / LITTLE_CROUCH) : 0;
    const size = 1.35 * (1 - dying * 0.8);
    this.little.position.copy(at(little.x, little.y - 9 * size + squash * 3));
    this.little.rotation.set(dying * Math.PI, little.facing > 0 ? 0 : Math.PI, 0);
    this.little.scale.set(size, size * (1 - squash * 0.25), size);
    const airborne = little.state === 'flying' || little.state === 'hop';
    this.littleLegs.forEach((piece, k) => {
      const side = k % 2 === 0 ? 1 : -1;
      const i = Math.floor(k / 2);
      const wiggle = Math.sin(little.walk * 0.3 + k * 1.7) * 2;
      const hip = new Vector3(-4 + i * 4, 0, side * 4);
      const foot = new Vector3(-10 + i * 7 + wiggle, airborne ? -4 : -9 + squash * 2, side * 13);
      setBone(piece, hip, foot);
    });
  }

  private updateGoo(fight: BossFight): void {
    this.goo.forEach((glob, i) => {
      const g = fight.goo[i];
      glob.visible = !!g;
      if (!g) return;
      glob.position.copy(at(g.x, g.y));
      const s = 1 + Math.sin(fight.clock * 25 + i) * 0.15;
      glob.scale.set(s, 1 / s, s);
    });
    let rogSplat = 0;
    this.splats.forEach((mesh, i) => {
      const splat = fight.splats[i];
      mesh.visible = !!splat && !splat.onRog;
      if (splat?.onRog) rogSplat = Math.max(rogSplat, 1 - splat.age);
      if (!splat || splat.onRog) return;
      const spread = Math.min(1, splat.age * 6);
      mesh.position.copy(at(splat.x, FLOOR - 0.5 - i * 0.01, 0));
      mesh.scale.setScalar(6 + spread * 16);
      (mesh.material as MeshStandardMaterial).opacity = Math.min(1, (1 - splat.age / SPLAT_TIME) * 3) * 0.85;
    });
    this.rogSplat.visible = rogSplat > 0;
    this.rogSplat.position.copy(at(fight.rog.x, fight.rog.y - 20));
    this.rogSplat.scale.set(6, 8, 6);
    (this.rogSplat.material as MeshStandardMaterial).opacity = rogSplat * 0.5;
  }

  private updateDust(fight: BossFight): void {
    const t = 1 - fight.shake;
    this.dust.forEach((puff, i) => {
      puff.visible = fight.shake > 0;
      if (!puff.visible) return;
      const angle = (i / this.dust.length) * Math.PI * 2;
      const r = 6 + t * (20 + noise(i) * 30);
      puff.position.copy(at(fight.spider.target + Math.cos(angle) * r, FLOOR - 4 - t * (6 + noise(i * 3) * 18), Math.sin(angle) * r));
      puff.scale.setScalar(8 + t * 16);
      (puff.material as SpriteMaterial).opacity = 0.7 * fight.shake;
    });
  }

  /** Rog's punching fist, and the light that follows him. */
  private updateRogBits(fight: BossFight): void {
    const { rog } = fight;
    this.rogLight.position.copy(at(rog.x, rog.y - 40, 25));
    this.fist.visible = rog.punch > 0;
    if (rog.punch > 0) {
      const out = Math.sin(Math.PI * (1 - rog.punch / PUNCH_TIME));
      this.fist.position.copy(at(rog.x + rog.facing * (8 + 16 * out), rog.y - 29, 0));
    }
  }

  dispose(): void {
    this.group.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) material.dispose();
      }
    });
  }
}