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/stickman.ts
/** 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;
    }
  }
}