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.
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));
}
