SOURCE / PINNED RELEASE
Made of little things.
Stick Runner
- Release
- 50f4e4cd86f6…
- Author-recorded commit
- 2960bafd6cd9…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-938fb0aa7b7
Archive hash verified: 3dacab6eb59b4481…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
export type Pose = 'stand' | 'run' | 'jump' | 'fall' | 'cheer' | 'roll' | 'slide';
const INK = '#1d1724';
const BANDANA = '#2fa84f';
const BANDANA_DARK = '#1e7a37';
/**
* Draw the stickman with his feet at (x, y).
* `cycle` moves the legs and arms while running, or is how far through a roll he is (0 to 1).
*/
export function drawStickman(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
facing: number,
pose: Pose,
cycle: number,
vy: number,
): void {
ctx.save();
ctx.translate(x, y);
ctx.scale(facing, 1);
ctx.strokeStyle = INK;
ctx.fillStyle = INK;
ctx.lineWidth = 2.6;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (pose === 'roll') {
drawRoll(ctx, cycle);
ctx.restore();
return;
}
const hip = { x: 0, y: -16 };
let lean = 0;
let legs: [number, number][] = [];
let arms: [number, number][] = [];
if (pose === 'run') {
const swing = Math.sin(cycle);
lean = 3;
legs = [limb(hip, 0.2 + swing * 0.8, 16), limb(hip, 0.2 - swing * 0.8, 16)];
arms = [[-swing * 7, 7], [swing * 7, 7]];
} else if (pose === 'jump') {
const rising = vy < 0;
lean = 2;
legs = rising ? [[7, -6], [-5, -2]] : [[5, -1], [-4, 0]];
arms = rising ? [[6, -8], [-6, -6]] : [[8, -2], [-8, -3]];
} else if (pose === 'slide') {
// Surfing on the ice: feet apart, leaning back, arms out for balance.
const wobble = Math.sin(cycle * 3) * 2;
lean = -3;
legs = [[9, 0], [-7, 0]];
arms = [[10, -4 + wobble], [-10, -6 - wobble]];
} else if (pose === 'fall') {
legs = [[-7, -4], [7, -2]];
arms = [[-9, -12], [9, -12]];
} else if (pose === 'cheer') {
const bounce = Math.abs(Math.sin(cycle)) * 3;
ctx.translate(0, -bounce);
legs = [[-5, 0], [5, 0]];
arms = [[-7, -14], [7, -14]];
} else {
legs = [[-4, 0], [4, 0]];
arms = [[-4, 9], [4, 9]];
}
const shoulder = { x: lean, y: -29 };
// Body.
line(ctx, hip.x, hip.y, shoulder.x, shoulder.y);
// Head, with Rog's green bandana.
const headX = shoulder.x + lean * 0.4;
const headY = shoulder.y - 6;
ctx.beginPath();
ctx.arc(headX, headY, 5, 0, Math.PI * 2);
ctx.fill();
drawBandana(ctx, headX, headY, 5, pose !== 'stand');
// Legs (knee halfway, pushed forward a little).
for (const [fx, fy] of legs) {
const kx = (hip.x + fx) / 2 + 3;
const ky = (hip.y + fy) / 2;
polyline(ctx, [hip.x, hip.y, kx, ky, fx, fy]);
}
// Arms.
for (const [hx, hy] of arms) {
const ex = shoulder.x + hx * 0.5 + (pose === 'run' ? -2 : 0);
const ey = shoulder.y + hy * 0.5 + 2;
polyline(ctx, [shoulder.x, shoulder.y + 1, ex, ey, shoulder.x + hx, shoulder.y + hy]);
}
ctx.restore();
}
/** Curled up in a ball, spinning forward once. */
function drawRoll(ctx: CanvasRenderingContext2D, progress: number): void {
// Squash down at the start and stand back up at the end.
const tuck = Math.sin(progress * Math.PI);
ctx.translate(0, -10 - (1 - tuck) * 4);
ctx.rotate(progress * Math.PI * 2);
// Curved back.
ctx.beginPath();
ctx.arc(0, 0, 8, Math.PI * 0.75, Math.PI * 2.1);
ctx.stroke();
// Head tucked in.
ctx.beginPath();
ctx.arc(6, -5, 4.5, 0, Math.PI * 2);
ctx.fill();
drawBandana(ctx, 6, -5, 4.5, true);
// Knees pulled up, arms hugging them.
polyline(ctx, [-6, 5, 2, -1, 6, 6]);
polyline(ctx, [-5, 6, 4, 3, 7, 8]);
polyline(ctx, [3, -4, 4, 2, 8, 1]);
}
/** Rog's green bandana: a band around his forehead, tied at the back with two tails that flap when he moves. */
function drawBandana(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, moving: boolean): void {
ctx.save();
// The band, wrapped around the top of the head.
ctx.beginPath();
ctx.arc(x, y, r + 0.3, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = BANDANA;
ctx.fillRect(x - r - 1, y - r * 0.8, r * 2 + 2, r * 0.65);
ctx.restore();
// The knot at the back of the head.
const knotX = x - r * 0.9;
const knotY = y - r * 0.45;
ctx.fillStyle = BANDANA_DARK;
ctx.beginPath();
ctx.arc(knotX, knotY, 1.3, 0, Math.PI * 2);
ctx.fill();
// Two tails: streaming out behind when moving, hanging down when standing still.
const flap = Math.sin(performance.now() / 70);
const tails: [number, number][] = moving
? [
[knotX - 7, knotY - 1 + flap * 1.5],
[knotX - 6, knotY + 2 - flap * 1.5],
]
: [
[knotX - 2, knotY + 6],
[knotX - 0.5, knotY + 7],
];
ctx.strokeStyle = BANDANA;
ctx.lineWidth = 1.6;
for (const [tx, ty] of tails) {
ctx.beginPath();
ctx.moveTo(knotX, knotY);
ctx.quadraticCurveTo((knotX + tx) / 2, (knotY + ty) / 2 + (moving ? flap : 0), tx, ty);
ctx.stroke();
}
ctx.strokeStyle = INK;
ctx.lineWidth = 2.6;
}
function limb(from: { x: number; y: number }, angle: number, length: number): [number, number] {
return [from.x + Math.sin(angle) * length, Math.min(0, from.y + Math.cos(angle) * length)];
}
function line(ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number): void {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
function polyline(ctx: CanvasRenderingContext2D, points: number[]): void {
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) ctx.lineTo(points[i], points[i + 1]);
ctx.stroke();
}
