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.
/** Joint indices; positions are stored as interleaved x/y pairs. */
export const HEAD = 0;
export const NECK = 1;
export const PELVIS = 2;
export const L_ELBOW = 3;
export const L_HAND = 4;
export const R_ELBOW = 5;
export const R_HAND = 6;
export const L_KNEE = 7;
export const L_FOOT = 8;
export const R_KNEE = 9;
export const R_FOOT = 10;
export const JOINTS = 11;
export const BONES: ReadonlyArray<readonly [number, number]> = [
[HEAD, NECK],
[NECK, PELVIS],
[NECK, L_ELBOW],
[L_ELBOW, L_HAND],
[NECK, R_ELBOW],
[R_ELBOW, R_HAND],
[PELVIS, L_KNEE],
[L_KNEE, L_FOOT],
[PELVIS, R_KNEE],
[R_KNEE, R_FOOT],
];
export const DIM = {
thigh: 7.5,
shin: 7.5,
torso: 10,
neck: 5,
upperArm: 6.5,
forearm: 6,
headRadius: 4,
};
export type PoseState = 'idle' | 'run' | 'jump' | 'fall' | 'tumble' | 'crawl';
export interface PoseInput {
x: number;
/** Feet line (bottom of the hitbox). */
y: number;
facing: 1 | -1;
state: PoseState;
/** Run cycle phase in radians. */
phase: number;
/** Seconds, for idle breathing and flailing. */
time: number;
/** 0–1 run intensity. */
speed: number;
/** 0–1 landing squash. */
crouch: number;
/** Whole-body rotation while tumbling. */
spin: number;
}
export type Pose = Float32Array;
export function createPose(): Pose {
return new Float32Array(JOINTS * 2);
}
interface LimbAngles {
/** Upper-segment angle from straight down; positive swings toward facing. */
a: number;
/** Bend at the middle joint. */
bend: number;
}
/**
* Procedural stickman animation. Angles are measured from straight down, with
* positive values swinging toward the facing direction.
*/
export function computePose(input: PoseInput, out: Pose = createPose()): Pose {
const { facing, time, crouch } = input;
let lean = 0;
let legL: LimbAngles;
let legR: LimbAngles;
let armL: LimbAngles;
let armR: LimbAngles;
switch (input.state) {
case 'run': {
const p = input.phase;
const amp = 0.35 + input.speed * 0.55;
legL = { a: Math.sin(p) * amp, bend: 0.25 + Math.max(0, Math.cos(p)) * 1.4 * input.speed };
legR = { a: -Math.sin(p) * amp, bend: 0.25 + Math.max(0, -Math.cos(p)) * 1.4 * input.speed };
armL = { a: -Math.sin(p) * amp * 1.1, bend: 1.0 };
armR = { a: Math.sin(p) * amp * 1.1, bend: 1.0 };
lean = 0.08 + input.speed * 0.18;
break;
}
case 'jump':
legL = { a: 0.75, bend: 1.5 };
legR = { a: 0.15, bend: 1.0 };
armL = { a: 2.5, bend: 0.3 };
armR = { a: -2.3, bend: 0.4 };
lean = 0.1;
break;
case 'fall':
legL = { a: 0.35, bend: 0.5 };
legR = { a: -0.3, bend: 0.25 };
armL = { a: 2.1 + Math.sin(time * 13) * 0.45, bend: 0.3 };
armR = { a: -2.0 + Math.cos(time * 12) * 0.45, bend: 0.3 };
lean = -0.05;
break;
case 'tumble':
legL = { a: 0.9 + Math.sin(time * 17) * 0.6, bend: 0.9 };
legR = { a: -0.6 + Math.cos(time * 15) * 0.6, bend: 0.6 };
armL = { a: 2.4 + Math.sin(time * 19) * 0.7, bend: 0.5 };
armR = { a: -2.2 + Math.cos(time * 21) * 0.7, bend: 0.5 };
lean = 0.2;
break;
case 'crawl': {
// Hands and knees, low enough for the crouched hitbox; opposite hand and knee move together.
const swing = Math.sin(input.phase) * input.speed;
legL = { a: 0.25 + swing * 0.4, bend: 1.75 + swing * 0.4 };
legR = { a: 0.25 - swing * 0.4, bend: 1.75 - swing * 0.4 };
armL = { a: 1.15 - swing * 0.35, bend: -1.0 };
armR = { a: 1.15 + swing * 0.35, bend: -1.0 };
lean = 1.45;
break;
}
default: {
const breathe = Math.sin(time * 2.2);
legL = { a: 0.13, bend: 0.06 };
legR = { a: -0.11, bend: 0.06 };
armL = { a: 0.2 + breathe * 0.04, bend: 0.25 };
armR = { a: -0.14 - breathe * 0.04, bend: 0.2 };
lean = 0.03 + breathe * 0.015;
}
}
if (crouch > 0 && input.state !== 'crawl') {
legL = { a: legL.a + crouch * 0.5, bend: legL.bend + crouch * 1.0 };
legR = { a: legR.a + crouch * 0.4, bend: legR.bend + crouch * 0.9 };
lean += crouch * 0.25;
}
// Build in local space with the pelvis at the origin.
const set = (j: number, x: number, y: number): void => {
out[j * 2] = x;
out[j * 2 + 1] = y;
};
set(PELVIS, 0, 0);
const nx = Math.sin(lean) * facing * DIM.torso;
const ny = -Math.cos(lean) * DIM.torso;
set(NECK, nx, ny);
if (input.state === 'crawl') set(HEAD, nx + facing * 4.7, ny - 1.4);
else set(HEAD, nx + Math.sin(lean) * facing * DIM.neck, ny - Math.cos(lean) * DIM.neck);
limb(out, PELVIS, L_KNEE, L_FOOT, legL.a, -legL.bend, DIM.thigh, DIM.shin, facing);
limb(out, PELVIS, R_KNEE, R_FOOT, legR.a, -legR.bend, DIM.thigh, DIM.shin, facing);
limb(out, NECK, L_ELBOW, L_HAND, armL.a, armL.bend, DIM.upperArm, DIM.forearm, facing);
limb(out, NECK, R_ELBOW, R_HAND, armR.a, armR.bend, DIM.upperArm, DIM.forearm, facing);
// Ground poses plant the lowest foot on the feet line; airborne poses hang
// around the hitbox centre.
// Crawling plants whichever of hands, knees and feet is lowest, with the body
// shifted back so it straddles the hitbox centre.
const crawl = input.state === 'crawl';
const grounded = crawl || input.state === 'idle' || input.state === 'run';
let lowest = Math.max(out[L_FOOT * 2 + 1], out[R_FOOT * 2 + 1]);
if (crawl) for (const j of [L_KNEE, R_KNEE, L_HAND, R_HAND]) lowest = Math.max(lowest, out[j * 2 + 1]);
const offsetY = grounded ? input.y - lowest : input.y - 15;
const offsetX = crawl ? -facing * 5 : 0;
const cy = input.y - 15;
const cos = Math.cos(input.spin);
const sin = Math.sin(input.spin);
for (let j = 0; j < JOINTS; j++) {
let px = out[j * 2] + offsetX;
let py = out[j * 2 + 1] + offsetY - cy;
if (input.spin !== 0) {
const rx = px * cos - py * sin;
py = px * sin + py * cos;
px = rx;
}
out[j * 2] = input.x + px;
out[j * 2 + 1] = cy + py;
}
return out;
}
function limb(
out: Pose,
root: number,
mid: number,
end: number,
angle: number,
bend: number,
upper: number,
lower: number,
facing: number,
): void {
const rx = out[root * 2];
const ry = out[root * 2 + 1];
const mx = rx + Math.sin(angle) * facing * upper;
const my = ry + Math.cos(angle) * upper;
const a2 = angle + bend;
out[mid * 2] = mx;
out[mid * 2 + 1] = my;
out[end * 2] = mx + Math.sin(a2) * facing * lower;
out[end * 2 + 1] = my + Math.cos(a2) * lower;
}
/** Returns true when a point moving from (ox, oy) to (x, y) hits terrain. */
export type PointCollider = (ox: number, oy: number, x: number, y: number) => boolean;
interface Constraint {
a: number;
b: number;
length: number;
}
const RAGDOLL_SUBSTEPS = 4;
const RAGDOLL_ITERATIONS = 5;
const MAX_POINT_SPEED = 1400;
/** Position-based ragdoll seeded from the live pose when the stickman dies. */
export class Ragdoll {
readonly pos: Float32Array;
readonly vel: Float32Array;
private readonly prev: Float32Array;
private readonly constraints: Constraint[];
/** Seconds the body has been nearly still. */
restTime = 0;
constructor(pose: Pose, vx: number, vy: number) {
this.pos = Float32Array.from(pose);
this.prev = Float32Array.from(pose);
this.vel = new Float32Array(JOINTS * 2);
for (let j = 0; j < JOINTS; j++) {
this.vel[j * 2] = vx;
this.vel[j * 2 + 1] = vy;
}
const pairs: [number, number][] = [...BONES.map(([a, b]) => [a, b] as [number, number]), [HEAD, PELVIS]];
this.constraints = pairs.map(([a, b]) => ({ a, b, length: this.distance(a, b) }));
}
distance(a: number, b: number): number {
return Math.hypot(this.pos[a * 2] - this.pos[b * 2], this.pos[a * 2 + 1] - this.pos[b * 2 + 1]);
}
/** Radial push away from (ox, oy). Nearer joints get more, so the body spins. */
impulse(ox: number, oy: number, radius: number, power: number): void {
for (let j = 0; j < JOINTS; j++) {
const dx = this.pos[j * 2] - ox;
const dy = this.pos[j * 2 + 1] - oy;
const d = Math.max(1, Math.hypot(dx, dy));
if (d >= radius) continue;
const f = 1 - d / radius;
this.vel[j * 2] += (dx / d) * power * f;
this.vel[j * 2 + 1] += (dy / d) * power * f - power * 0.3 * f;
}
this.restTime = 0;
}
centerX(): number {
let sum = 0;
for (let j = 0; j < JOINTS; j++) sum += this.pos[j * 2];
return sum / JOINTS;
}
centerY(): number {
let sum = 0;
for (let j = 0; j < JOINTS; j++) sum += this.pos[j * 2 + 1];
return sum / JOINTS;
}
/**
* Throw the whole body: a shared velocity plus a spin about its centre.
* A purely radial push from a blast at the body's centre would cancel out.
*/
launch(vx: number, vy: number, spin: number): void {
const cx = this.centerX();
const cy = this.centerY();
for (let j = 0; j < JOINTS; j++) {
const rx = this.pos[j * 2] - cx;
const ry = this.pos[j * 2 + 1] - cy;
this.vel[j * 2] += vx - ry * spin;
this.vel[j * 2 + 1] += vy + rx * spin;
}
this.restTime = 0;
}
step(dt: number, gravity: number, collide: PointCollider): void {
const h = dt / RAGDOLL_SUBSTEPS;
for (let s = 0; s < RAGDOLL_SUBSTEPS; s++) this.substep(h, gravity, collide);
let energy = 0;
for (let i = 0; i < this.vel.length; i++) energy = Math.max(energy, Math.abs(this.vel[i]));
this.restTime = energy < 12 ? this.restTime + dt : 0;
}
private substep(h: number, gravity: number, collide: PointCollider): void {
const { pos, prev, vel } = this;
for (let j = 0; j < JOINTS; j++) {
const ix = j * 2;
const iy = ix + 1;
vel[iy] += gravity * h;
const speed = Math.hypot(vel[ix], vel[iy]);
if (speed > MAX_POINT_SPEED) {
vel[ix] *= MAX_POINT_SPEED / speed;
vel[iy] *= MAX_POINT_SPEED / speed;
}
prev[ix] = pos[ix];
prev[iy] = pos[iy];
pos[ix] += vel[ix] * h;
pos[iy] += vel[iy] * h;
}
for (let it = 0; it < RAGDOLL_ITERATIONS; it++) {
for (const c of this.constraints) {
const ax = c.a * 2;
const bx = c.b * 2;
const dx = pos[bx] - pos[ax];
const dy = pos[bx + 1] - pos[ax + 1];
const d = Math.hypot(dx, dy) || 0.0001;
const diff = ((d - c.length) / d) * 0.5;
pos[ax] += dx * diff;
pos[ax + 1] += dy * diff;
pos[bx] -= dx * diff;
pos[bx + 1] -= dy * diff;
}
}
for (let j = 0; j < JOINTS; j++) {
const ix = j * 2;
const iy = ix + 1;
let touched = false;
if (collide(prev[ix], prev[iy], pos[ix], pos[iy])) {
if (!collide(prev[ix], prev[iy], pos[ix], prev[iy])) {
pos[iy] = prev[iy];
touched = true;
} else if (!collide(prev[ix], prev[iy], prev[ix], pos[iy])) {
pos[ix] = prev[ix];
} else {
pos[ix] = prev[ix];
pos[iy] = prev[iy];
touched = true;
}
}
const nvx = (pos[ix] - prev[ix]) / h;
const nvy = (pos[iy] - prev[iy]) / h;
// Keep a small bounce off surfaces and drag along the ground.
vel[ix] = touched ? nvx * 0.82 : nvx;
vel[iy] = touched && vel[iy] > 160 ? -vel[iy] * 0.22 : nvy;
}
}
}
