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 { DUST_COLOR, FG_COLORS, SMOKE_COLOR, SPARK_COLORS } from './palette.ts';
import {
createMole,
findMoleSpawn,
MOLE_BLAST,
MOLE_FUSE,
MOLE_HALF_H,
MOLE_TRIGGER,
stepArmed,
stepMole,
type Mole,
type MoleTarget,
} from './moles.ts';
import { createRng, type Rng } from './rng.ts';
import {
CAP,
CELL,
COLLIDE_NONE,
COLLIDE_ONEWAY,
COLLIDE_SOLID,
generateWorld,
type Terrain,
} from './terrain.ts';
import { computePose, createPose, PELVIS, Ragdoll, type Pose, type PoseState } from './stickman.ts';
export const GRAVITY = 1400;
export const PLAYER_W = 10;
export const PLAYER_H = 30;
/** Hitbox height while crouched or crawling: fits a four-cell gap. */
export const CROUCH_H = 16;
const RUN_SPEED = 165;
const CRAWL_SPEED = 62;
const JUMP_SPEED = 470;
const BOUNCE_SPEED = 640;
const MAX_FALL = 1000;
const METEOR_GRAVITY = 220;
const METEOR_START_Y = -300;
/** Inside this fraction of the blast radius the stickman dies. */
export const KILL_REACH = 0.7;
/** Out to this fraction of the blast radius the stickman is shoved. */
export const KNOCK_REACH = 2.6;
const MAX_PARTICLES = 1100;
/** Seconds a newly joined player ignores lethal blasts while they find their feet. */
export const SPAWN_SHIELD = 1.5;
/** Seconds a dead player's camera stays on their body before following a teammate. */
export const SPECTATE_DELAY = 2;
export interface Controls {
left: boolean;
right: boolean;
jump: boolean;
down: boolean;
/** Held to crouch and crawl through low gaps. */
crouch: boolean;
/** True only on the step the jump input was first pressed. */
jumpPressed: boolean;
}
export const NO_CONTROLS: Controls = { left: false, right: false, jump: false, down: false, crouch: false, jumpPressed: false };
export interface Player {
x: number;
/** Feet line. */
y: number;
vx: number;
vy: number;
facing: 1 | -1;
grounded: boolean;
floor: number;
coyote: number;
jumpBuffer: number;
jumpHeld: boolean;
dropTimer: number;
stun: number;
spin: number;
spinVelocity: number;
phase: number;
crouch: number;
/** Crouched or crawling, with the short hitbox. Stays set while there is no headroom to stand. */
ducked: boolean;
/** Visual-only offset that smooths automatic step-ups. */
stepOffset: number;
state: PoseState;
}
export interface Meteor {
x: number;
y: number;
vx: number;
vy: number;
size: number;
blast: number;
/** Estimated seconds until it reaches the ground, from launch. */
eta: number;
}
export const PARTICLE_DEBRIS = 0;
export const PARTICLE_SPARK = 1;
export const PARTICLE_SMOKE = 2;
export const PARTICLE_DUST = 3;
export interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
max: number;
size: number;
color: number;
kind: number;
}
export interface Blast {
x: number;
y: number;
radius: number;
age: number;
/** 0 far away … 1 on top of the nearest player. */
proximity: number;
cause: DeathCause;
}
export type DeathCause = 'meteor' | 'mole';
/** One local player: their stickman while alive, their ragdoll once dead. */
export interface PlayerSlot {
/** Stable 0–3 identity: colour, label and controller routing. */
readonly slot: number;
readonly body: Player;
readonly pose: Pose;
ragdoll: Ragdoll | null;
alive: boolean;
deathCause: DeathCause | null;
/** World clock at death. */
diedAt: number;
/** Seconds of spawn protection left. */
shield: number;
/** Screen shake for this player's view. */
shake: number;
}
/** 0 far away … 1 on top of (x, y). */
export function blastProximity(blast: { x: number; y: number; radius: number }, x: number, y: number): number {
const distance = Math.hypot(x - blast.x, y - blast.y);
return Math.max(0, Math.min(1, 1 - distance / (blast.radius * 9)));
}
/** Feedback hooks for sound and rumble; the simulation never depends on them. */
export interface GameListeners {
explosion?(blast: Blast): void;
/** One player died; `runOver` follows when they were the last one standing. */
death?(slot: number, cause: DeathCause): void;
runOver?(): void;
/** `proximity` is 0–1 for this player, for per-controller rumble. */
blastNear?(slot: number, proximity: number, blast: Blast): void;
jump?(slot: number): void;
/** `impact` is the downward speed at touchdown. */
land?(impact: number, slot: number): void;
bounce?(slot: number): void;
footstep?(slot: number): void;
meteor?(meteor: Meteor): void;
moleSurface?(mole: Mole): void;
moleDive?(mole: Mole): void;
moleArmed?(mole: Mole): void;
}
/** Seconds between meteors after `t` seconds of survival. */
export function spawnInterval(t: number): number {
return Math.max(0.16, 1.9 * Math.pow(0.5, t / 45));
}
/** How many moles the world keeps topped up to after `t` seconds of survival. */
export function moleTarget(t: number): number {
return Math.min(22, 11 + Math.floor(t / 20));
}
/** Blast radius in world units for a meteor spawned at `t`; `roll` is 0–1. */
export function blastRadius(t: number, roll: number): number {
return 22 + Math.min(26, t * 0.18) + roll * 14;
}
/** Distance from a point to the player's hitbox (0 when inside). */
export function distanceToBox(px: number, py: number, x: number, feetY: number, height = PLAYER_H): number {
const left = x - PLAYER_W / 2;
const right = x + PLAYER_W / 2;
const top = feetY - height;
const dx = Math.max(left - px, 0, px - right);
const dy = Math.max(top - py, 0, py - feetY);
return Math.hypot(dx, dy);
}
export class Game {
readonly seed: number;
readonly terrain: Terrain;
readonly players: PlayerSlot[] = [];
readonly meteors: Meteor[] = [];
readonly particles: Particle[] = [];
readonly blasts: Blast[] = [];
readonly moles: Mole[] = [];
/** How the most recent death happened. */
deathCause: DeathCause | null = null;
/** Meteors only fall once a run has started. */
meteorsEnabled = false;
/** Seconds survived in this run, by whoever is still standing. */
survived = 0;
/** World clock, including time after death. */
clock = 0;
listeners: GameListeners = {};
private readonly rng: Rng;
/** Moles draw from their own stream so they never change the meteor sequence. */
private readonly moleRng: Rng;
private spawnTimer = 2;
private moleTimer = 5;
private readonly moleTarget: MoleTarget = { x: 0, y: 0, active: false };
private readonly spawnX: number;
private readonly spawnY: number;
constructor(seed: number, slots: readonly number[] = [0]) {
this.seed = seed;
this.rng = createRng(seed ^ 0x5bd1e995);
this.moleRng = createRng(seed ^ 0x27d4eb2f);
const world = generateWorld(seed);
this.terrain = world.terrain;
this.spawnX = world.spawnX;
this.spawnY = world.spawnY;
for (let i = moleTarget(0); i > 0; i--) this.spawnMole([{ x: world.spawnX, y: world.spawnY }]);
for (const slot of slots) this.addPlayer(slot);
}
/** True while anyone is still standing. */
get alive(): boolean {
return this.players.some((p) => p.alive);
}
player(slot: number): PlayerSlot | undefined {
return this.players.find((p) => p.slot === slot);
}
/**
* Join a player. The first spawns at the world spawn; later ones drop in at a
* living teammate's feet, which is always clear ground, with a short spawn shield.
*/
addPlayer(slot: number): PlayerSlot {
const existing = this.player(slot);
if (existing) return existing;
const anchor = this.players.find((p) => p.alive)?.body;
const joining = this.players.length > 0;
const x = anchor?.x ?? this.spawnX;
const y = anchor?.y ?? this.spawnY;
const body: Player = {
x: x + (anchor?.grounded ? this.standingRoom(x, y, slot) : 0),
y,
vx: 0,
vy: 0,
facing: slot % 2 === 0 ? 1 : -1,
grounded: anchor?.grounded ?? true,
floor: anchor?.floor ?? COLLIDE_SOLID,
coyote: 0,
jumpBuffer: 0,
jumpHeld: false,
dropTimer: 0,
stun: 0,
spin: 0,
spinVelocity: 0,
phase: 0,
crouch: 0,
ducked: false,
stepOffset: 0,
state: 'idle',
};
const player: PlayerSlot = {
slot,
body,
pose: createPose(),
ragdoll: null,
alive: true,
deathCause: null,
diedAt: 0,
shield: joining ? SPAWN_SHIELD : 0,
shake: 0,
};
this.players.push(player);
this.players.sort((a, b) => a.slot - b.slot);
this.updatePose(player);
if (joining) this.puff(body.x, body.y, 8, 70);
return player;
}
/** A small sideways offset onto clear, solid ground, so joiners don't stand exactly on top of each other. */
private standingRoom(x: number, y: number, slot: number): number {
const side = slot % 2 === 0 ? 1 : -1;
for (const offset of [14 * side, -14 * side, 7 * side, -7 * side]) {
if (!this.bodyBlocked(x + offset, y) && this.bodyBlocked(x + offset, y + CELL)) return offset;
}
return 0;
}
/** Remove an unplugged player. If they were the last one standing, the run is over. */
removePlayer(slot: number): void {
const index = this.players.findIndex((p) => p.slot === slot);
if (index < 0) return;
const [gone] = this.players.splice(index, 1);
if (gone.alive && this.players.length > 0 && !this.alive) this.listeners.runOver?.();
}
/** Who a slot's camera follows: itself, its body for a moment after death, then the nearest living teammate. */
viewTarget(slot: number): PlayerSlot | undefined {
const own = this.player(slot);
if (!own) return this.players[0];
if (own.alive || this.clock - own.diedAt < SPECTATE_DELAY) return own;
const x = bodyX(own);
const y = bodyY(own);
let best: PlayerSlot | undefined;
let bestDistance = Infinity;
for (const p of this.players) {
if (!p.alive) continue;
const d = Math.hypot(bodyX(p) - x, bodyY(p) - y);
if (d < bestDistance) {
best = p;
bestDistance = d;
}
}
return best ?? own;
}
/** World x a slot's camera should follow; the first player's by default. */
focusX(slot = this.players[0]?.slot ?? 0): number {
const p = this.viewTarget(slot);
return p ? bodyX(p) : this.spawnX;
}
focusY(slot = this.players[0]?.slot ?? 0): number {
const p = this.viewTarget(slot);
return p ? bodyY(p) : this.spawnY - PLAYER_H / 2;
}
/** Storm intensity clock: freezes when the last player dies. */
stormTime(): number {
return this.survived;
}
/** `controls` is one set for every player, or a lookup by slot. */
update(dt: number, controls: Controls | ((slot: number) => Controls)): void {
this.clock += dt;
if (this.alive && this.meteorsEnabled) this.survived += dt;
for (const p of this.players) {
if (p.alive) this.updatePlayer(p, dt, typeof controls === 'function' ? controls(p.slot) : controls);
else p.ragdoll?.step(dt, GRAVITY, this.collidePoint);
p.shield = Math.max(0, p.shield - dt);
p.shake *= Math.exp(-dt * 7);
}
this.updateMoles(dt);
this.updateMeteors(dt);
this.updateParticles(dt);
for (let i = this.blasts.length - 1; i >= 0; i--) {
this.blasts[i].age += dt;
if (this.blasts[i].age > 0.6) this.blasts.splice(i, 1);
}
}
// Player ────────────────────────────────────────────────────────────────
private updatePlayer(player: PlayerSlot, dt: number, c: Controls): void {
const p = player.body;
this.unstick(p);
const wish = (c.right ? 1 : 0) - (c.left ? 1 : 0);
const stunned = p.stun > 0;
p.stun = Math.max(0, p.stun - dt);
// Duck from the ground (or keep ducking mid-air); stand only where there is headroom.
if (c.crouch && (p.grounded || p.ducked)) p.ducked = true;
else if (p.ducked && !this.bodyBlocked(p.x, p.y, PLAYER_H)) p.ducked = false;
const maxSpeed = p.ducked ? CRAWL_SPEED : RUN_SPEED;
if (wish !== 0) {
const control = stunned ? 0.3 : 1;
if (Math.sign(p.vx) === wish && Math.abs(p.vx) > maxSpeed) {
// Don't cancel a knockback just because the player runs the same way.
p.vx = approach(p.vx, wish * maxSpeed, (p.grounded ? 900 : 120) * dt);
} else {
p.vx = approach(p.vx, wish * maxSpeed, (p.grounded ? 1800 : 1100) * control * dt);
}
if (!stunned) p.facing = wish > 0 ? 1 : -1;
} else {
const friction = p.grounded ? (stunned ? 500 : 1500) : 120;
p.vx = approach(p.vx, 0, friction * dt);
}
p.jumpBuffer = c.jumpPressed ? 0.13 : Math.max(0, p.jumpBuffer - dt);
p.coyote = p.grounded ? 0.1 : Math.max(0, p.coyote - dt);
p.dropTimer = Math.max(0, p.dropTimer - dt);
if (c.down && p.grounded && p.floor === COLLIDE_ONEWAY) {
p.dropTimer = 0.22;
p.grounded = false;
p.coyote = 0;
} else if (p.jumpBuffer > 0 && p.coyote > 0 && !p.ducked) {
p.vy = -JUMP_SPEED;
p.jumpHeld = true;
p.jumpBuffer = 0;
p.coyote = 0;
p.grounded = false;
this.puff(p.x, p.y, 5, 60);
this.listeners.jump?.(player.slot);
}
if (p.jumpHeld && !c.jump && p.vy < 0) {
p.vy *= 0.5;
p.jumpHeld = false;
}
if (p.vy >= 0) p.jumpHeld = false;
p.vy = Math.min(p.vy + GRAVITY * dt, MAX_FALL);
const wasGrounded = p.grounded;
const impact = p.vy;
this.moveX(p, p.vx * dt);
p.grounded = false;
const landedOn = this.moveY(p, p.vy * dt);
if (landedOn !== -1) {
if (landedOn === CAP && !c.down && impact > 60) {
p.vy = -Math.max(BOUNCE_SPEED, impact * 0.9);
p.grounded = false;
p.jumpHeld = false;
p.crouch = 0.6;
this.puff(p.x, p.y, 6, 90);
this.listeners.bounce?.(player.slot);
} else {
p.vy = 0;
p.grounded = true;
if (!wasGrounded) {
p.crouch = Math.min(1, impact / 750);
if (impact > 320) this.puff(p.x, p.y, Math.round(impact / 80), impact * 0.12);
if (impact > 120) this.listeners.land?.(impact, player.slot);
}
}
}
if (p.grounded) {
p.spin = wrapAngle(p.spin) * Math.exp(-dt * 20);
p.spinVelocity = 0;
const stride = Math.floor(p.phase / Math.PI);
p.phase += (Math.abs(p.vx) * dt) / (p.ducked ? 5 : 8.5);
if (Math.floor(p.phase / Math.PI) !== stride && Math.abs(p.vx) > 40) this.listeners.footstep?.(player.slot);
} else if (p.spinVelocity !== 0) {
p.spin += p.spinVelocity * dt;
p.spinVelocity *= Math.exp(-dt * 1.5);
} else {
p.spin = wrapAngle(p.spin) * Math.exp(-dt * 10);
}
p.crouch = Math.max(0, p.crouch - dt * 4);
p.stepOffset = approach(p.stepOffset, 0, 110 * dt);
if (p.stun > 0 && !p.grounded && Math.abs(p.spinVelocity) > 1) p.state = 'tumble';
else if (p.ducked) p.state = 'crawl';
else if (p.grounded) p.state = Math.abs(p.vx) > 18 ? 'run' : 'idle';
else p.state = p.vy < 0 ? 'jump' : 'fall';
this.updatePose(player);
}
private updatePose(player: PlayerSlot): void {
const p = player.body;
computePose(
{
x: p.x,
y: p.y + p.stepOffset,
facing: p.facing,
state: p.state,
phase: p.phase,
time: this.clock,
speed: Math.min(1, Math.abs(p.vx) / (p.ducked ? CRAWL_SPEED : RUN_SPEED)),
crouch: p.crouch,
spin: p.spin,
},
player.pose,
);
}
/** Solid cells (not one-way) overlapping a hitbox of `height` at (x, feetY). */
bodyBlocked(x: number, feetY: number, height = PLAYER_H): boolean {
const t = this.terrain;
const c0 = Math.floor((x - PLAYER_W / 2) / CELL);
const c1 = Math.floor((x + PLAYER_W / 2 - 1e-4) / CELL);
const r0 = Math.floor((feetY - height) / CELL);
const r1 = Math.floor((feetY - 1e-4) / CELL);
for (let r = r0; r <= r1; r++) {
for (let cx = c0; cx <= c1; cx++) if (t.collisionAt(cx, r) === COLLIDE_SOLID) return true;
}
return false;
}
private unstick(p: Player): void {
const h = heightOf(p);
if (!this.bodyBlocked(p.x, p.y, h)) return;
for (let k = 1; k <= 6; k++) {
if (!this.bodyBlocked(p.x, p.y - k * CELL, h)) {
p.y -= k * CELL;
return;
}
}
}
private moveX(p: Player, dx: number): void {
const h = heightOf(p);
let remaining = dx;
while (Math.abs(remaining) > 1e-4) {
const s = Math.max(-1, Math.min(1, remaining));
remaining -= s;
const nx = p.x + s;
if (!this.bodyBlocked(nx, p.y, h)) {
p.x = nx;
continue;
}
let stepped = false;
if (p.grounded || p.coyote > 0) {
for (let k = 1; k <= 2; k++) {
const ny = p.y - k * CELL;
if (!this.bodyBlocked(nx, ny, h) && !this.bodyBlocked(p.x, ny, h)) {
p.x = nx;
p.y = ny;
p.stepOffset += k * CELL;
stepped = true;
break;
}
}
}
if (!stepped) {
p.vx = 0;
return;
}
}
}
/** Returns the landed-on material, or -1 if nothing was landed on. */
private moveY(p: Player, dy: number): number {
const t = this.terrain;
let remaining = dy;
while (Math.abs(remaining) > 1e-4) {
const s = Math.max(-1, Math.min(1, remaining));
remaining -= s;
const ny = p.y + s;
if (s > 0) {
const oldRow = Math.floor((p.y - 1e-4) / CELL);
const newRow = Math.floor((ny - 1e-4) / CELL);
if (newRow > oldRow) {
const c0 = Math.floor((p.x - PLAYER_W / 2) / CELL);
const c1 = Math.floor((p.x + PLAYER_W / 2 - 1e-4) / CELL);
let floor = COLLIDE_NONE;
let material = -1;
for (let cx = c0; cx <= c1; cx++) {
const kind = t.collisionAt(cx, newRow);
if (kind === COLLIDE_SOLID || (kind === COLLIDE_ONEWAY && p.dropTimer <= 0)) {
if (floor !== COLLIDE_SOLID) floor = kind;
if (material === -1 || t.fgAt(cx, newRow) === CAP) material = t.fgAt(cx, newRow);
}
}
if (floor !== COLLIDE_NONE) {
p.y = newRow * CELL;
p.floor = floor;
return material;
}
}
} else {
const oldTop = Math.floor((p.y - heightOf(p)) / CELL);
const newTop = Math.floor((ny - heightOf(p)) / CELL);
if (newTop < oldTop) {
const c0 = Math.floor((p.x - PLAYER_W / 2) / CELL);
const c1 = Math.floor((p.x + PLAYER_W / 2 - 1e-4) / CELL);
for (let cx = c0; cx <= c1; cx++) {
if (t.collisionAt(cx, newTop) === COLLIDE_SOLID) {
p.vy = 0;
return -1;
}
}
}
}
p.y = ny;
}
return -1;
}
/** Ragdoll joints collide with solids, and with one-way tops from above. */
readonly collidePoint = (_ox: number, oy: number, x: number, y: number): boolean => {
const kind = this.terrain.collisionAtPoint(x, y);
if (kind === COLLIDE_SOLID) return true;
return kind === COLLIDE_ONEWAY && y > oy && Math.floor(oy / CELL) < Math.floor(y / CELL);
};
// Meteors and explosions ─────────────────────────────────────────────────
private updateMeteors(dt: number): void {
if (this.meteorsEnabled) {
this.spawnTimer -= dt;
while (this.spawnTimer <= 0) {
const t = this.stormTime();
this.spawnMeteor();
if (this.rng.chance(Math.min(0.35, t / 300))) this.spawnMeteor();
this.spawnTimer += spawnInterval(t) * this.rng.range(0.6, 1.3);
}
}
const t = this.terrain;
for (let i = this.meteors.length - 1; i >= 0; i--) {
const m = this.meteors[i];
m.vy += METEOR_GRAVITY * dt;
const steps = Math.max(1, Math.ceil((Math.max(Math.abs(m.vx), Math.abs(m.vy)) * dt) / 2));
let exploded = false;
for (let s = 0; s < steps && !exploded; s++) {
m.x += (m.vx * dt) / steps;
m.y += (m.vy * dt) / steps;
if (m.x < 0 || m.x >= t.width || m.y >= t.height) break;
const hitsTerrain = m.y > 0 && t.collisionAtPoint(m.x, m.y) !== COLLIDE_NONE;
if (hitsTerrain || this.players.some((p) => meteorHits(p, m))) {
this.explode(m.x, m.y, m.blast);
exploded = true;
}
}
if (exploded || m.x < -60 || m.x > t.width + 60 || m.y > t.height) {
this.meteors.splice(i, 1);
continue;
}
if (this.particles.length < MAX_PARTICLES) {
this.addParticle(m.x, m.y, -m.vx * 0.1 + this.rng.range(-20, 20), -m.vy * 0.1, 0.35, m.size * 0.9,
SPARK_COLORS[this.rng.int(0, SPARK_COLORS.length - 1)], PARTICLE_SPARK);
if (this.rng.chance(0.5)) {
this.addParticle(m.x, m.y, this.rng.range(-15, 15), -10, 0.9, m.size * 0.8, SMOKE_COLOR, PARTICLE_SMOKE);
}
}
}
}
spawnMeteor(): void {
const t = this.stormTime();
const rng = this.rng;
const width = this.terrain.width;
// Early meteors mostly land elsewhere; the storm homes in as it grows.
const near = rng.chance(Math.min(0.7, 0.25 + t / 60));
const target = near
? this.meteorAim() + (rng.next() - 0.5) * 2 * rng.range(0, 260)
: rng.range(30, width - 30);
const x = Math.max(30, Math.min(width - 30, target));
const vx = rng.range(-110, 110);
const vy = 180 + Math.min(260, t * 2.4) + rng.range(0, 80);
const col = Math.max(0, Math.min(this.terrain.cols - 1, Math.floor(x / CELL)));
const drop = this.terrain.surfaceRow(col) * CELL - METEOR_START_Y;
const fallTime = drop / vy;
const meteor: Meteor = {
x: x - vx * fallTime * 0.85,
y: METEOR_START_Y,
vx,
vy,
size: 3 + rng.range(0, 2.5) + Math.min(3, t / 50),
blast: blastRadius(t, rng.next()),
eta: (-vy + Math.sqrt(vy * vy + 2 * METEOR_GRAVITY * drop)) / METEOR_GRAVITY,
};
this.meteors.push(meteor);
this.listeners.meteor?.(meteor);
}
/** Near meteors home in on one living player, chosen at random when there are several. */
private meteorAim(): number {
const living = this.players.filter((p) => p.alive);
if (living.length === 0) return this.focusX();
const chosen = living.length === 1 ? living[0] : living[this.rng.int(0, living.length - 1)];
return chosen.body.x;
}
explode(x: number, y: number, radius: number, cause: DeathCause = 'meteor'): void {
const rng = this.rng;
const result = this.terrain.carve(x, y, radius);
const debris = Math.min(80, 8 + Math.floor(result.removed / 3));
for (let i = 0; i < debris; i++) {
const material = result.sample.length ? result.sample[rng.int(0, result.sample.length - 1)] : 0;
const a = rng.range(0, Math.PI * 2);
const speed = rng.range(120, 460);
this.addParticle(x + Math.cos(a) * radius * 0.4, y + Math.sin(a) * radius * 0.4,
Math.cos(a) * speed, Math.sin(a) * speed - 160, rng.range(0.8, 1.8), rng.range(1.5, 3.5),
FG_COLORS[material] ?? DUST_COLOR, PARTICLE_DEBRIS);
}
for (let i = 0; i < 30; i++) {
const a = rng.range(0, Math.PI * 2);
const speed = rng.range(60, 380);
this.addParticle(x, y, Math.cos(a) * speed, Math.sin(a) * speed - 80, rng.range(0.25, 0.7),
rng.range(1.5, 3.5), SPARK_COLORS[rng.int(0, SPARK_COLORS.length - 1)], PARTICLE_SPARK);
}
for (let i = 0; i < 12; i++) {
this.addParticle(x + rng.range(-radius, radius) * 0.5, y + rng.range(-radius, radius) * 0.4,
rng.range(-40, 40), rng.range(-70, -10), rng.range(1.2, 2.4), radius * rng.range(0.3, 0.6),
SMOKE_COLOR, PARTICLE_SMOKE);
}
const blast: Blast = { x, y, radius, age: 0, proximity: 0, cause };
for (const p of this.players) {
const near = blastProximity(blast, bodyX(p), bodyY(p));
blast.proximity = Math.max(blast.proximity, near);
p.shake = Math.min(24, p.shake + (2 + radius * 0.3) * (0.2 + 0.8 * near));
}
this.blasts.push(blast);
const wasAlive = this.alive;
for (const p of this.players) {
if (p.alive) {
const d = distanceToBox(x, y, p.body.x, p.body.y, heightOf(p.body));
if (d < radius * KILL_REACH && p.shield <= 0) this.kill(p, x, y, radius, cause);
else if (d < radius * KNOCK_REACH) this.knockBack(p.body, x, y, radius, Math.max(d, radius * KILL_REACH));
} else if (p.ragdoll) {
this.throwBody(p.ragdoll, x, y, radius * KNOCK_REACH, 650);
}
}
for (const p of this.players) {
const near = blastProximity(blast, bodyX(p), bodyY(p));
if (near > 0) this.listeners.blastNear?.(p.slot, near, blast);
}
if (wasAlive && !this.alive) this.listeners.runOver?.();
// Blasts set off any mole nearby, buried or not: chain reactions.
for (const m of this.moles) {
if (m.state !== 'armed' && Math.hypot(m.x - x, m.y - y) < radius * 1.5) {
this.armMole(m, this.moleRng.range(0.06, 0.24));
}
}
this.listeners.explosion?.(blast);
}
private knockBack(p: Player, x: number, y: number, radius: number, d: number): void {
const f = 1 - (d - radius * KILL_REACH) / (radius * (KNOCK_REACH - KILL_REACH));
let dx = p.x - x;
let dy = p.y - heightOf(p) / 2 - y - radius * 0.35;
const len = Math.hypot(dx, dy) || 1;
dx /= len;
dy /= len;
const magnitude = 240 + 560 * f;
p.vx += dx * magnitude;
p.vy = Math.min(p.vy, 0) + dy * magnitude - 150 * f;
p.grounded = false;
p.coyote = 0;
p.stun = 0.2 + 0.6 * f;
p.spinVelocity = f > 0.3 ? (dx >= 0 ? 1 : -1) * (5 + 11 * f) : 0;
}
private kill(player: PlayerSlot, x: number, y: number, radius: number, cause: DeathCause): void {
const p = player.body;
player.alive = false;
player.deathCause = cause;
player.diedAt = this.clock;
this.deathCause = cause;
player.ragdoll = new Ragdoll(player.pose, p.vx, p.vy);
this.throwBody(player.ragdoll, x, y, radius * 3.2, 1050);
this.listeners.death?.(player.slot, cause);
}
/** Fling a ragdoll away from a blast, with a tumble. */
private throwBody(body: Ragdoll, x: number, y: number, reach: number, power: number): void {
let dx = body.centerX() - x;
let dy = body.centerY() - y;
const d = Math.hypot(dx, dy);
if (d >= reach) return;
if (d < 3) {
dx = this.rng.sign() * 0.4;
dy = -1;
}
// Blasts always lift: bodies go up and away rather than into the ground.
const n = Math.hypot(dx, dy);
let ux = dx / n;
let uy = Math.min(dy / n, -0.45);
const un = Math.hypot(ux, uy);
ux /= un;
uy /= un;
const f = 1 - d / reach;
const speed = power * (0.45 + 0.55 * f);
const spin = (ux >= 0 ? 1 : -1) * this.rng.range(8, 16) * (0.4 + 0.6 * f);
body.launch(ux * speed, uy * speed, spin);
body.impulse(x, y, reach, power * 0.25);
}
// Moles ──────────────────────────────────────────────────────────────────
private spawnMole(away: readonly { x: number; y: number }[]): void {
const spot = findMoleSpawn(this.terrain, this.moleRng, away, 300);
if (spot) this.moles.push(createMole(spot.x, spot.y, this.moleRng));
}
private armMole(m: Mole, fuse: number): void {
m.state = 'armed';
m.fuse = fuse;
m.fuseLength = fuse;
this.listeners.moleArmed?.(m);
}
private updateMoles(dt: number): void {
const target = this.moleTarget;
const spots = this.players.map((p) => ({ x: bodyX(p), y: bodyY(p) }));
this.moleTimer -= dt;
if (this.moleTimer <= 0) {
this.moleTimer = 4;
if (this.moles.length < moleTarget(this.survived)) this.spawnMole(spots);
}
const rng = this.moleRng;
for (let i = this.moles.length - 1; i >= 0; i--) {
const m = this.moles[i];
// Each mole hunts whichever living player is closest to it.
target.active = false;
let nearest = Infinity;
for (const p of this.players) {
if (!p.alive) continue;
const d = Math.hypot(p.body.x - m.x, bodyY(p) - m.y);
if (d < nearest) {
nearest = d;
target.x = p.body.x;
target.y = bodyY(p);
target.active = true;
}
}
if (m.state === 'armed') {
stepArmed(m, this.terrain, dt, target);
m.fuse -= dt;
if (rng.chance(dt * 40)) {
// The lit tail fizzes.
this.addParticle(m.x - m.facing * 7, m.y - 3, rng.range(-40, 40), rng.range(-90, -20), rng.range(0.15, 0.35),
rng.range(1, 2), SPARK_COLORS[rng.int(0, SPARK_COLORS.length - 1)], PARTICLE_SPARK);
}
if (m.fuse <= 0) {
this.moles.splice(i, 1);
this.explode(m.x, m.y, MOLE_BLAST, 'mole');
}
continue;
}
const event = stepMole(m, this.terrain, rng, dt, target);
if (event === 'gone') {
this.moles.splice(i, 1);
continue;
}
const near = spots.some((spot) => Math.hypot(m.x - spot.x, m.y - spot.y) < 360);
if (event === 'surface') this.listeners.moleSurface?.(m);
else if (event === 'dive') this.listeners.moleDive?.(m);
if (event === 'surface' || event === 'dive') {
if (near) this.dirtBurst(m.x, m.y + (event === 'dive' ? MOLE_HALF_H : 0), event === 'surface' ? 14 : 7);
} else if (m.state === 'burrow' && near) {
// Loose grains trickle where something moves inside the earth.
m.dust += dt * 6;
while (m.dust >= 1) {
m.dust -= 1;
const material = this.terrain.fgAt(Math.floor(m.x / CELL), Math.floor(m.y / CELL));
this.addParticle(m.x + rng.range(-4, 4), m.y + rng.range(-3, 3), rng.range(-12, 12), rng.range(-14, 4),
rng.range(0.25, 0.5), rng.range(1, 1.8), FG_COLORS[material] ?? DUST_COLOR, PARTICLE_DUST);
}
}
if (m.state !== 'walk') continue;
for (const p of this.players) {
if (p.alive && distanceToBox(m.x, m.y, p.body.x, p.body.y, heightOf(p.body)) < MOLE_TRIGGER) {
m.facing = p.body.x >= m.x ? 1 : -1;
this.armMole(m, MOLE_FUSE);
break;
}
}
}
}
/** Clods thrown out where a mole breaks through a cave wall. */
private dirtBurst(x: number, y: number, count: number): void {
const rng = this.moleRng;
for (let i = 0; i < count; i++) {
const cx = Math.floor((x + rng.range(-8, 8)) / CELL);
const cy = Math.floor((y + rng.range(-8, 8)) / CELL);
const material = this.terrain.fgAt(cx, cy);
const a = rng.range(-Math.PI, 0);
const speed = rng.range(40, 150);
this.addParticle(x, y, Math.cos(a) * speed, Math.sin(a) * speed, rng.range(0.4, 0.9), rng.range(1.2, 2.6),
FG_COLORS[material] ?? DUST_COLOR, PARTICLE_DEBRIS);
}
}
// Particles ──────────────────────────────────────────────────────────────
private addParticle(
x: number, y: number, vx: number, vy: number, life: number, size: number, color: number, kind: number,
): void {
if (this.particles.length >= MAX_PARTICLES) this.particles.shift();
this.particles.push({ x, y, vx, vy, life, max: life, size, color, kind });
}
private puff(x: number, y: number, count: number, speed: number): void {
for (let i = 0; i < count; i++) {
const dir = i % 2 === 0 ? 1 : -1;
this.addParticle(x + dir * 3, y - 1, dir * this.rng.range(0.4, 1) * speed, -this.rng.range(10, 40),
this.rng.range(0.3, 0.6), this.rng.range(1.5, 3), DUST_COLOR, PARTICLE_DUST);
}
}
private updateParticles(dt: number): void {
const t = this.terrain;
const list = this.particles;
for (let i = list.length - 1; i >= 0; i--) {
const q = list[i];
q.life -= dt;
if (q.life <= 0) {
list[i] = list[list.length - 1];
list.pop();
continue;
}
switch (q.kind) {
case PARTICLE_DEBRIS:
q.vy += 900 * dt;
if (t.collisionAtPoint(q.x + q.vx * dt, q.y + q.vy * dt) === COLLIDE_SOLID) {
q.vx *= 0.3;
q.vy = 0;
q.life = Math.min(q.life, 0.5);
}
break;
case PARTICLE_SPARK:
q.vy += 320 * dt;
q.vx *= Math.exp(-dt * 2);
break;
case PARTICLE_SMOKE:
q.vy -= 18 * dt;
q.vx *= Math.exp(-dt * 1.5);
q.vy *= Math.exp(-dt * 1.5);
q.size += dt * 6;
break;
default:
q.vx *= Math.exp(-dt * 5);
q.vy += 60 * dt;
}
q.x += q.vx * dt;
q.y += q.vy * dt;
}
}
}
/** Centre of a player: their hitbox while alive, their pelvis once they are a ragdoll. */
export function bodyX(p: PlayerSlot): number {
return p.ragdoll ? p.ragdoll.pos[PELVIS * 2] : p.body.x;
}
export function bodyY(p: PlayerSlot): number {
return p.ragdoll ? p.ragdoll.pos[PELVIS * 2 + 1] : p.body.y - heightOf(p.body) / 2;
}
/** Live hitbox height: shorter while crouched or crawling. */
export function heightOf(p: Player): number {
return p.ducked ? CROUCH_H : PLAYER_H;
}
function meteorHits(p: PlayerSlot, m: Meteor): boolean {
if (p.alive) return distanceToBox(m.x, m.y, p.body.x, p.body.y, heightOf(p.body)) < m.size;
return p.ragdoll !== null && Math.hypot(m.x - bodyX(p), m.y - bodyY(p)) < m.size + 6;
}
function approach(value: number, target: number, delta: number): number {
return value < target ? Math.min(target, value + delta) : Math.max(target, value - delta);
}
function wrapAngle(a: number): number {
return Math.atan2(Math.sin(a), Math.cos(a));
}
