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/level.ts
/** The game world is 480×270 units tall; the camera scrolls sideways. */
export const VIEW_W = 480;
export const VIEW_H = 270;

/** Anything solid the stickman can stand on or bump into. */
export interface Block {
  x: number;
  y: number;
  w: number;
  h: number;
  kind: 'roof' | 'chimney' | 'vent' | 'tank';
}

/** Slippery patches. The stickman slides fast to the end of these and can't stop. */
export interface Ice {
  x: number;
  y: number;
  w: number;
}

/** A clothesline tied from one building's wall to the next, sagging in the middle. */
export interface Clothesline {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  sag: number;
}

/** Colours for a level's sky and faraway city. */
export interface Scenery {
  sky: [string, string, string];
  sun: string;
  farCity: [string, string];
}

/** How a level is written down. */
interface LevelPlan {
  name: string;
  /** Each roof is [gap before it, width, roof height]. The gaps are the pits. */
  roofs: [number, number, number][];
  /** Small things on the roofs to jump over: [roof number, distance from the roof's left edge, width, height, kind]. */
  props: [number, number, number, number, Block['kind']][];
  /** Ice at the end of a roof: [roof number, how long the ice is]. */
  ice: [number, number][];
  /** Roof numbers that have a clothesline across the (too wide to jump) gap before them. */
  clotheslines: number[];
  flag: 'checkered' | 'gold';
  scenery: Scenery;
}

/** A level ready to play. */
export interface Level {
  name: string;
  blocks: Block[];
  ice: Ice[];
  clotheslines: Clothesline[];
  startX: number;
  finishX: number;
  end: number;
  flag: 'checkered' | 'gold';
  scenery: Scenery;
}

const LEVEL_1: LevelPlan = {
  name: 'Level 1',
  // The map gets harder as you go: small gaps and wide roofs at first,
  // then bigger gaps, narrower roofs and higher climbs near the end.
  roofs: [
    // Part 1: warm-up.
    [0, 520, 200],
    [60, 320, 200],
    [60, 280, 190],
    [70, 260, 190],
    [70, 240, 175],
    // Part 2: bigger gaps and more things in the way.
    [240, 220, 195], // clothesline
    [80, 200, 170],
    [100, 220, 185],
    [90, 180, 160],
    [260, 220, 200], // clothesline
    [85, 160, 165],
    // Part 3: narrow roofs, wide pits and big climbs.
    [120, 150, 200],
    [90, 120, 150],
    [115, 130, 175],
    [95, 110, 130],
    [260, 140, 180], // clothesline
    [100, 100, 145],
    [120, 120, 165],
    [100, 100, 125],
    [125, 420, 175],
  ],
  props: [
    [1, 90, 22, 28, 'chimney'],
    [3, 110, 40, 22, 'vent'],
    [5, 90, 24, 30, 'chimney'],
    [7, 60, 22, 34, 'chimney'],
    [9, 80, 30, 40, 'tank'],
    [19, 90, 22, 30, 'chimney'],
  ],
  ice: [
    [2, 100],
    [5, 80],
    [6, 70],
    [18, 60],
  ],
  clotheslines: [5, 9, 15],
  flag: 'checkered',
  scenery: {
    sky: ['#ffb36b', '#ffd9a0', '#fff1d6'],
    sun: '#fff4dc',
    farCity: ['#e8a07a', '#c97c68'],
  },
};

const LEVEL_2: LevelPlan = {
  name: 'Level 2',
  // A different city in the evening. Short warm-up, a long middle part, then a hard finish.
  roofs: [
    // Part 1: warm-up.
    [0, 460, 170],
    [70, 260, 185],
    [60, 220, 165],
    [80, 300, 190],
    // Part 2: the long middle, with lots of ice and clotheslines.
    [100, 200, 175],
    [250, 220, 195], // clothesline
    [90, 180, 170],
    [100, 240, 185],
    [95, 160, 160],
    [240, 260, 180], // clothesline
    [110, 200, 200],
    [90, 180, 170],
    [100, 220, 190],
    [120, 240, 200],
    [250, 200, 175], // clothesline
    [85, 160, 160],
    [100, 260, 180],
    // Part 3: tiny roofs and big climbs.
    [120, 130, 200],
    [95, 110, 150],
    [255, 120, 170], // clothesline
    [115, 110, 135],
    [120, 120, 175],
    [105, 140, 150],
    [120, 110, 120],
    [125, 420, 165],
  ],
  props: [
    [0, 260, 24, 30, 'chimney'],
    [1, 120, 30, 40, 'tank'],
    [3, 100, 22, 30, 'chimney'],
    [7, 60, 40, 22, 'vent'],
    [24, 90, 22, 30, 'chimney'],
  ],
  ice: [
    [2, 80],
    [6, 70],
    [9, 90],
    [12, 80],
    [15, 60],
    [21, 50],
  ],
  clotheslines: [5, 9, 14, 19],
  flag: 'gold',
  scenery: {
    sky: ['#9b7fd1', '#e3a6c8', '#ffd8c9'],
    sun: '#fff0f5',
    farCity: ['#b98bb8', '#94709e'],
  },
};

/** Roofs reach down past the bottom of the screen so they look like whole buildings. */
const BUILDING_BOTTOM = VIEW_H + 40;

/** Falling this far down means the stickman is in a pit. */
export const PIT_DEPTH = VIEW_H + 30;

function buildLevel(plan: LevelPlan): Level {
  const blocks: Block[] = [];
  let nextX = 0;
  for (const [gap, w, y] of plan.roofs) {
    blocks.push({ x: nextX + gap, y, w, h: BUILDING_BOTTOM - y, kind: 'roof' });
    nextX += gap + w;
  }
  const roofs = blocks.slice();
  for (const [index, offset, w, h, kind] of plan.props) {
    const roof = roofs[index];
    blocks.push({ x: roof.x + offset, y: roof.y - h, w, h, kind });
  }
  const ice = plan.ice.map(([index, length]) => {
    const roof = roofs[index];
    return { x: roof.x + roof.w - length, y: roof.y, w: length };
  });
  const clotheslines = plan.clotheslines.map((index) => {
    const before = roofs[index - 1];
    const after = roofs[index];
    return { x1: before.x + before.w, y1: before.y + 8, x2: after.x, y2: after.y + 8, sag: 16 };
  });
  return {
    name: plan.name,
    blocks,
    ice,
    clotheslines,
    startX: 60,
    finishX: nextX - 110,
    end: nextX,
    flag: plan.flag,
    scenery: plan.scenery,
  };
}

export const LEVELS: Level[] = [LEVEL_1, LEVEL_2].map(buildLevel);

/** How high the rope is at a spot along it. */
export function lineHeightAt(line: Clothesline, x: number): number {
  const t = (x - line.x1) / (line.x2 - line.x1);
  return line.y1 + (line.y2 - line.y1) * t + line.sag * Math.sin(Math.PI * t);
}

export function roofHeightAt(blocks: Block[], x: number): number {
  const roof = blocks.find((block) => block.kind === 'roof' && x >= block.x && x <= block.x + block.w);
  return roof ? roof.y : VIEW_H;
}