Back to Stick Runner
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.

src/game/stickman.ts
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();
}