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