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/moles.ts
import type { Rng } from './rng.ts';
import { BEDROCK, CELL, COLLIDE_NONE, COLLIDE_SOLID, type Terrain } from './terrain.ts';

/**
 * Blast moles: they swim through solid earth without disturbing it, pop out
 * into caves, wander and hunt, and blow up when the stickman gets close.
 * This module owns their movement; the game owns arming and explosions.
 */

export const MOLE_HALF_W = 5;
export const MOLE_HALF_H = 4;
/** A surfaced mole arms when the stickman's hitbox comes this close. */
export const MOLE_TRIGGER = 30;
/** Seconds from arming to detonation. */
export const MOLE_FUSE = 0.8;
export const MOLE_BLAST = 30;
/** Moles only surface this many cells or more below the original ground line. */
export const MOLE_MIN_DEPTH = 4;

const GRAVITY = 1400;
const BURROW_SPEED = 40;
const BURROW_HUNT_SPEED = 62;
const WALK_SPEED = 28;
const WALK_HUNT_SPEED = 56;
/** Armed moles keep shuffling at the stickman while the fuse burns. */
const LUNGE_SPEED = 35;
/** Burrowing moles home in on the stickman inside this range. */
const SENSE_RANGE = 230;

export type MoleState = 'burrow' | 'walk' | 'armed';

export interface Mole {
  /** Body centre. */
  x: number;
  y: number;
  vy: number;
  /** Heading while burrowing, in radians. */
  dir: number;
  facing: 1 | -1;
  state: MoleState;
  grounded: boolean;
  /** Burrowing: seconds underground. Walking: seconds left before diving again. */
  timer: number;
  /** Seconds a fresh dive must last before the mole may surface again. */
  minBurrow: number;
  fuse: number;
  /** Fuse length when armed, for the swell and flash animation. */
  fuseLength: number;
  /** Walk cycle. */
  phase: number;
  /** Seconds since surfacing, for the pop-out animation. */
  pop: number;
  /** Dust emission accumulator while burrowing. */
  dust: number;
}

export interface MoleTarget {
  x: number;
  y: number;
  active: boolean;
}

export type MoleEvent = 'surface' | 'dive' | 'gone' | null;

export function createMole(x: number, y: number, rng: Rng): Mole {
  return {
    x,
    y,
    vy: 0,
    dir: rng.range(0, Math.PI * 2),
    facing: rng.sign(),
    state: 'burrow',
    grounded: false,
    timer: 0,
    minBurrow: rng.range(0.5, 2.5),
    fuse: 0,
    fuseLength: MOLE_FUSE,
    phase: rng.range(0, Math.PI * 2),
    pop: 0,
    dust: rng.next(),
  };
}

/** Solid cells overlapping a mole body centred at (x, y). */
export function moleBlocked(t: Terrain, x: number, y: number): boolean {
  const c0 = Math.floor((x - MOLE_HALF_W) / CELL);
  const c1 = Math.floor((x + MOLE_HALF_W - 1e-4) / CELL);
  const r0 = Math.floor((y - MOLE_HALF_H) / CELL);
  const r1 = Math.floor((y + MOLE_HALF_H - 1e-4) / CELL);
  for (let r = r0; r <= r1; r++) {
    for (let c = c0; c <= c1; c++) if (t.collisionAt(c, r) === COLLIDE_SOLID) return true;
  }
  return false;
}

/** A random spot inside solid underground earth, at least `minDistance` from every point in `away`. */
export function findMoleSpawn(
  t: Terrain, rng: Rng, away: readonly { x: number; y: number }[], minDistance: number,
): { x: number; y: number } | null {
  for (let attempt = 0; attempt < 80; attempt++) {
    const cx = rng.int(20, t.cols - 21);
    const top = t.surface[cx] + 10;
    if (top >= t.rows - 8) continue;
    const cy = rng.int(top, t.rows - 8);
    const x = (cx + 0.5) * CELL;
    const y = (cy + 0.5) * CELL;
    if (t.collisionAt(cx, cy) !== COLLIDE_SOLID || t.fgAt(cx, cy) === BEDROCK) continue;
    if (away.some((p) => Math.hypot(x - p.x, y - p.y) < minDistance)) continue;
    return { x, y };
  }
  return null;
}

/** Advance one mole that is burrowing or walking. */
export function stepMole(m: Mole, t: Terrain, rng: Rng, dt: number, target: MoleTarget): MoleEvent {
  return m.state === 'burrow' ? stepBurrow(m, t, rng, dt, target) : stepWalk(m, t, rng, dt, target);
}

/** Armed moles lunge at the stickman and fall if out in the open; buried ones stay put. */
export function stepArmed(m: Mole, t: Terrain, dt: number, target: MoleTarget): void {
  if (moleBlocked(t, m.x, m.y)) return;
  fall(m, t, dt);
  if (!m.grounded || !target.active || Math.abs(target.x - m.x) < 3) return;
  m.facing = target.x > m.x ? 1 : -1;
  walk(m, t, LUNGE_SPEED * dt);
  m.phase += LUNGE_SPEED * dt * 0.45;
}

function dive(m: Mole, rng: Rng, heading: number): void {
  m.state = 'burrow';
  m.dir = heading;
  m.timer = 0;
  m.minBurrow = rng.range(1.2, 2.6);
  m.grounded = false;
  m.vy = 0;
}

function stepBurrow(m: Mole, t: Terrain, rng: Rng, dt: number, target: MoleTarget): MoleEvent {
  const depth = t.depthAt(m.x, m.y);
  const dx = target.x - m.x;
  const dy = target.y - m.y;
  const hunting = target.active && Math.hypot(dx, dy) < SENSE_RANGE;

  let want: number | null = null;
  if (depth < MOLE_MIN_DEPTH + 4) want = Math.PI / 2;
  else if (m.y > (t.rows - 8) * CELL) want = -Math.PI / 2;
  else if (m.x < 16 * CELL) want = 0;
  else if (m.x > (t.cols - 16) * CELL) want = Math.PI;
  else if (hunting) want = Math.atan2(dy, dx);

  m.dir += rng.range(-1, 1) * 5 * dt;
  if (want !== null) m.dir = turnToward(m.dir, want, (hunting ? 2.4 : 3) * dt);

  const speed = hunting ? BURROW_HUNT_SPEED : BURROW_SPEED;
  const nx = m.x + Math.cos(m.dir) * speed * dt;
  const ny = m.y + Math.sin(m.dir) * speed * dt;
  const cx = Math.floor(nx / CELL);
  const cy = Math.floor(ny / CELL);
  if (t.depthAt(nx, ny) < MOLE_MIN_DEPTH + 1 || t.fgAt(cx, cy) === BEDROCK) {
    // Never break the surface or dig through bedrock: bounce off instead.
    m.dir += Math.PI * rng.range(0.6, 1.4);
    return null;
  }
  m.x = nx;
  m.y = ny;
  m.timer += dt;
  if (Math.cos(m.dir) !== 0) m.facing = Math.cos(m.dir) > 0 ? 1 : -1;

  const ready = m.timer >= (hunting ? Math.min(m.minBurrow, 0.35) : m.minBurrow);
  if (ready && t.collisionAt(cx, cy) === COLLIDE_NONE && !moleBlocked(t, m.x, m.y)) {
    m.state = 'walk';
    m.vy = 0;
    m.grounded = false;
    m.pop = 0;
    m.timer = rng.range(4, 9);
    return 'surface';
  }
  return null;
}

function stepWalk(m: Mole, t: Terrain, rng: Rng, dt: number, target: MoleTarget): MoleEvent {
  m.pop += dt;
  m.timer -= dt;
  fall(m, t, dt);
  if (m.y > t.height) return 'gone';

  const dx = target.x - m.x;
  const hunting = target.active && Math.abs(dx) < 170 && Math.abs(target.y - m.y) < 60;
  if (hunting && Math.abs(dx) > 2) m.facing = dx > 0 ? 1 : -1;

  if (t.depthAt(m.x, m.y) < MOLE_MIN_DEPTH - 1) {
    // Wandered up toward daylight: head back down.
    dive(m, rng, Math.PI / 2);
    return 'dive';
  }
  if (!m.grounded) return null;
  if (m.timer <= 0 && !hunting) {
    dive(m, rng, Math.PI / 2 - m.facing * rng.range(0.2, 0.7));
    return 'dive';
  }

  const speed = hunting ? WALK_HUNT_SPEED : WALK_SPEED;
  m.phase += speed * dt * 0.45;
  if (walk(m, t, speed * dt) || hunting) return null;
  // A wall: tunnel into it, or turn around.
  if (rng.chance(0.35)) {
    dive(m, rng, m.facing > 0 ? 0 : Math.PI);
    return 'dive';
  }
  m.facing = m.facing > 0 ? -1 : 1;
  return null;
}

/** Walk `distance` along `facing`, stepping up small ledges. False when a wall stops it. */
function walk(m: Mole, t: Terrain, distance: number): boolean {
  let remaining = distance;
  while (remaining > 1e-4) {
    const s = Math.min(1, remaining) * m.facing;
    remaining -= Math.abs(s);
    const nx = m.x + s;
    if (!moleBlocked(t, nx, m.y)) {
      m.x = nx;
      continue;
    }
    let stepped = false;
    for (let k = 1; k <= 2 && !stepped; k++) {
      const ny = m.y - k * CELL;
      if (!moleBlocked(t, nx, ny) && !moleBlocked(t, m.x, ny)) {
        m.x = nx;
        m.y = ny;
        stepped = true;
      }
    }
    if (!stepped) return false;
  }
  return true;
}

function fall(m: Mole, t: Terrain, dt: number): void {
  m.vy = Math.min(m.vy + GRAVITY * dt, 700);
  let remaining = m.vy * dt;
  m.grounded = false;
  while (Math.abs(remaining) > 1e-4) {
    const s = Math.max(-1, Math.min(1, remaining));
    remaining -= s;
    const ny = m.y + s;
    if (s > 0) {
      const oldRow = Math.floor((m.y + MOLE_HALF_H - 1e-4) / CELL);
      const newRow = Math.floor((ny + MOLE_HALF_H - 1e-4) / CELL);
      if (newRow > oldRow && standsOn(t, m.x, newRow)) {
        m.y = newRow * CELL - MOLE_HALF_H;
        m.vy = 0;
        m.grounded = true;
        return;
      }
    } else if (moleBlocked(t, m.x, ny)) {
      m.vy = 0;
      return;
    }
    m.y = ny;
  }
}

/** Solid ground or one-way tops under the mole's feet in `row`. */
function standsOn(t: Terrain, x: number, row: number): boolean {
  const c0 = Math.floor((x - MOLE_HALF_W + 1) / CELL);
  const c1 = Math.floor((x + MOLE_HALF_W - 1) / CELL);
  for (let c = c0; c <= c1; c++) if (t.collisionAt(c, row) !== COLLIDE_NONE) return true;
  return false;
}

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