Back to Stick Brawl
SOURCE / PINNED RELEASE

Made of little things.

Stick Brawl

Release
732bb70ea856…
Author-recorded commit
918970bb749a…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-554b329db93

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

src/game/building.ts
/**
 * The neighbourhood: the apartment building, a street, and a brick warehouse
 * next door. Fire-escape stairs run from the apartment balconies across to
 * the warehouse.
 *
 * Apartments: five floors with four rooms each. Every room has a door on both
 * sides (including the outside walls), and wooden stairs zig-zag up inside.
 *
 * Warehouse: three tall floors with two huge rooms each, metal doors, metal
 * stairs, wooden crates to jump over, and a flat roof with a water tower.
 *
 * Positions are in world units: x grows to the right, y grows downwards.
 */

// ---------- apartments ----------
export const FLOORS = 5;
export const ROOMS = 4;
export const ROOM_W = 360;
export const BUILDING_W = ROOMS * ROOM_W;
/** Height of one storey, from one floor's surface to the next. */
export const STOREY = 160;
const SLAB = 10;
export const DOOR_H = 64;
const WALL_T = 6;
/** How far sideways a flight of stairs inside the apartments goes. */
const STAIRS_RUN = 190;
/** Balconies stick out this far from the buildings. */
export const BALCONY = 44;
/** The street and every ground floor are at this height. */
export const GROUND_Y = FLOORS * STOREY + 60;
export const ROOF_Y = GROUND_Y - FLOORS * STOREY;

// ---------- warehouse ----------
const WH_FLOORS = 3;
const WH_ROOMS = 2;
const WH_ROOM_W = 420;
const WH_W = WH_ROOMS * WH_ROOM_W;
const WH_STOREY = 200;
const WH_SLAB = 12;
const WH_BALCONY = 40;
/** The gap between the two buildings, which the fire escape crosses. */
const STREET_GAP = 220;
const WH_X = BUILDING_W + BALCONY + STREET_GAP + WH_BALCONY;
const WH_ROOF_Y = GROUND_Y - WH_FLOORS * WH_STOREY;
const CRATE = 34;

export const WORLD_LEFT = -BALCONY - 200;
export const WORLD_RIGHT = WH_X + WH_W + WH_BALCONY + 200;

/** The walking surface of apartment floor `f` (0 is the ground floor). */
export function floorY(f: number): number {
  return GROUND_Y - f * STOREY;
}

function whFloorY(g: number): number {
  return GROUND_Y - g * WH_STOREY;
}

type Style = 'apartments' | 'warehouse' | 'street';

export interface Solid {
  x: number;
  y: number;
  w: number;
  h: number;
  kind: 'slab' | 'wall' | 'door' | 'balcony' | 'rail' | 'crate' | 'ground';
  style: Style;
  /** Doors only: smashed doors stop being solid. */
  broken?: boolean;
}

/** A flight of stairs. You climb up from the bottom end and down from the top end. */
export interface Stairs {
  bottomX: number;
  topX: number;
  bottomY: number;
  topY: number;
  /** +1 if the stairs go up to the right, -1 if they go up to the left. */
  dir: number;
  style: 'wood' | 'metal' | 'fire-escape';
}

/** Somewhere you can walk along: one floor of one building, a balcony, the street, a roof. */
export interface Surface {
  y: number;
  x0: number;
  x1: number;
  /** What to call it on screen. */
  place: string;
}

export interface SpawnPoint {
  x: number;
  y: number;
  facing: number;
}

export interface Building {
  solids: Solid[];
  doors: Solid[];
  stairs: Stairs[];
  surfaces: Surface[];
  spawns: SpawnPoint[];
  /** Routes already worked out, so enemies don't have to search every frame. */
  routes: Map<Surface, Map<Surface, Step | null>>;
}

export function buildBuilding(): Building {
  const solids: Solid[] = [];
  const doors: Solid[] = [];
  const stairs: Stairs[] = [];
  const surfaces: Surface[] = [];
  const spawns: SpawnPoint[] = [];

  // The street runs under everything.
  solids.push({ x: WORLD_LEFT - 300, y: GROUND_Y, w: WORLD_RIGHT - WORLD_LEFT + 600, h: 200, kind: 'ground', style: 'street' });
  surfaces.push({ y: GROUND_Y, x0: WORLD_LEFT, x1: -BALCONY, place: 'Street' });
  surfaces.push({ y: GROUND_Y, x0: -BALCONY, x1: BUILDING_W + BALCONY, place: 'Apartments · Floor 1' });
  surfaces.push({ y: GROUND_Y, x0: BUILDING_W + BALCONY, x1: WH_X, place: 'Street' });
  surfaces.push({ y: GROUND_Y, x0: WH_X, x1: WH_X + WH_W, place: 'Warehouse · Floor 1' });
  surfaces.push({ y: GROUND_Y, x0: WH_X + WH_W, x1: WORLD_RIGHT, place: 'Street' });
  spawns.push({ x: WORLD_LEFT + 20, y: GROUND_Y, facing: 1 });
  spawns.push({ x: WORLD_RIGHT - 20, y: GROUND_Y, facing: -1 });

  // ----- Apartments -----
  for (let f = 1; f <= FLOORS; f++) {
    solids.push({ x: 0, y: floorY(f), w: BUILDING_W, h: SLAB, kind: 'slab', style: 'apartments' });
  }
  for (let f = 1; f < FLOORS; f++) {
    const y = floorY(f);
    solids.push({ x: -BALCONY, y, w: BALCONY, h: SLAB, kind: 'balcony', style: 'apartments' });
    solids.push({ x: BUILDING_W, y, w: BALCONY, h: SLAB, kind: 'balcony', style: 'apartments' });
    surfaces.push({ y, x0: -BALCONY, x1: BUILDING_W + BALCONY, place: `Apartments · Floor ${f + 1}` });
    // Railings on the left balconies, where enemies climb over to get in.
    solids.push({ x: -BALCONY, y: y - 22, w: 3, h: 22, kind: 'rail', style: 'apartments' });
    spawns.push({ x: -BALCONY + 14, y, facing: 1 });
  }
  // The top right balcony has no fire escape, so it gets a railing too.
  solids.push({ x: BUILDING_W + BALCONY - 3, y: floorY(FLOORS - 1) - 22, w: 3, h: 22, kind: 'rail', style: 'apartments' });
  for (let f = 0; f < FLOORS; f++) {
    const floor = floorY(f);
    const ceiling = floorY(f + 1) + SLAB;
    for (let k = 0; k <= ROOMS; k++) {
      const x = k * ROOM_W - WALL_T / 2;
      solids.push({ x, y: ceiling, w: WALL_T, h: floor - DOOR_H - ceiling, kind: 'wall', style: 'apartments' });
      const door: Solid = { x, y: floor - DOOR_H, w: WALL_T, h: DOOR_H, kind: 'door', style: 'apartments', broken: false };
      solids.push(door);
      doors.push(door);
    }
  }
  // Wooden stairs zig-zag: up to the right in room 2 on even floors, up to the left in room 3 on odd floors.
  for (let f = 0; f < FLOORS - 1; f++) {
    const even = f % 2 === 0;
    const room = even ? 1 : 2;
    const dir = even ? 1 : -1;
    const bottomX = even ? room * ROOM_W + 70 : (room + 1) * ROOM_W - 70;
    stairs.push({ bottomX, topX: bottomX + dir * STAIRS_RUN, bottomY: floorY(f), topY: floorY(f + 1), dir, style: 'wood' });
  }

  // ----- Fire escape: from the apartments' right balconies up to the warehouse -----
  const from = BUILDING_W + BALCONY - 6;
  stairs.push({ bottomX: from, topX: WH_X - WH_BALCONY + 6, bottomY: floorY(1), topY: whFloorY(1), dir: 1, style: 'fire-escape' });
  stairs.push({ bottomX: from, topX: WH_X - WH_BALCONY + 6, bottomY: floorY(2), topY: whFloorY(2), dir: 1, style: 'fire-escape' });
  stairs.push({ bottomX: from, topX: WH_X + 10, bottomY: floorY(3), topY: WH_ROOF_Y, dir: 1, style: 'fire-escape' });

  // ----- Warehouse -----
  for (let g = 1; g <= WH_FLOORS; g++) {
    solids.push({ x: WH_X, y: whFloorY(g), w: WH_W, h: WH_SLAB, kind: 'slab', style: 'warehouse' });
  }
  surfaces.push({ y: WH_ROOF_Y, x0: WH_X, x1: WH_X + WH_W, place: 'Warehouse roof' });
  for (let g = 1; g < WH_FLOORS; g++) {
    const y = whFloorY(g);
    solids.push({ x: WH_X - WH_BALCONY, y, w: WH_BALCONY, h: WH_SLAB, kind: 'balcony', style: 'warehouse' });
    solids.push({ x: WH_X + WH_W, y, w: WH_BALCONY, h: WH_SLAB, kind: 'balcony', style: 'warehouse' });
    solids.push({ x: WH_X + WH_W + WH_BALCONY - 3, y: y - 22, w: 3, h: 22, kind: 'rail', style: 'warehouse' });
    surfaces.push({ y, x0: WH_X - WH_BALCONY, x1: WH_X + WH_W + WH_BALCONY, place: `Warehouse · Floor ${g + 1}` });
    spawns.push({ x: WH_X + WH_W + WH_BALCONY - 14, y, facing: -1 });
  }
  for (let g = 0; g < WH_FLOORS; g++) {
    const floor = whFloorY(g);
    const ceiling = whFloorY(g + 1) + WH_SLAB;
    for (let k = 0; k <= WH_ROOMS; k++) {
      const x = WH_X + k * WH_ROOM_W - WALL_T / 2;
      solids.push({ x, y: ceiling, w: WALL_T, h: floor - DOOR_H - ceiling, kind: 'wall', style: 'warehouse' });
      const door: Solid = { x, y: floor - DOOR_H, w: WALL_T, h: DOOR_H, kind: 'door', style: 'warehouse', broken: false };
      solids.push(door);
      doors.push(door);
    }
  }
  // Steep metal stairs: up to the right in the left hall, then up to the left in the right hall.
  stairs.push({ bottomX: WH_X + 100, topX: WH_X + 330, bottomY: whFloorY(0), topY: whFloorY(1), dir: 1, style: 'metal' });
  stairs.push({ bottomX: WH_X + WH_W - 100, topX: WH_X + WH_W - 330, bottomY: whFloorY(1), topY: whFloorY(2), dir: -1, style: 'metal' });
  // Crates to jump over (and hide behind).
  const crates: [number, number, number][] = [
    [0, 470, 0],
    [0, 504, 0],
    [0, 487, 1],
    [0, 640, 0],
    [1, 120, 0],
    [1, 600, 0],
    [1, 600, 1],
    [2, 250, 0],
    [2, 640, 0],
  ];
  for (const [g, offset, level] of crates) {
    solids.push({
      x: WH_X + offset,
      y: whFloorY(g) - CRATE * (level + 1),
      w: CRATE,
      h: CRATE,
      kind: 'crate',
      style: 'warehouse',
    });
  }

  return { solids, doors, stairs, surfaces, spawns, routes: new Map() };
}

export function resetDoors(building: Building): void {
  for (const door of building.doors) door.broken = false;
}

/** The surface someone standing at (x, y) is on. */
export function surfaceAt(building: Building, x: number, y: number): Surface | null {
  for (const surface of building.surfaces) {
    if (Math.abs(surface.y - y) < 3 && x >= surface.x0 && x <= surface.x1) return surface;
  }
  return null;
}

/** The first step on the way from one surface to another: walk to a neighbouring surface, or take some stairs. */
export type Step = { walkTo: number } | { stairs: Stairs; up: boolean };

export function firstStep(building: Building, from: Surface, to: Surface): Step | null {
  if (from === to) return null;
  let known = building.routes.get(from);
  if (!known) building.routes.set(from, (known = new Map()));
  if (!known.has(to)) known.set(to, searchRoute(building, from, to));
  return known.get(to)!;
}

function searchRoute(building: Building, from: Surface, to: Surface): Step | null {
  // Search outwards from where we are, remembering the first step that led to each surface.
  const firstSteps = new Map<Surface, Step>();
  const queue: Surface[] = [from];
  const seen = new Set<Surface>([from]);
  while (queue.length > 0) {
    const here = queue.shift()!;
    for (const [next, step] of neighbours(building, here)) {
      if (seen.has(next)) continue;
      seen.add(next);
      firstSteps.set(next, here === from ? step : firstSteps.get(here)!);
      if (next === to) return firstSteps.get(next)!;
      queue.push(next);
    }
  }
  return null;
}

function neighbours(building: Building, here: Surface): [Surface, Step][] {
  const result: [Surface, Step][] = [];
  for (const other of building.surfaces) {
    if (other === here || other.y !== here.y) continue;
    // Side-by-side surfaces at the same height (a street and a ground floor) join up.
    if (other.x0 === here.x1) result.push([other, { walkTo: other.x0 + 4 }]);
    if (other.x1 === here.x0) result.push([other, { walkTo: other.x1 - 4 }]);
  }
  for (const s of building.stairs) {
    const bottom = surfaceAt(building, s.bottomX, s.bottomY);
    const top = surfaceAt(building, s.topX, s.topY);
    if (bottom === here && top) result.push([top, { stairs: s, up: true }]);
    if (top === here && bottom) result.push([bottom, { stairs: s, up: false }]);
  }
  return result;
}

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

/** A repeatable "random" number from 0 to 1. */
function noise(seed: number): number {
  const value = Math.sin(seed * 12.9898) * 43758.5453;
  return value - Math.floor(value);
}

// Each apartment room gets its own wall colour so you can tell them apart.
const WALL_COLOURS = ['#2d3147', '#3a2f45', '#2c3b40', '#3d3530', '#30344a', '#3b3045', '#2b3a36', '#41352d'];

/** The night sky behind everything. Drawn in screen space, with a little sideways drift. */
export function drawSky(ctx: CanvasRenderingContext2D, viewW: number, viewH: number, cameraX: number): void {
  const sky = ctx.createLinearGradient(0, 0, 0, viewH);
  sky.addColorStop(0, '#070816');
  sky.addColorStop(1, '#221d45');
  ctx.fillStyle = sky;
  ctx.fillRect(0, 0, viewW, viewH);
  ctx.fillStyle = '#d9dcff';
  for (let i = 0; i < 40; i++) {
    const x = (noise(i * 3.1) * 900 - cameraX * 0.05) % viewW;
    ctx.globalAlpha = 0.3 + noise(i * 7.7) * 0.7;
    ctx.fillRect((x + viewW) % viewW, noise(i * 5.3) * viewH * 0.8, 1, 1);
  }
  ctx.globalAlpha = 1;
  ctx.fillStyle = '#f4f1d0';
  ctx.beginPath();
  ctx.arc(viewW * 0.8 - cameraX * 0.03, 50, 14, 0, Math.PI * 2);
  ctx.fill();
}

type View = { x: number; y: number; w: number; h: number };

/** Everything except the people. Drawn in world space. */
export function drawBuilding(ctx: CanvasRenderingContext2D, building: Building, view: View, time: number): void {
  const inView = (x: number, y: number, w: number, h: number) =>
    x + w > view.x && x < view.x + view.w && y + h > view.y && y < view.y + view.h;

  // Faraway city behind everything.
  ctx.fillStyle = '#141429';
  for (let i = -10; i < 50; i++) {
    const x = i * 70;
    const top = GROUND_Y - 120 - noise(i * 9.1) * 260;
    if (inView(x, top, 66, GROUND_Y - top)) ctx.fillRect(x, top, 66, GROUND_Y - top);
  }
  drawStreetLamps(ctx, inView);
  drawApartmentRooms(ctx, inView);
  drawWarehouseHalls(ctx, inView, time);
  for (const s of building.stairs) drawStairs(ctx, s, inView);
  for (const solid of building.solids) {
    if (inView(solid.x, solid.y - 24, solid.w, solid.h + 24)) drawSolid(ctx, solid);
  }
  drawApartmentRoof(ctx, time);
  drawWaterTower(ctx, inView);
}

function drawStreetLamps(ctx: CanvasRenderingContext2D, inView: (x: number, y: number, w: number, h: number) => boolean): void {
  for (const x of [WORLD_LEFT + 90, BUILDING_W + BALCONY + STREET_GAP / 2, WH_X + WH_W + 150]) {
    if (!inView(x - 60, GROUND_Y - 110, 120, 110)) continue;
    ctx.fillStyle = '#3d4152';
    ctx.fillRect(x - 1.5, GROUND_Y - 90, 3, 90);
    ctx.fillRect(x - 1.5, GROUND_Y - 90, 12, 3);
    ctx.fillStyle = '#ffd98a';
    ctx.fillRect(x + 6, GROUND_Y - 88, 6, 3);
    const glow = ctx.createRadialGradient(x + 9, GROUND_Y - 86, 2, x + 9, GROUND_Y - 86, 80);
    glow.addColorStop(0, 'rgba(255, 217, 138, 0.22)');
    glow.addColorStop(1, 'rgba(255, 217, 138, 0)');
    ctx.fillStyle = glow;
    ctx.fillRect(x - 75, GROUND_Y - 170, 170, 170);
  }
}

function drawApartmentRooms(ctx: CanvasRenderingContext2D, inView: (x: number, y: number, w: number, h: number) => boolean): void {
  for (let f = 0; f < FLOORS; f++) {
    const floor = floorY(f);
    const ceiling = floorY(f + 1) + SLAB;
    for (let r = 0; r < ROOMS; r++) {
      const left = r * ROOM_W;
      if (!inView(left, ceiling, ROOM_W, floor - ceiling)) continue;
      ctx.fillStyle = WALL_COLOURS[(f * 3 + r) % WALL_COLOURS.length];
      ctx.fillRect(left, ceiling, ROOM_W, floor - ceiling);
      ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
      ctx.fillRect(left, floor - 40, ROOM_W, 40);
      ctx.fillStyle = 'rgba(255, 255, 255, 0.08)';
      ctx.fillRect(left, floor - 42, ROOM_W, 2);
      // Window.
      const wx = left + ((f + r) % 2 === 0 ? ROOM_W * 0.68 : ROOM_W * 0.18);
      ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
      ctx.fillRect(wx - 3, ceiling + 22, 56, 50);
      ctx.fillStyle = '#0b0d22';
      ctx.fillRect(wx, ceiling + 25, 50, 44);
      ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
      ctx.fillRect(wx + 23, ceiling + 25, 4, 44);
      // Lamp with a soft glow.
      const lx = left + ROOM_W / 2;
      ctx.fillStyle = '#555b77';
      ctx.fillRect(lx - 0.5, ceiling, 1, 10);
      ctx.fillStyle = '#ffe9a8';
      ctx.beginPath();
      ctx.arc(lx, ceiling + 13, 4, Math.PI, 0);
      ctx.fill();
      const glow = ctx.createRadialGradient(lx, ceiling + 13, 2, lx, ceiling + 13, 110);
      glow.addColorStop(0, 'rgba(255, 233, 168, 0.14)');
      glow.addColorStop(1, 'rgba(255, 233, 168, 0)');
      ctx.fillStyle = glow;
      ctx.fillRect(left, ceiling, ROOM_W, floor - ceiling);
    }
  }
}

/** Big brick halls with tall factory windows and hanging lamps that throw cones of light. */
function drawWarehouseHalls(ctx: CanvasRenderingContext2D, inView: (x: number, y: number, w: number, h: number) => boolean, time: number): void {
  for (let g = 0; g < WH_FLOORS; g++) {
    const floor = whFloorY(g);
    const ceiling = whFloorY(g + 1) + WH_SLAB;
    for (let r = 0; r < WH_ROOMS; r++) {
      const left = WH_X + r * WH_ROOM_W;
      if (!inView(left, ceiling, WH_ROOM_W, floor - ceiling)) continue;
      // Brick wall.
      ctx.fillStyle = '#4a2622';
      ctx.fillRect(left, ceiling, WH_ROOM_W, floor - ceiling);
      ctx.fillStyle = '#3a1c19';
      for (let y = ceiling + 8, row = 0; y < floor; y += 8, row++) {
        ctx.fillRect(left, y, WH_ROOM_W, 1);
        for (let x = left + (row % 2) * 9; x < left + WH_ROOM_W; x += 18) ctx.fillRect(x, y - 8, 1, 8);
      }
      // A tall factory window made of lots of small panes.
      const wx = left + (r === 0 ? 230 : 70);
      ctx.fillStyle = '#2b2f36';
      ctx.fillRect(wx - 4, ceiling + 20, 128, 96);
      for (let py = 0; py < 4; py++) {
        for (let px = 0; px < 6; px++) {
          const lit = noise(g * 31 + r * 7 + py * 6 + px) > 0.8;
          ctx.fillStyle = lit ? '#2f3a5c' : '#10132a';
          ctx.fillRect(wx + px * 20, ceiling + 24 + py * 22, 18, 20);
        }
      }
      // Yellow safety stripe along the bottom of the wall.
      ctx.fillStyle = '#2a1715';
      ctx.fillRect(left, floor - 18, WH_ROOM_W, 18);
      for (let x = left; x < left + WH_ROOM_W; x += 16) {
        ctx.fillStyle = '#c9a227';
        ctx.beginPath();
        ctx.moveTo(x, floor - 18);
        ctx.lineTo(x + 8, floor - 18);
        ctx.lineTo(x + 2, floor);
        ctx.lineTo(x - 6, floor);
        ctx.closePath();
        ctx.fill();
      }
      // Hanging lamps with cones of light (one flickers).
      for (const lx of [left + 110, left + 310]) {
        const flicker = lx === WH_X + 310 && g === 1 && Math.sin(time * 23) > 0.6 ? 0.3 : 1;
        ctx.fillStyle = '#6b6f78';
        ctx.fillRect(lx - 0.5, ceiling, 1, 34);
        ctx.fillStyle = '#8f949e';
        ctx.beginPath();
        ctx.moveTo(lx - 9, ceiling + 42);
        ctx.lineTo(lx + 9, ceiling + 42);
        ctx.lineTo(lx + 3, ceiling + 34);
        ctx.lineTo(lx - 3, ceiling + 34);
        ctx.closePath();
        ctx.fill();
        ctx.fillStyle = `rgba(255, 240, 200, ${0.09 * flicker})`;
        ctx.beginPath();
        ctx.moveTo(lx - 8, ceiling + 42);
        ctx.lineTo(lx + 8, ceiling + 42);
        ctx.lineTo(lx + 70, floor);
        ctx.lineTo(lx - 70, floor);
        ctx.closePath();
        ctx.fill();
      }
    }
  }
}

function drawStairs(ctx: CanvasRenderingContext2D, s: Stairs, inView: (x: number, y: number, w: number, h: number) => boolean): void {
  const minX = Math.min(s.bottomX, s.topX);
  if (!inView(minX - 10, s.topY - 30, Math.abs(s.topX - s.bottomX) + 20, s.bottomY - s.topY + 30)) return;
  const steps = Math.max(6, Math.round((s.bottomY - s.topY) / 11));
  const run = (s.topX - s.bottomX) / steps;
  const rise = (s.bottomY - s.topY) / steps;
  if (s.style === 'wood') {
    // Side panel under the steps.
    ctx.fillStyle = '#4a3829';
    ctx.beginPath();
    ctx.moveTo(s.bottomX, s.bottomY);
    for (let i = 0; i < steps; i++) {
      ctx.lineTo(s.bottomX + run * i, s.bottomY - rise * (i + 1));
      ctx.lineTo(s.bottomX + run * (i + 1), s.bottomY - rise * (i + 1));
    }
    ctx.lineTo(s.topX, s.bottomY);
    ctx.closePath();
    ctx.fill();
  } else {
    // Metal stairs are open underneath: just a thin beam.
    ctx.strokeStyle = s.style === 'metal' ? '#59606b' : '#3f454f';
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(s.bottomX, s.bottomY + 2);
    ctx.lineTo(s.topX, s.topY + 2);
    ctx.stroke();
  }
  // The steps themselves.
  ctx.fillStyle = s.style === 'wood' ? '#7a5c43' : s.style === 'metal' ? '#9aa1ab' : '#6f7682';
  for (let i = 0; i < steps; i++) {
    const x = s.bottomX + run * i;
    ctx.fillRect(Math.min(x, x + run), s.bottomY - rise * (i + 1), Math.abs(run), 2);
  }
  // Hand rail.
  ctx.strokeStyle = s.style === 'wood' ? '#9a8a7a' : '#c9a227';
  ctx.lineWidth = 1.2;
  ctx.beginPath();
  ctx.moveTo(s.bottomX, s.bottomY - 26);
  ctx.lineTo(s.topX, s.topY - 26);
  ctx.stroke();
  for (let i = 0; i <= steps; i += 3) {
    const x = s.bottomX + run * i;
    const y = s.bottomY - rise * i;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x, y - 26);
    ctx.stroke();
  }
}

function drawSolid(ctx: CanvasRenderingContext2D, solid: Solid): void {
  const warehouse = solid.style === 'warehouse';
  if (solid.kind === 'ground') {
    ctx.fillStyle = '#26272e';
    ctx.fillRect(solid.x, solid.y, solid.w, solid.h);
    ctx.fillStyle = '#3a3c46';
    ctx.fillRect(solid.x, solid.y, solid.w, 3);
    // Wooden floor inside the apartments' ground floor, concrete in the warehouse.
    ctx.fillStyle = '#6e5644';
    ctx.fillRect(-BALCONY, solid.y, BUILDING_W + BALCONY * 2, 3);
    ctx.fillStyle = '#7d8088';
    ctx.fillRect(WH_X, solid.y, WH_W, 3);
    // Road markings.
    ctx.fillStyle = '#8a8a7a';
    for (let x = WORLD_LEFT; x < WORLD_RIGHT; x += 40) ctx.fillRect(x, solid.y + 14, 18, 2);
  } else if (solid.kind === 'slab' || solid.kind === 'balcony') {
    ctx.fillStyle = warehouse ? '#2e3138' : '#3c3a44';
    ctx.fillRect(solid.x, solid.y, solid.w, solid.h);
    if (warehouse) {
      // Metal grating.
      ctx.fillStyle = '#7d8088';
      ctx.fillRect(solid.x, solid.y, solid.w, 3);
      ctx.fillStyle = '#4c5058';
      for (let x = solid.x; x < solid.x + solid.w; x += 6) ctx.fillRect(x, solid.y + 3, 1, solid.h - 3);
    } else {
      ctx.fillStyle = solid.kind === 'slab' ? '#6e5644' : '#5d6270';
      ctx.fillRect(solid.x, solid.y, solid.w, 3);
    }
  } else if (solid.kind === 'rail') {
    ctx.strokeStyle = warehouse ? '#c9a227' : '#7d8494';
    ctx.lineWidth = 1.2;
    const towardsBuilding = solid.x < 0 ? 1 : -1;
    const x = solid.x + solid.w / 2;
    const reach = (warehouse ? WH_BALCONY : BALCONY) * towardsBuilding;
    ctx.beginPath();
    ctx.moveTo(x, solid.y + solid.h);
    ctx.lineTo(x, solid.y);
    ctx.lineTo(x + reach, solid.y);
    ctx.stroke();
  } else if (solid.kind === 'wall') {
    ctx.fillStyle = warehouse ? '#5b2f29' : '#5a5f73';
    ctx.fillRect(solid.x, solid.y, solid.w, solid.h);
  } else if (solid.kind === 'crate') {
    ctx.fillStyle = '#8a6a3f';
    ctx.fillRect(solid.x, solid.y, solid.w, solid.h);
    ctx.strokeStyle = '#5e4527';
    ctx.lineWidth = 2;
    ctx.strokeRect(solid.x + 1, solid.y + 1, solid.w - 2, solid.h - 2);
    ctx.beginPath();
    ctx.moveTo(solid.x + 2, solid.y + 2);
    ctx.lineTo(solid.x + solid.w - 2, solid.y + solid.h - 2);
    ctx.moveTo(solid.x + solid.w - 2, solid.y + 2);
    ctx.lineTo(solid.x + 2, solid.y + solid.h - 2);
    ctx.stroke();
  } else if (solid.broken) {
    // A smashed door: just jagged bits left at the hinges.
    ctx.fillStyle = warehouse ? '#6c727c' : '#6b4a2f';
    ctx.beginPath();
    ctx.moveTo(solid.x, solid.y);
    ctx.lineTo(solid.x + solid.w, solid.y);
    ctx.lineTo(solid.x + solid.w * 0.6, solid.y + 9);
    ctx.lineTo(solid.x + solid.w * 0.2, solid.y + 5);
    ctx.closePath();
    ctx.fill();
    ctx.beginPath();
    ctx.moveTo(solid.x, solid.y + solid.h);
    ctx.lineTo(solid.x + solid.w, solid.y + solid.h);
    ctx.lineTo(solid.x + solid.w * 0.7, solid.y + solid.h - 7);
    ctx.lineTo(solid.x + solid.w * 0.3, solid.y + solid.h - 11);
    ctx.closePath();
    ctx.fill();
  } else if (warehouse) {
    // A grey metal door with a hazard stripe.
    ctx.fillStyle = '#7b818b';
    ctx.fillRect(solid.x, solid.y, solid.w, solid.h);
    ctx.fillStyle = '#5d636c';
    ctx.fillRect(solid.x + 1, solid.y + 3, solid.w - 2, solid.h - 6);
    for (let y = solid.y + 26; y < solid.y + 38; y += 4) {
      ctx.fillStyle = (y / 4) % 2 < 1 ? '#c9a227' : '#1d1d1d';
      ctx.fillRect(solid.x, y, solid.w, 4);
    }
  } else {
    // A wooden door with panels and a knob.
    ctx.fillStyle = '#8a5f3c';
    ctx.fillRect(solid.x, solid.y, solid.w, solid.h);
    ctx.fillStyle = '#6b4a2f';
    ctx.fillRect(solid.x + 1, solid.y + 6, solid.w - 2, 20);
    ctx.fillRect(solid.x + 1, solid.y + 34, solid.w - 2, 24);
    ctx.fillStyle = '#e0c060';
    ctx.fillRect(solid.x + solid.w / 2 - 1, solid.y + 31, 2, 2);
  }
}

function drawApartmentRoof(ctx: CanvasRenderingContext2D, time: number): void {
  // Low wall around the roof and a blinking light.
  ctx.fillStyle = '#4b4f63';
  ctx.fillRect(0, ROOF_Y - 8, 4, 8);
  ctx.fillRect(BUILDING_W - 4, ROOF_Y - 8, 4, 8);
  ctx.fillStyle = Math.sin(time * 3) > 0 ? '#ff4d4d' : '#5a1a1a';
  ctx.fillRect(BUILDING_W / 2 - 1.5, ROOF_Y - 22, 3, 3);
  ctx.fillStyle = '#4b4f63';
  ctx.fillRect(BUILDING_W / 2 - 0.5, ROOF_Y - 19, 1, 5);
}

/** An old wooden water tower on the warehouse roof. */
function drawWaterTower(ctx: CanvasRenderingContext2D, inView: (x: number, y: number, w: number, h: number) => boolean): void {
  const x = WH_X + WH_W - 180;
  const y = WH_ROOF_Y;
  if (!inView(x - 10, y - 120, 100, 120)) return;
  ctx.strokeStyle = '#3a3d45';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(x + 10, y);
  ctx.lineTo(x + 18, y - 50);
  ctx.moveTo(x + 70, y);
  ctx.lineTo(x + 62, y - 50);
  ctx.moveTo(x + 12, y - 12);
  ctx.lineTo(x + 66, y - 40);
  ctx.moveTo(x + 68, y - 12);
  ctx.lineTo(x + 14, y - 40);
  ctx.stroke();
  ctx.fillStyle = '#5a4128';
  ctx.fillRect(x + 12, y - 98, 56, 48);
  ctx.fillStyle = '#4a3420';
  for (let i = 0; i < 4; i++) ctx.fillRect(x + 12, y - 92 + i * 12, 56, 2);
  ctx.fillStyle = '#3a2a1a';
  ctx.beginPath();
  ctx.moveTo(x + 8, y - 98);
  ctx.lineTo(x + 40, y - 118);
  ctx.lineTo(x + 72, y - 98);
  ctx.closePath();
  ctx.fill();
}