Back to Stick Brawl
SOURCE / PINNED RELEASE

Made of little things.

Stick Brawl

Release
732bb70ea856…
Author-recorded commit
918970bb749a…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-554b329db93

Archive hash verified: 67f16c1148706bbd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/figure.ts
/**
 * Stickman bodies. Every stickman has joints (shoulders, elbows, hips, knees),
 * and each move is a "pose" of joint angles. The body blends smoothly from
 * one pose to the next, so all movement looks fluid.
 *
 * Angles are in radians, measured from pointing straight down,
 * with positive meaning "towards the way he is facing".
 */
export interface Pose {
  lean: number; // torso tilt from straight up
  armFu: number; // front arm: upper part
  armFl: number; // front arm: forearm
  armBu: number; // back arm
  armBl: number;
  legFu: number; // front leg: thigh
  legFl: number; // front leg: shin
  legBu: number; // back leg
  legBl: number;
}

const KEYS: (keyof Pose)[] = ['lean', 'armFu', 'armFl', 'armBu', 'armBl', 'legFu', 'legFl', 'legBu', 'legBl'];

const THIGH = 8.5;
const SHIN = 8.5;
const TORSO = 13;
const UPPER_ARM = 7;
const FOREARM = 7;
const HEAD_R = 4.5;

/** Roughly how tall a standing stickman is. */
export const FIGURE_HEIGHT = THIGH + SHIN + TORSO + HEAD_R * 2 + 1;

export function copyPose(pose: Pose): Pose {
  return { ...pose };
}

/** Move the body part of the way towards a target pose. Bigger `rate` = snappier. */
export function blendPose(current: Pose, target: Pose, rate: number, dt: number): void {
  const t = 1 - Math.exp(-rate * dt);
  for (const key of KEYS) current[key] += (target[key] - current[key]) * t;
}

// ---------- the poses ----------

export function idlePose(time: number): Pose {
  const breathe = Math.sin(time * 2.5) * 0.04;
  return {
    lean: 0.08 + breathe,
    armFu: 0.35 + breathe,
    armFl: 1.9,
    armBu: 0.1 - breathe,
    armBl: 1.6,
    legFu: 0.22,
    legFl: 0.0,
    legBu: -0.2,
    legBl: -0.05,
  };
}

/** A running cycle; `phase` goes round and round as he runs. */
export function runPose(phase: number): Pose {
  const s = Math.sin(phase);
  const c = Math.cos(phase);
  // Knees bend most while the leg swings forward.
  const bendF = Math.max(0, c) * 1.3 + 0.2;
  const bendB = Math.max(0, -c) * 1.3 + 0.2;
  return {
    lean: 0.28,
    armFu: -s * 0.9 + 0.2,
    armFl: -s * 0.9 + 1.6,
    armBu: s * 0.9 + 0.2,
    armBl: s * 0.9 + 1.6,
    legFu: s * 0.85 + 0.1,
    legFl: s * 0.85 + 0.1 - bendF,
    legBu: -s * 0.85 + 0.1,
    legBl: -s * 0.85 + 0.1 - bendB,
  };
}

export function jumpPose(vy: number): Pose {
  const rising = vy < 0;
  return {
    lean: 0.15,
    armFu: rising ? 2.4 : 1.4,
    armFl: rising ? 2.8 : 2.0,
    armBu: rising ? -1.2 : -0.9,
    armBl: rising ? -0.6 : -0.3,
    legFu: 1.1,
    legFl: -0.1,
    legBu: 0.3,
    legBl: -1.0,
  };
}

/** A punch: pull back, then snap the front fist forward. `p` goes from 0 to 1. */
export function punchPose(p: number, windup = 0.25): Pose {
  if (p < windup) {
    return {
      lean: -0.05,
      armFu: -0.3,
      armFl: 1.2,
      armBu: 0.6,
      armBl: 2.0,
      legFu: 0.45,
      legFl: 0.1,
      legBu: -0.4,
      legBl: -0.2,
    };
  }
  return {
    lean: 0.3,
    armFu: 1.62,
    armFl: 1.6,
    armBu: -0.4,
    armBl: 0.9,
    legFu: 0.5,
    legFl: 0.15,
    legBu: -0.45,
    legBl: -0.2,
  };
}

/** A big front kick. */
export function kickPose(p: number): Pose {
  if (p < 0.25) {
    return {
      lean: -0.1,
      armFu: 0.8,
      armFl: 2.2,
      armBu: -0.5,
      armBl: 0.6,
      legFu: 1.6,
      legFl: -0.5,
      legBu: -0.05,
      legBl: -0.05,
    };
  }
  return {
    lean: -0.45,
    armFu: 0.9,
    armFl: 2.0,
    armBu: -1.0,
    armBl: -0.2,
    legFu: 1.7,
    legFl: 1.6,
    legBu: -0.1,
    legBl: -0.05,
  };
}

/** Drop-kick: leaning way back, both feet shooting forward. */
export function dropKickPose(): Pose {
  return {
    lean: -1.25,
    armFu: -1.6,
    armFl: -1.3,
    armBu: -2.0,
    armBl: -1.6,
    legFu: 1.45,
    legFl: 1.6,
    legBu: 1.25,
    legBl: 1.5,
  };
}

/** Sliding along the floor: front leg out straight, back leg tucked under, leaning back. */
export function slidePose(): Pose {
  return {
    lean: -0.55,
    armFu: -1.2,
    armFl: -0.6,
    armBu: -1.6,
    armBl: -0.9,
    legFu: 1.35,
    legFl: 1.45,
    legBu: 0.55,
    legBl: -0.95,
  };
}

/** Curled up into a ball for a roll. */
export function tuckPose(): Pose {
  return {
    lean: 0.9,
    armFu: 1.3,
    armFl: 2.3,
    armBu: 1.0,
    armBl: 2.2,
    legFu: 2.0,
    legFl: -0.4,
    legBu: 1.7,
    legBl: -0.6,
  };
}

/** Guarding: both forearms up in front of the face. */
export function blockPose(): Pose {
  return {
    lean: -0.08,
    armFu: 1.1,
    armFl: 3.0,
    armBu: 0.9,
    armBl: 2.8,
    legFu: 0.4,
    legFl: 0.15,
    legBu: -0.4,
    legBl: -0.2,
  };
}

/** Hand down at the hip, where the revolver's holster is. */
export function holsterPose(): Pose {
  return {
    lean: 0.02,
    armFu: -0.12,
    armFl: 0.3,
    armBu: -0.2,
    armBl: 0.3,
    legFu: 0.3,
    legFl: 0.05,
    legBu: -0.3,
    legBl: -0.1,
  };
}

/** Quick draw: gun arm straight out at hip height. */
export function shootPose(): Pose {
  return {
    lean: 0.05,
    armFu: 0.55,
    armFl: 1.45,
    armBu: -0.3,
    armBl: 0.4,
    legFu: 0.35,
    legFl: 0.1,
    legBu: -0.35,
    legBl: -0.15,
  };
}

/** Sword swing, in steps: reach over the shoulder, raise it, slash down, then put it back. */
export function swordPose(stage: 'grab' | 'raise' | 'slash' | 'follow'): Pose {
  const legs = { legFu: 0.55, legFl: 0.15, legBu: -0.5, legBl: -0.25 };
  // Angles above π point up and back, so the arm sweeps up in front, over the head, then down.
  if (stage === 'grab') return { lean: 0, armFu: 3.58, armFl: 3.38, armBu: 0.2, armBl: 1.2, ...legs };
  if (stage === 'raise') return { lean: -0.12, armFu: 2.95, armFl: 3.75, armBu: -0.4, armBl: 0.6, ...legs };
  if (stage === 'slash') return { lean: 0.35, armFu: 1.25, armFl: 1.0, armBu: -0.8, armBl: 0.2, ...legs, legFu: 0.75 };
  return { lean: 0.25, armFu: 0.95, armFl: 0.8, armBu: -0.6, armBl: 0.3, ...legs };
}

/** Knocked back by a hit. */
export function hurtPose(): Pose {
  return {
    lean: -0.5,
    armFu: 1.0,
    armFl: 1.8,
    armBu: -1.4,
    armBl: -0.8,
    legFu: 0.5,
    legFl: 0.0,
    legBu: -0.3,
    legBl: -0.6,
  };
}

/** Lying flat, used when a stickman is knocked out. */
export function knockedOutPose(): Pose {
  return {
    lean: 0.0,
    armFu: 2.6,
    armFl: 2.9,
    armBu: 2.4,
    armBl: 2.1,
    legFu: 0.1,
    legFl: 0.0,
    legBu: -0.15,
    legBl: 0.1,
  };
}

// ---------- drawing ----------

export interface Point {
  x: number;
  y: number;
}

/** Where the body's main joints are, measured from the feet, with +x pointing the way he faces. */
export interface Joints {
  hip: Point;
  shoulder: Point;
  head: Point;
  elbowF: Point;
  handF: Point;
}

export function figureJoints(pose: Pose, minHip = 6): Joints {
  // Keep the lower foot on the ground.
  const hipHeight = Math.max(legReach(pose.legFu, pose.legFl), legReach(pose.legBu, pose.legBl), minHip);
  const hip: Point = { x: 0, y: -hipHeight };
  const shoulder: Point = { x: hip.x + Math.sin(pose.lean) * TORSO, y: hip.y - Math.cos(pose.lean) * TORSO };
  const head: Point = {
    x: shoulder.x + Math.sin(pose.lean) * (HEAD_R + 1.5),
    y: shoulder.y - Math.cos(pose.lean) * (HEAD_R + 1.5),
  };
  const elbowF = limbEnd(shoulder, pose.armFu, UPPER_ARM);
  const handF = limbEnd(elbowF, pose.armFl, FOREARM);
  return { hip, shoulder, head, elbowF, handF };
}

function limbEnd(from: Point, angle: number, length: number): Point {
  return { x: from.x + Math.sin(angle) * length, y: from.y + Math.cos(angle) * length };
}

/** How far below the hip a leg reaches. */
function legReach(upper: number, lower: number): number {
  return THIGH * Math.cos(upper) + SHIN * Math.cos(lower);
}

export interface DrawOptions {
  color: string;
  /** Whole-body spin around the hips (for rolling). */
  spin?: number;
  /** Keep the hips at least this high, so a spinning body stays above the floor. */
  minHip?: number;
  /** Tip over around the feet (for falling down). Negative falls backwards. */
  topple?: number;
  alpha?: number;
  /** Flash white for a moment when hit. */
  flash?: number;
  /** How big the stickman is (1 = normal). */
  size?: number;
  /** Extra things to draw on the body (weapons), behind it and in front of it. */
  gear?: (ctx: CanvasRenderingContext2D, joints: Joints, layer: 'behind' | 'front') => void;
}

/** Draw a stickman standing with his feet at (x, y). */
export function drawFigure(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  facing: number,
  pose: Pose,
  options: DrawOptions,
): void {
  ctx.save();
  ctx.translate(x, y);
  ctx.scale(facing * (options.size ?? 1), options.size ?? 1);
  ctx.globalAlpha = options.alpha ?? 1;
  if (options.topple) ctx.rotate(options.topple);

  const joints = figureJoints(pose, options.minHip);
  const { hip, shoulder, head } = joints;
  if (options.spin) {
    ctx.translate(hip.x, hip.y);
    ctx.rotate(options.spin);
    ctx.translate(-hip.x, -hip.y);
  }

  const color = options.color;
  const back = shade(color);
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.lineWidth = 2.6;

  options.gear?.(ctx, joints, 'behind');

  // Back arm and leg first, a bit darker, so the body looks like it has depth.
  ctx.strokeStyle = back;
  limb(ctx, shoulder, pose.armBu, UPPER_ARM, pose.armBl, FOREARM);
  limb(ctx, hip, pose.legBu, THIGH, pose.legBl, SHIN);

  ctx.strokeStyle = color;
  ctx.beginPath();
  ctx.moveTo(hip.x, hip.y);
  ctx.lineTo(shoulder.x, shoulder.y);
  ctx.stroke();
  limb(ctx, hip, pose.legFu, THIGH, pose.legFl, SHIN);
  limb(ctx, shoulder, pose.armFu, UPPER_ARM, pose.armFl, FOREARM);

  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.arc(head.x, head.y, HEAD_R, 0, Math.PI * 2);
  ctx.fill();
  options.gear?.(ctx, joints, 'front');

  if (options.flash && options.flash > 0) {
    ctx.globalAlpha = (options.alpha ?? 1) * Math.min(1, options.flash);
    ctx.strokeStyle = '#ffffff';
    ctx.fillStyle = '#ffffff';
    ctx.beginPath();
    ctx.arc(head.x, head.y, HEAD_R, 0, Math.PI * 2);
    ctx.fill();
    ctx.beginPath();
    ctx.moveTo(hip.x, hip.y);
    ctx.lineTo(shoulder.x, shoulder.y);
    ctx.stroke();
  }
  ctx.restore();
}

function limb(ctx: CanvasRenderingContext2D, from: Point, upper: number, upperLen: number, lower: number, lowerLen: number): void {
  const joint = limbEnd(from, upper, upperLen);
  const end = limbEnd(joint, lower, lowerLen);
  ctx.beginPath();
  ctx.moveTo(from.x, from.y);
  ctx.lineTo(joint.x, joint.y);
  ctx.lineTo(end.x, end.y);
  ctx.stroke();
}

/** A darker version of an hsl() or #rrggbb colour, for the far-side arm and leg. */
function shade(color: string): string {
  const hsl = color.match(/^hsl\((\d+(?:\.\d+)?),\s*(\d+)%,\s*(\d+)%\)$/);
  if (hsl) return `hsl(${hsl[1]}, ${hsl[2]}%, ${Math.round(Number(hsl[3]) * 0.68)}%)`;
  const hex = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
  if (hex) {
    const [r, g, b] = [hex[1], hex[2], hex[3]].map((part) => Math.round(parseInt(part, 16) * 0.68));
    return `rgb(${r}, ${g}, ${b})`;
  }
  return color;
}