Back to Sickman Survival
SOURCE / PINNED RELEASE

Made of little things.

Sickman Survival

Release
7335c9fdf0a8…
Author-recorded commit
cbd2eeafa70d…
License
LICENSE
Author’s source reference
nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f

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

src/terrain.ts
import { createRng, type Rng } from './rng.ts';

/** World units per terrain cell. */
export const CELL = 4;
export const WORLD_COLS = 1200;
export const WORLD_ROWS = 260;
/** The surface landscape is laid out as if the world were this many rows deep; the rest is cave country. */
const SURFACE_ROWS = 160;

// Foreground materials: these take part in collision.
export const AIR = 0;
export const DIRT = 1;
export const GRASS = 2;
export const STONE = 3;
export const BEDROCK = 4;
export const BRICK = 5;
export const PLANK = 6;
export const LEAF = 7;
export const CAP = 8;
export const ROOF = 9;
export const CRATE = 10;

// Background materials: decorative and passable, still destroyed by blasts.
export const BG_NONE = 0;
export const BG_WALL = 1;
export const BG_TRUNK = 2;
export const BG_STEM = 3;
export const BG_WINDOW = 4;
export const BG_WINDOW_DARK = 5;
/** Bioluminescent cave fungus cap. */
export const BG_GLOW = 6;
/** Dark timber: posts, ceiling beams, window frames and furniture legs. */
export const BG_BEAM = 7;
/** A second plaster tone so neighbouring rooms read apart. */
export const BG_WALL_ALT = 8;
/** Bookshelf backs; the renderer varies book colors per cell. */
export const BG_SHELF = 9;

export const COLLIDE_NONE = 0;
export const COLLIDE_SOLID = 1;
/** One-way platforms: land on them from above, jump up through them. */
export const COLLIDE_ONEWAY = 2;

const COLLISION = new Uint8Array(16);
COLLISION[DIRT] = COLLIDE_SOLID;
COLLISION[GRASS] = COLLIDE_SOLID;
COLLISION[STONE] = COLLIDE_SOLID;
COLLISION[BEDROCK] = COLLIDE_SOLID;
COLLISION[BRICK] = COLLIDE_SOLID;
COLLISION[ROOF] = COLLIDE_SOLID;
COLLISION[CRATE] = COLLIDE_SOLID;
COLLISION[PLANK] = COLLIDE_ONEWAY;
COLLISION[LEAF] = COLLIDE_ONEWAY;
COLLISION[CAP] = COLLIDE_ONEWAY;

/** How much of a blast radius a material resists (1 = no resistance). */
const BLAST_REACH = new Float32Array(16).fill(1);
BLAST_REACH[STONE] = 0.8;
BLAST_REACH[BRICK] = 0.9;
BLAST_REACH[BEDROCK] = 0;

export interface DirtyRect {
  x0: number;
  y0: number;
  x1: number;
  y1: number;
}

export interface CarveResult {
  removed: number;
  /** A small sample of removed foreground materials, for debris colors. */
  sample: number[];
}

export class Terrain {
  readonly cols: number;
  readonly rows: number;
  readonly fg: Uint8Array;
  readonly bg: Uint8Array;
  /** Stable per-cell texture noise (0–255). */
  readonly noise: Uint8Array;
  /** Blast scorching (0–255). */
  readonly scorch: Uint8Array;
  /** Original ground row per column; open cells below it show the earth back wall. */
  readonly surface: Int16Array;
  /** Cell indices of glowing fungus caps, for the renderer's light pass. */
  glows: number[] = [];
  dirty: DirtyRect | null = null;

  constructor(cols: number, rows: number, seed = 1) {
    this.cols = cols;
    this.rows = rows;
    this.fg = new Uint8Array(cols * rows);
    this.bg = new Uint8Array(cols * rows);
    this.noise = new Uint8Array(cols * rows);
    this.scorch = new Uint8Array(cols * rows);
    this.surface = new Int16Array(cols).fill(rows);
    const rng = createRng(seed ^ 0x9e3779b9);
    for (let i = 0; i < this.noise.length; i++) this.noise[i] = Math.floor(rng.next() * 256);
  }

  get width(): number {
    return this.cols * CELL;
  }

  get height(): number {
    return this.rows * CELL;
  }

  inBounds(cx: number, cy: number): boolean {
    return cx >= 0 && cy >= 0 && cx < this.cols && cy < this.rows;
  }

  /** Foreground material; the world sides and floor behave as bedrock. */
  fgAt(cx: number, cy: number): number {
    if (cx < 0 || cx >= this.cols || cy >= this.rows) return BEDROCK;
    if (cy < 0) return AIR;
    return this.fg[cy * this.cols + cx];
  }

  set(cx: number, cy: number, material: number): void {
    if (this.inBounds(cx, cy)) this.fg[cy * this.cols + cx] = material;
  }

  setBg(cx: number, cy: number, material: number): void {
    if (this.inBounds(cx, cy)) this.bg[cy * this.cols + cx] = material;
  }

  collisionAt(cx: number, cy: number): number {
    return COLLISION[this.fgAt(cx, cy)];
  }

  /** Collision class at a world-space point. */
  collisionAtPoint(x: number, y: number): number {
    return this.collisionAt(Math.floor(x / CELL), Math.floor(y / CELL));
  }

  /** Cells below the original ground line at a world point (negative above ground). */
  depthAt(x: number, y: number): number {
    const cx = Math.max(0, Math.min(this.cols - 1, Math.floor(x / CELL)));
    return y / CELL - this.surface[cx];
  }

  /** First row (from the top) that something could stand on. */
  surfaceRow(cx: number): number {
    for (let cy = 0; cy < this.rows; cy++) {
      if (this.collisionAt(cx, cy) !== COLLIDE_NONE) return cy;
    }
    return this.rows;
  }

  markDirty(x0: number, y0: number, x1: number, y1: number): void {
    const rect = {
      x0: Math.max(0, x0),
      y0: Math.max(0, y0),
      x1: Math.min(this.cols - 1, x1),
      y1: Math.min(this.rows - 1, y1),
    };
    if (!this.dirty) {
      this.dirty = rect;
      return;
    }
    this.dirty.x0 = Math.min(this.dirty.x0, rect.x0);
    this.dirty.y0 = Math.min(this.dirty.y0, rect.y0);
    this.dirty.x1 = Math.max(this.dirty.x1, rect.x1);
    this.dirty.y1 = Math.max(this.dirty.y1, rect.y1);
  }

  /** Blow a roughly circular hole in the world and scorch its rim. */
  carve(wx: number, wy: number, radius: number): CarveResult {
    const scorchRadius = radius * 1.4;
    const cx0 = Math.floor((wx - scorchRadius) / CELL);
    const cx1 = Math.floor((wx + scorchRadius) / CELL);
    const cy0 = Math.floor((wy - scorchRadius) / CELL);
    const cy1 = Math.floor((wy + scorchRadius) / CELL);
    let removed = 0;
    const sample: number[] = [];

    for (let cy = cy0; cy <= cy1; cy++) {
      for (let cx = cx0; cx <= cx1; cx++) {
        if (!this.inBounds(cx, cy)) continue;
        const i = cy * this.cols + cx;
        const dx = (cx + 0.5) * CELL - wx;
        const dy = (cy + 0.5) * CELL - wy;
        const d = Math.hypot(dx, dy);
        // A little noise roughens the crater edge.
        const rough = radius * (0.92 + (this.noise[i] / 255) * 0.16);
        const m = this.fg[i];
        if (m !== AIR && d < rough * BLAST_REACH[m]) {
          this.fg[i] = AIR;
          removed++;
          if (sample.length < 48 && (removed & 1) === 0) sample.push(m);
        }
        if (this.bg[i] !== BG_NONE && d < rough * 0.95) this.bg[i] = BG_NONE;
        if (d < scorchRadius) {
          const heat = Math.min(1, Math.max(0, 1 - (d - radius * 0.7) / (scorchRadius - radius * 0.7)));
          this.scorch[i] = Math.max(this.scorch[i], Math.round(heat * 235));
        }
      }
    }
    this.markDirty(cx0 - 1, cy0 - 1, cx1 + 1, cy1 + 1);
    return { removed, sample };
  }
}

export interface Landmark {
  kind: 'building' | 'tree' | 'mushroom' | 'island' | 'cave-mouth';
  x0: number;
  x1: number;
}

export interface World {
  terrain: Terrain;
  spawnX: number;
  spawnY: number;
  landmarks: Landmark[];
}

/** Generate a new world: cliffs, ledges, islands, houses, trees and mushrooms over a cave network. */
export function generateWorld(seed: number, cols = WORLD_COLS, rows = WORLD_ROWS): World {
  const rng = createRng(seed);
  const terrain = new Terrain(cols, rows, seed);
  const ground = buildHeightmap(rng, cols, Math.min(rows, SURFACE_ROWS));
  const flats: { x0: number; x1: number }[] = [];
  const landmarks: Landmark[] = [];
  /** Feature counts were tuned for a 600-column world; scale them with width. */
  const scale = cols / 600;

  fillGround(rng, terrain, ground);
  addOverhangs(rng, terrain, ground);
  terrain.surface.set(ground);

  // Near-flat runs are candidate building lots; the house levels the rest.
  let runStart = 0;
  for (let x = 1; x <= cols; x++) {
    if (x === cols || Math.abs(ground[x] - ground[runStart]) > 2) {
      if (x - runStart >= HOUSE_MIN_WIDTH + 4) flats.push({ x0: runStart, x1: x - 1 });
      runStart = x;
    }
  }

  const used = new Uint8Array(cols);
  const reserve = (x0: number, x1: number, pad: number): boolean => {
    for (let x = Math.max(0, x0 - pad); x <= Math.min(cols - 1, x1 + pad); x++) if (used[x]) return false;
    for (let x = Math.max(0, x0); x <= Math.min(cols - 1, x1); x++) used[x] = 1;
    return true;
  };
  // Keep the spawn column clear of buildings.
  const spawnCol = Math.floor(cols / 2);
  for (let x = spawnCol - 5; x <= spawnCol + 5; x++) used[x] = 1;

  // Caves come first so houses and trees never stand over a cave mouth.
  for (const mouth of carveCaves(rng, terrain, ground, spawnCol, scale)) {
    for (let x = Math.max(0, mouth - 8); x <= Math.min(cols - 1, mouth + 8); x++) used[x] = 1;
    landmarks.push({ kind: 'cave-mouth', x0: mouth - 5, x1: mouth + 5 });
  }

  const houseTarget = Math.round(6 * scale);
  let houses = 0;
  for (const flat of flats.sort(() => rng.next() - 0.5)) {
    const width = rng.int(HOUSE_MIN_WIDTH, Math.min(HOUSE_MAX_WIDTH, flat.x1 - flat.x0 - 2));
    if (width < HOUSE_MIN_WIDTH) continue;
    const x0 = rng.int(flat.x0 + 1, flat.x1 - width - 1);
    // The reservation also covers eaves, balconies and the graded approach on each side.
    if (x0 < 24 || x0 + width > cols - 24 || !reserve(x0 - HOUSE_YARD, x0 + width - 1 + HOUSE_YARD, 2)) continue;
    buildHouse(rng, terrain, ground, x0, width);
    landmarks.push({ kind: 'building', x0, x1: x0 + width - 1 });
    if (++houses >= houseTarget) break;
  }

  const treeTarget = Math.round(rng.int(12, 18) * scale);
  for (let attempt = 0, made = 0; attempt < 200 * scale && made < treeTarget; attempt++) {
    const x = rng.int(22, cols - 23);
    const level = terrain.surfaceRow(x);
    if (Math.abs(terrain.surfaceRow(x + 1) - level) > 1 || terrain.fgAt(x, level) !== GRASS) continue;
    if (!reserve(x - 3, x + 4, 1)) continue;
    plantTree(rng, terrain, x, level);
    landmarks.push({ kind: 'tree', x0: x - 3, x1: x + 4 });
    made++;
  }

  const shroomTarget = Math.round(rng.int(9, 15) * scale);
  for (let attempt = 0, made = 0; attempt < 200 * scale && made < shroomTarget; attempt++) {
    const x = rng.int(22, cols - 23);
    const level = terrain.surfaceRow(x);
    if (terrain.fgAt(x, level) !== GRASS) continue;
    if (!reserve(x - 2, x + 2, 0)) continue;
    growMushroom(rng, terrain, x, level);
    landmarks.push({ kind: 'mushroom', x0: x - 2, x1: x + 2 });
    made++;
  }

  for (let i = Math.round(rng.int(6, 10) * scale); i > 0; i--) {
    const x = rng.int(25, cols - 26);
    const level = terrain.surfaceRow(x);
    if (used[x] || used[x + 2] || terrain.fgAt(x, level) !== GRASS) continue;
    if (rng.chance(0.55)) placeBoulder(rng, terrain, x, level);
    else placeCrates(rng, terrain, x, level);
  }

  for (let i = Math.round(rng.int(3, 6) * scale); i > 0; i--) {
    const width = rng.int(9, 20);
    const x0 = rng.int(25, cols - width - 25);
    let top = rows;
    for (let x = x0; x < x0 + width; x++) top = Math.min(top, terrain.surfaceRow(x));
    const y = top - rng.int(17, 26);
    if (y < 12 || (x0 <= spawnCol + 6 && x0 + width >= spawnCol - 6)) continue;
    floatIsland(rng, terrain, x0, y, width);
    landmarks.push({ kind: 'island', x0, x1: x0 + width - 1 });
  }

  // Re-clear the spawn pocket in case a canopy or island hangs over it.
  const spawnRow = terrain.surfaceRow(spawnCol);
  for (let y = spawnRow - 9; y < spawnRow; y++) {
    for (let x = spawnCol - 2; x <= spawnCol + 2; x++) {
      if (terrain.collisionAt(x, y) !== COLLIDE_NONE) terrain.set(x, y, AIR);
    }
  }
  terrain.dirty = { x0: 0, y0: 0, x1: cols - 1, y1: rows - 1 };
  return {
    terrain,
    spawnX: (spawnCol + 0.5) * CELL,
    spawnY: terrain.surfaceRow(spawnCol) * CELL,
    landmarks,
  };
}

function buildHeightmap(rng: Rng, cols: number, rows: number): Int16Array {
  const ground = new Int16Array(cols);
  const minLevel = Math.round(rows * 0.4);
  const maxLevel = Math.round(rows * 0.74);
  let level = Math.round(rows * 0.6);
  let x = 0;
  while (x < cols) {
    const len = rng.int(16, 52);
    const kind = rng.next();
    const start = level;
    let target = level;
    if (kind < 0.28) {
      // Cliff: an abrupt step up or down.
      level = clamp(level + rng.sign() * rng.int(9, 20), minLevel, maxLevel);
      target = level;
    } else if (kind < 0.6) {
      target = clamp(level + rng.sign() * rng.int(4, 14), minLevel, maxLevel);
    }
    const hill = kind > 0.82 ? rng.int(3, 9) : 0;
    for (let i = 0; i < len && x < cols; i++, x++) {
      const t = i / len;
      const base = kind < 0.28 ? level : Math.round(start + (target - start) * t);
      ground[x] = base - Math.round(Math.sin(t * Math.PI) * hill);
    }
    level = target;
  }
  // Raise the world edges into tall walls so the level reads as bounded.
  for (let i = 0; i < 16; i++) {
    const lift = Math.round(((16 - i) / 16) ** 2 * 42);
    ground[i] = Math.max(8, ground[i] - lift);
    ground[cols - 1 - i] = Math.max(8, ground[cols - 1 - i] - lift);
  }
  return ground;
}

function fillGround(rng: Rng, terrain: Terrain, ground: Int16Array): void {
  const { cols, rows } = terrain;
  let stoneDepth = 16;
  for (let x = 0; x < cols; x++) {
    stoneDepth = clamp(stoneDepth + rng.int(-1, 1), 10, 26);
    const bedrockTop = rows - 3 - (rng.chance(0.4) ? 1 : 0);
    for (let y = ground[x]; y < rows; y++) {
      const depth = y - ground[x];
      let m = depth === 0 ? GRASS : depth < stoneDepth ? DIRT : STONE;
      if (y >= bedrockTop) m = BEDROCK;
      terrain.set(x, y, m);
    }
  }
  // Stone pockets in the dirt for texture and tougher spots.
  for (let i = Math.round(rng.int(14, 24) * (cols / 600)); i > 0; i--) {
    const x = rng.int(0, cols - 1);
    const y = ground[x] + rng.int(4, 18);
    const r = rng.range(1.5, 4);
    for (let dy = -4; dy <= 4; dy++) {
      for (let dx = -5; dx <= 5; dx++) {
        if (dx * dx * 0.6 + dy * dy <= r * r && terrain.fgAt(x + dx, y + dy) === DIRT) {
          terrain.set(x + dx, y + dy, STONE);
        }
      }
    }
  }
}

/** Stone lips that jut out over cliff drops. */
function addOverhangs(rng: Rng, terrain: Terrain, ground: Int16Array): void {
  for (let x = 20; x < terrain.cols - 20; x++) {
    const step = ground[x + 1] - ground[x];
    if (Math.abs(step) < 9 || !rng.chance(0.7)) continue;
    const dir = step > 0 ? 1 : -1;
    const top = Math.min(ground[x], ground[x + 1]);
    const reach = rng.int(3, 8);
    const from = dir > 0 ? x + 1 : x;
    for (let i = 0; i < reach; i++) {
      const cx = from + dir * i;
      const thickness = i < reach - 2 ? 3 : 2;
      for (let t = 0; t < thickness; t++) terrain.set(cx, top + t, t === 0 ? GRASS : STONE);
    }
  }
}

/** House footprint limits, in cells. */
const HOUSE_MIN_WIDTH = 28;
const HOUSE_MAX_WIDTH = 46;
/** Clear ground kept beside a house for eaves, balconies and its graded approach. */
const HOUSE_YARD = 7;
/** Floor-to-floor height: the 30-unit stickman needs ~8 cells, plus room to jump. */
const STOREY = 14;
/** Doorway height: 36 units, comfortably taller than the stickman, under a brick lintel. */
const DOOR = 9;
/** Exterior wall thickness. */
const WALL = 2;
/** Stair treads are 3 cells wide and rise 2; the top tread sits just under the stairwell. */
const STAIR_STEPS = (STOREY - 2) / 2;
const STAIR_RUN = 3;

/**
 * A multi-storey brick house: doors on both ground-floor sides, a plank
 * staircase and stairwell on every floor, furnished rooms, upper-floor
 * balconies, an optional cellar under a trapdoor, and an attic or roof deck.
 */
function buildHouse(rng: Rng, terrain: Terrain, ground: Int16Array, x0: number, width: number): void {
  let base = 0;
  for (let x = x0; x < x0 + width; x++) base = Math.max(base, ground[x]);
  const floors = Math.max(1, Math.min(rng.int(1, 3), Math.floor((base - 34) / STOREY)));
  const top = base - floors * STOREY;
  const x1 = x0 + width - 1;
  const inner0 = x0 + WALL;
  const inner1 = x1 - WALL;
  const pitched = rng.chance(0.6);

  // Level the lot: foundation below, clear air above (high enough for any roof).
  for (let x = x0 - 2; x <= x1 + 2; x++) {
    for (let y = top - 32; y < base; y++) {
      terrain.set(x, y, AIR);
      terrain.setBg(x, y, BG_NONE);
    }
    for (let y = base; y < base + 3; y++) if (terrain.fgAt(x, y) === AIR || y === base) terrain.set(x, y, y === base ? GRASS : DIRT);
    // The levelled lot is the new ground line, so the house interior stays sky-backed.
    terrain.surface[x] = base;
  }
  gradeApproach(terrain, ground, x0 - 3, -1, base);
  gradeApproach(terrain, ground, x1 + 3, 1, base);
  for (let x = x0; x <= x1; x++) terrain.set(x, base, BRICK);

  // Shell: thick brick side walls around plastered rooms.
  for (let y = top; y < base; y++) {
    for (let x = x0; x <= x1; x++) {
      if (x < inner0 || x > inner1) terrain.set(x, y, BRICK);
      else terrain.setBg(x, y, BG_WALL);
    }
  }

  let stairSide = rng.chance(0.5) ? -1 : 1;
  for (let f = 0; f < floors; f++) {
    const floorRow = base - f * STOREY;
    const ceiling = floorRow - STOREY;
    const topFloor = f === floors - 1;
    const busy = new Uint8Array(width);
    const claim = (from: number, to: number): boolean => {
      for (let x = from; x <= to; x++) if (x < inner0 || x > inner1 || busy[x - x0]) return false;
      for (let x = from; x <= to; x++) busy[x - x0] = 1;
      return true;
    };

    // Rooms: a timber post splits wide floors, and each room gets its own plaster.
    const split = inner1 - inner0 > 30 ? rng.int(inner0 + 12, inner1 - 12) : -1;
    if (rng.chance(0.5) || split > 0) {
      const from = split > 0 && rng.chance(0.5) ? split : inner0;
      const to = from === split ? inner1 : split > 0 ? split : inner1;
      for (let y = ceiling + 1; y < floorRow; y++) for (let x = from; x <= to; x++) terrain.setBg(x, y, BG_WALL_ALT);
    }
    for (let x = inner0; x <= inner1; x++) terrain.setBg(x, ceiling + 1, BG_BEAM);
    if (split > 0) {
      for (let y = ceiling + 1; y < floorRow; y++) terrain.setBg(split, y, BG_BEAM);
      busy[split - x0] = 1;
    }

    // Ceiling / next floor: one-way planks, or a solid roof deck over a flat-roofed top floor.
    for (let x = inner0; x <= inner1; x++) terrain.set(x, ceiling, topFloor && !pitched ? ROOF : PLANK);

    // Staircase up to a stairwell gap, alternating sides floor to floor.
    const run = STAIR_STEPS * STAIR_RUN;
    const sx = stairSide < 0 ? inner0 + 2 : inner1 - 2 - run + 1;
    for (let k = 0; k < STAIR_STEPS; k++) {
      const tx = stairSide < 0 ? sx + k * STAIR_RUN : sx + run - (k + 1) * STAIR_RUN;
      const row = floorRow - 2 * (k + 1);
      for (let dx = 0; dx < STAIR_RUN; dx++) terrain.set(tx + dx, row, PLANK);
      // A diagonal stringer under the treads.
      terrain.setBg(stairSide < 0 ? tx : tx + STAIR_RUN - 1, row + 1, BG_BEAM);
    }
    claim(sx, sx + run - 1);
    const wellX = stairSide < 0 ? sx + run - STAIR_RUN : sx - 2;
    const hatch = topFloor && !pitched ? PLANK : AIR;
    for (let dx = 0; dx < STAIR_RUN + 2; dx++) terrain.set(wellX + dx, ceiling, hatch);
    stairSide = -stairSide;

    // Doors: both sides on the ground floor; upper floors open onto a balcony.
    const doorSides = f === 0 ? [-1, 1] : [rng.chance(0.5) ? -1 : 1];
    for (const side of doorSides) {
      const wx = side < 0 ? x0 : inner1 + 1;
      for (let y = floorRow - DOOR; y < floorRow; y++) {
        for (let dx = 0; dx < WALL; dx++) terrain.set(wx + dx, y, AIR);
      }
      if (f > 0) {
        const edge = side < 0 ? x0 - 1 : x1 + 1;
        for (let i = 0; i < 5; i++) terrain.set(edge + side * i, floorRow, PLANK);
        // Timber brace under the balcony.
        for (let i = 0; i < 3; i++) terrain.setBg(edge + side * i, floorRow + 3 - i, BG_BEAM);
      }
    }

    // Framed windows along the back wall.
    for (let wx = inner0 + 3; wx + 5 < inner1 - 1; wx += rng.int(9, 13)) {
      if (Math.abs(wx + 2 - split) < 4) continue;
      const lit = rng.chance(0.6) ? BG_WINDOW : BG_WINDOW_DARK;
      for (let dy = 0; dy < 5; dy++) {
        for (let dx = 0; dx < 5; dx++) terrain.setBg(wx + dx, floorRow - 10 + dy, dx === 2 || dy === 2 ? BG_BEAM : lit);
      }
      for (let dx = -1; dx <= 5; dx++) terrain.setBg(wx + dx, floorRow - 5, BG_BEAM);
    }

    furnish(rng, terrain, floorRow, ceiling, inner0, inner1, claim);
  }

  if (rng.chance(0.5)) digCellar(rng, terrain, base, inner0, inner1);

  if (pitched) {
    // Hollow attic under a 2-cell roof with overhanging eaves, a gable window and a chimney.
    const e0 = x0 - 2;
    const e1 = x1 + 2;
    const roofRow = (x: number): number => top - 1 - Math.floor(Math.min(x - e0, e1 - x) * 0.8);
    for (let x = e0; x <= e1; x++) {
      const r = roofRow(x);
      terrain.set(x, r, ROOF);
      terrain.set(x, r - 1, ROOF);
      if (x >= x0 && x <= x1) for (let y = r + 1; y < top; y++) terrain.setBg(x, y, BG_WALL_ALT);
    }
    const mid = Math.floor((x0 + x1) / 2);
    for (let dy = 0; dy < 3; dy++) for (let dx = -1; dx <= 1; dx++) terrain.setBg(mid + dx, top - 6 + dy, BG_WINDOW);
    if (rng.chance(0.6)) {
      const lx = rng.chance(0.5) ? inner0 + 1 : inner1 - 4;
      for (let dy = 1; dy <= 3; dy++) for (let dx = 0; dx < 4; dx++) terrain.set(lx + dx, top - dy, CRATE);
    }
    const chimney = rng.chance(0.5) ? x0 + 4 : x1 - 5;
    const chimneyTop = roofRow(chimney + 1) - 6;
    for (let y = chimneyTop; y < roofRow(chimney + 1); y++) {
      for (let dx = 0; dx < 2; dx++) {
        if (terrain.fgAt(chimney + dx, y) === AIR) terrain.set(chimney + dx, y, BRICK);
      }
    }
  } else {
    // Flat roof deck with a parapet, reached through the top-floor trapdoor.
    for (let y = top - 2; y < top; y++) {
      for (let dx = 0; dx < WALL; dx++) {
        terrain.set(x0 + dx, y, BRICK);
        terrain.set(x1 - dx, y, BRICK);
      }
    }
    // A water tank on stilts: planks to perch on, timber legs behind.
    const tx = rng.chance(0.5) ? inner0 + 2 : inner1 - 8;
    for (let y = top - 6; y < top; y++) {
      terrain.setBg(tx + 1, y, BG_BEAM);
      terrain.setBg(tx + 5, y, BG_BEAM);
    }
    for (let dx = 0; dx < 7; dx++) terrain.set(tx + dx, top - 6, PLANK);
    for (let y = top - 11; y < top - 6; y++) for (let dx = 1; dx < 6; dx++) terrain.setBg(tx + dx, y, BG_WALL_ALT);
  }
}

/** Ease the ground beside a lot toward its foundation in walkable 2-cell steps. */
function gradeApproach(terrain: Terrain, ground: Int16Array, from: number, dir: number, base: number): void {
  let level = base;
  for (let i = 0; i < HOUSE_YARD; i++) {
    const x = from + dir * i;
    if (x < 1 || x >= terrain.cols - 1) return;
    const g = ground[x];
    if (Math.abs(g - level) <= 2) return;
    level = g < level ? level - 2 : level + 2;
    for (let y = Math.min(g, level); y < Math.max(g, level); y++) terrain.set(x, y, g < level ? AIR : DIRT);
    terrain.set(x, level, GRASS);
    ground[x] = level;
    terrain.surface[x] = level;
  }
}

/** Tables, bookshelves, crates and hanging lamps on one floor. */
function furnish(
  rng: Rng, terrain: Terrain, floorRow: number, ceiling: number, inner0: number, inner1: number,
  claim: (from: number, to: number) => boolean,
): void {
  for (let attempt = 0, placed = 0; attempt < 14 && placed < 3; attempt++) {
    const kind = rng.int(0, 2);
    const w = kind === 0 ? rng.int(5, 7) : kind === 1 ? 4 : 3;
    const x = rng.int(inner0 + 3, inner1 - 3 - w);
    if (!claim(x - 1, x + w)) continue;
    placed++;
    if (kind === 0) {
      // Table: a one-way top to hop onto, with timber legs.
      for (let dx = 0; dx < w; dx++) terrain.set(x + dx, floorRow - 3, PLANK);
      for (let dy = 1; dy <= 2; dy++) {
        terrain.setBg(x, floorRow - dy, BG_BEAM);
        terrain.setBg(x + w - 1, floorRow - dy, BG_BEAM);
      }
      if (rng.chance(0.5)) terrain.setBg(x + rng.int(1, w - 2), floorRow - 4, BG_WINDOW);
    } else if (kind === 1) {
      // Bookshelf against the back wall.
      for (let dy = 1; dy <= 7; dy++) {
        for (let dx = 0; dx < w; dx++) {
          const frame = dx === 0 || dx === w - 1 || dy % 2 === 1;
          terrain.setBg(x + dx, floorRow - dy, frame ? BG_BEAM : BG_SHELF);
        }
      }
    } else {
      // Crates, sometimes stacked into a step.
      for (let dy = 1; dy <= 3; dy++) for (let dx = 0; dx < 3; dx++) terrain.set(x + dx, floorRow - dy, CRATE);
      if (rng.chance(0.35)) {
        for (let dy = 4; dy <= 5; dy++) for (let dx = 1; dx < 3; dx++) terrain.set(x + dx, floorRow - dy, CRATE);
      }
    }
  }
  // A hanging lamp or two, clear of anything on the floor.
  for (let i = rng.int(1, 2); i > 0; i--) {
    const x = rng.int(inner0 + 3, inner1 - 4);
    for (let y = ceiling + 2; y < ceiling + 4; y++) terrain.setBg(x, y, BG_BEAM);
    terrain.setBg(x - 1, ceiling + 4, BG_WINDOW);
    terrain.setBg(x, ceiling + 4, BG_WINDOW);
    terrain.setBg(x + 1, ceiling + 4, BG_WINDOW);
  }
}

/** A brick-lined cellar under the ground floor, reached through a plank trapdoor. */
function digCellar(rng: Rng, terrain: Terrain, base: number, inner0: number, inner1: number): void {
  const c0 = inner0 + 2;
  const c1 = inner1 - 2;
  const floor = base + STOREY - 1;
  if (floor >= terrain.rows - 6) return;
  for (let y = base + 1; y <= floor; y++) {
    for (let x = c0 - 1; x <= c1 + 1; x++) {
      const edge = y === floor || x === c0 - 1 || x === c1 + 1;
      // Where the cellar meets a cave it breaks through rather than plugging the passage.
      if (edge && terrain.fgAt(x, y) === AIR) continue;
      terrain.set(x, y, edge ? BRICK : AIR);
      if (!edge) terrain.setBg(x, y, BG_WALL_ALT);
    }
  }
  for (let x = c0; x <= c1; x++) terrain.setBg(x, base + 1, BG_BEAM);
  const hatch = rng.int(c0 + 2, c1 - 6);
  for (let dx = 0; dx < 4; dx++) terrain.set(hatch + dx, base, PLANK);
  // Barrels of supplies: crates along the cellar floor.
  for (let x = c0 + 1; x + 3 <= c1; x += rng.int(5, 9)) {
    if (x + 3 > hatch - 1 && x < hatch + 5) continue;
    if (!rng.chance(0.6)) continue;
    for (let dy = 1; dy <= 3; dy++) for (let dx = 0; dx < 3; dx++) terrain.set(x + dx, floor - dy, CRATE);
  }
}

/** Solid rock kept between the landscape and the cave network, in cells. */
const CAVE_CRUST = 14;
/** Steepest cave passage, in radians from horizontal: walkable with step-ups and jumps. */
const CAVE_SLOPE = 0.6;

/**
 * Worm-carved cave network: a chain of chambers linked by tunnels across the
 * whole world, dead-end mole nooks, and a few sloping mouths from the surface.
 * Returns the mouth columns.
 */
function carveCaves(rng: Rng, terrain: Terrain, ground: Int16Array, spawnCol: number, scale: number): number[] {
  const { cols, rows } = terrain;
  const bottom = rows - 8;
  const open = new Uint8Array(cols * rows);
  const top = (x: number): number => ground[clamp(Math.round(x), 0, cols - 1)] + CAVE_CRUST;

  const dig = (x: number, y: number, r: number): void => {
    const r2 = r * r;
    for (let cy = Math.floor(y - r); cy <= Math.ceil(y + r); cy++) {
      for (let cx = Math.floor(x - r); cx <= Math.ceil(x + r); cx++) {
        if (!terrain.inBounds(cx, cy) || cx < 3 || cx >= cols - 3) continue;
        const dx = cx + 0.5 - x;
        const dy = cy + 0.5 - y;
        if (dx * dx + dy * dy > r2 || terrain.fgAt(cx, cy) === BEDROCK) continue;
        terrain.set(cx, cy, AIR);
        open[cy * cols + cx] = 1;
      }
    }
  };

  /** Walk a worm from (x, y) along `steer`, carving as it goes. */
  const worm = (
    x: number, y: number, heading: number, steps: number, radius: number,
    steer: (x: number, y: number) => number | null, crust = true,
  ): void => {
    const wobble = rng.range(0, Math.PI * 2);
    for (let i = 0; i < steps; i++) {
      const want = steer(x, y);
      if (want === null) return;
      heading = clampSlope(turnToward(heading, want, 0.14) + rng.range(-0.3, 0.3));
      x += Math.cos(heading);
      y += Math.sin(heading);
      const r = radius + Math.sin(i * 0.13 + wobble) * 0.6;
      if (crust) y = clamp(y, top(x) + r + 1, bottom - r);
      if (x < 8 || x > cols - 9) return;
      dig(x, y, r);
    }
  };

  const chamber = (x: number, y: number): void => {
    const rx = rng.range(11, 22);
    const ry = rng.range(8, 13);
    const a = rng.range(0, Math.PI * 2);
    const b = rng.range(0, Math.PI * 2);
    // Lumpy, flat-floored ellipse: the lower part is cut off so the floor is walkable.
    for (let dy = -Math.ceil(ry * 1.3); dy <= Math.floor(ry * 0.55); dy++) {
      for (let dx = -Math.ceil(rx * 1.3); dx <= Math.ceil(rx * 1.3); dx++) {
        const theta = Math.atan2(dy / ry, dx / rx);
        const lump = 1 + Math.sin(theta * 3 + a) * 0.16 + Math.sin(theta * 5 + b) * 0.09;
        if ((dx / rx) ** 2 + (dy / ry) ** 2 <= lump * lump) dig(x + dx, y + dy, 0.75);
      }
    }
  };

  const tunnel = (a: { x: number; y: number }, b: { x: number; y: number }): void => {
    const steps = Math.round((Math.abs(b.x - a.x) + Math.abs(b.y - a.y)) * 2.5);
    worm(a.x, a.y, Math.atan2(b.y - a.y, b.x - a.x), steps, rng.range(4.6, 5.6), (x, y) =>
      Math.hypot(b.x - x, b.y - y) < 2 ? null : Math.atan2(b.y - y, b.x - x));
  };

  /** A left-to-right chain of chambers, each within a walkable slope of the last. */
  const chain = (count: number, lo: (x: number) => number, hi: (x: number) => number): { x: number; y: number }[] => {
    const span = (cols - 70) / count;
    const points: { x: number; y: number }[] = [];
    for (let i = 0; i < count; i++) {
      const x = Math.round(35 + span * (i + 0.5 + rng.range(-0.22, 0.22)));
      const prev = points[i - 1];
      let y = rng.int(Math.round(lo(x)), Math.round(Math.max(lo(x), hi(x))));
      if (prev) {
        const reach = (x - prev.x) * 0.5;
        y = clamp(y, prev.y - reach, prev.y + reach);
      }
      y = Math.min(y, bottom - 8);
      points.push({ x, y });
      chamber(x, y);
      if (prev) tunnel(prev, points[i]);
    }
    return points;
  };

  const upper = chain(Math.round(rng.int(6, 8) * scale), (x) => top(x) + 7, (x) => top(x) + 26);
  const lower = chain(Math.round(rng.int(5, 7) * scale), (x) => top(x) + 46, () => bottom - 10);
  const chambers = [...upper, ...lower];

  // Sloping links between the shallow and deep networks.
  for (let links = 0, attempt = 0; links < Math.round(3 * scale) && attempt < 12 * scale; attempt++) {
    const b = lower[rng.int(0, lower.length - 1)];
    let best: { x: number; y: number } | null = null;
    for (const a of upper) {
      const dx = Math.abs(a.x - b.x);
      if (dx * 0.5 < b.y - a.y || dx > 140) continue;
      if (!best || dx < Math.abs(best.x - b.x)) best = a;
    }
    if (!best) continue;
    tunnel(best, b);
    links++;
  }

  // Dead-end nooks: often too tight to walk, which suits the moles.
  for (let i = Math.round(rng.int(4, 7) * scale); i > 0; i--) {
    const from = chambers[rng.int(0, chambers.length - 1)];
    const tilt = rng.sign() * rng.range(0.2, CAVE_SLOPE);
    let heading = rng.chance(0.5) ? tilt : Math.PI - tilt;
    worm(from.x, from.y, heading, rng.int(14, 38), rng.range(3.4, 5), () => (heading += rng.range(-0.1, 0.1)));
  }

  // Sloping mouths from the surface down into the network, away from the spawn.
  const network = open.slice();
  const mouths: number[] = [];
  for (let attempt = 0; attempt < 40 * scale && mouths.length < Math.round(3 * scale); attempt++) {
    const x = rng.int(40, cols - 41);
    if (Math.abs(x - spawnCol) < 45 || mouths.some((m) => Math.abs(m - x) < 70)) continue;
    const dir = x < cols / 2 ? 1 : -1;
    const slope = rng.range(0.5, CAVE_SLOPE);
    worm(x, ground[x] - 2, dir > 0 ? slope : Math.PI - slope, 260, rng.range(4.8, 5.4), (cx, cy) => {
      const i = Math.floor(cy) * cols + Math.floor(cx);
      if (cy > top(cx) + 2 && network[i]) return null;
      return cy >= bottom - 6 ? null : dir > 0 ? slope : Math.PI - slope;
    }, false);
    mouths.push(x);
  }

  smoothCaves(terrain, open);
  decorateCaves(rng, terrain, open);
  return mouths;
}

/** Erode thin slivers and single-cell bumps left where carved shapes nearly meet. */
function smoothCaves(terrain: Terrain, open: Uint8Array): void {
  const { cols, rows } = terrain;
  for (let pass = 0; pass < 2; pass++) {
    const erode: number[] = [];
    for (let y = 1; y < rows - 1; y++) {
      for (let x = 1; x < cols - 1; x++) {
        const i = y * cols + x;
        if (open[i] || terrain.fg[i] === AIR || terrain.fg[i] === BEDROCK || y <= terrain.surface[x] + 1) continue;
        let around = 0;
        for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) around += open[i + dy * cols + dx];
        if (around >= 5) erode.push(i);
      }
    }
    for (const i of erode) {
      terrain.fg[i] = AIR;
      open[i] = 1;
    }
  }
}

/** Stalactites over tall chambers and glowing fungus on cave floors. */
function decorateCaves(rng: Rng, terrain: Terrain, open: Uint8Array): void {
  const { cols, rows, surface } = terrain;
  for (let x = 4; x < cols - 4; x++) {
    for (let y = surface[x] + 8; y < rows - 4; y++) {
      const i = y * cols + x;
      if (!open[i]) continue;
      const solid = (cy: number): boolean => terrain.collisionAt(x, cy) === COLLIDE_SOLID;
      if (solid(y - 1) && rng.chance(0.07)) {
        let clear = 0;
        while (clear < 16 && !solid(y + clear)) clear++;
        if (clear < 16) continue;
        const length = rng.int(2, 5);
        for (let d = 0; d < length; d++) terrain.set(x, y + d, STONE);
        y += length;
      } else if (solid(y + 1) && !solid(y - 1) && rng.chance(0.035)) {
        const stem = rng.int(0, 2);
        for (let d = 0; d < stem; d++) terrain.setBg(x, y - d, BG_STEM);
        terrain.setBg(x, y - stem, BG_GLOW);
        terrain.glows.push((y - stem) * cols + x);
      }
    }
  }
}

function turnToward(angle: number, target: number, maxTurn: number): number {
  const diff = Math.atan2(Math.sin(target - angle), Math.cos(target - angle));
  return angle + clamp(diff, -maxTurn, maxTurn);
}

/** Keep a heading within CAVE_SLOPE of horizontal, preserving its left/right sense. */
function clampSlope(heading: number): number {
  const dx = Math.cos(heading);
  const dy = Math.sin(heading);
  if (Math.abs(dy) <= Math.sin(CAVE_SLOPE)) return heading;
  return Math.atan2(Math.sign(dy) * Math.sin(CAVE_SLOPE), (dx < 0 ? -1 : 1) * Math.cos(CAVE_SLOPE));
}

function plantTree(rng: Rng, terrain: Terrain, x: number, level: number): void {
  const height = rng.int(9, 20);
  for (let y = level - height; y < level; y++) {
    terrain.setBg(x, y, BG_TRUNK);
    terrain.setBg(x + 1, y, BG_TRUNK);
  }
  const top = level - height;
  if (rng.chance(0.35)) {
    // Pine: stacked one-way tiers.
    for (let tier = 0; tier < 3; tier++) {
      const y = top + tier * 4;
      const half = 2 + tier * 2;
      for (let dy = 0; dy < 2; dy++) {
        for (let dx = -half + dy; dx <= half + 1 - dy; dx++) terrain.set(x + dx, y - dy, LEAF);
      }
    }
    return;
  }
  const rx = rng.range(4, 8);
  const ry = rng.range(3, 5);
  for (let dy = -Math.ceil(ry); dy <= Math.ceil(ry); dy++) {
    for (let dx = -Math.ceil(rx); dx <= Math.ceil(rx) + 1; dx++) {
      const nx = (dx - 0.5) / rx;
      const ny = dy / ry;
      if (nx * nx + ny * ny <= 1 - rng.next() * 0.18) terrain.set(x + dx, top + dy, LEAF);
    }
  }
}

function growMushroom(rng: Rng, terrain: Terrain, x: number, level: number): void {
  const big = rng.chance(0.4);
  const height = big ? rng.int(5, 8) : rng.int(2, 4);
  const stemWidth = big ? 2 : 1;
  for (let y = level - height; y < level; y++) {
    for (let dx = 0; dx < stemWidth; dx++) terrain.setBg(x + dx, y, BG_STEM);
  }
  const rx = big ? rng.int(4, 6) : rng.int(2, 3);
  const ry = big ? 3 : 2;
  const capBase = level - height;
  for (let dy = 0; dy < ry; dy++) {
    const span = Math.round(rx * Math.sqrt(1 - (dy / ry) ** 2));
    for (let dx = -span; dx <= span + stemWidth - 1; dx++) terrain.set(x + dx, capBase - dy, CAP);
  }
}

function placeBoulder(rng: Rng, terrain: Terrain, x: number, level: number): void {
  const r = rng.range(1.8, 3.6);
  for (let dy = -4; dy <= 2; dy++) {
    for (let dx = -4; dx <= 4; dx++) {
      if (dx * dx + dy * dy * 1.4 <= r * r) terrain.set(x + dx, level + dy, STONE);
    }
  }
}

function placeCrates(rng: Rng, terrain: Terrain, x: number, level: number): void {
  const stack = rng.int(1, 2);
  for (let s = 0; s < stack; s++) {
    const ox = x + (s === 1 ? 1 : 0);
    for (let dy = 0; dy < 3; dy++) {
      for (let dx = 0; dx < 3; dx++) terrain.set(ox + dx, level - 1 - s * 3 - dy, CRATE);
    }
  }
}

function floatIsland(rng: Rng, terrain: Terrain, x0: number, y: number, width: number): void {
  const depth = rng.int(3, 6);
  for (let i = 0; i < width; i++) {
    const t = (i + 0.5) / width;
    const under = Math.max(1, Math.round(depth * Math.sin(t * Math.PI)));
    for (let d = 0; d <= under; d++) terrain.set(x0 + i, y + d, d === 0 ? GRASS : d === 1 ? DIRT : STONE);
  }
  if (width > 12 && rng.chance(0.5)) growMushroom(rng, terrain, x0 + Math.floor(width / 2), y);
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value));
}