Back to Stick Runner
SOURCE / PINNED RELEASE

Made of little things.

Stick Runner

Release
068f2c4fd180…
Author-recorded commit
2f0b589e2e75…
License
LICENSE
Author’s source reference
nostr://npub1ny7l44q0au5xkv3mjgt6zks5f0e2ydefslq4nhh2ve2xypgc2mqqat8qx2/wss%3A%2F%2Fgit.napplet.soy%2F/n-938fb0aa7b7

Archive hash verified: a0399c408781bf50…. 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' | 'spring';
}

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

/** A spinning saw blade that slides back and forth along a roof. */
export interface Saw {
  /** The left and right ends of the slot its middle slides along. */
  x1: number;
  x2: number;
  /** The roof it's on. */
  roofY: number;
  /** Seconds to go from one end to the other and back. */
  period: number;
}

/** How big a saw blade is. Most of it sticks up above the roof. */
export const SAW_RADIUS = 16;

/** Where a saw's middle is at a moment in the level (it slows down at each end, then turns back). */
export function sawCenter(saw: Saw, time: number): { x: number; y: number } {
  const t = 0.5 - 0.5 * Math.cos((time / saw.period) * Math.PI * 2);
  return { x: saw.x1 + (saw.x2 - saw.x1) * t, y: saw.roofY - SAW_RADIUS * 0.55 };
}

/** A spring on a roof: jump onto it and Rog bounces way up, onto roofs too tall to jump. */
export interface Spring {
  x: number;
  roofY: number;
  /** True for a spring at the edge of a roof, for flying over a wide gap (not up onto a tall building). */
  overGap: boolean;
  /** For a gap spring: where on the next roof it throws Rog. */
  landX: number;
  landY: number;
}

/** How big a spring is. It's solid, so you have to jump on top. */
export const SPRING_WIDTH = 40;
/** Low enough that a quick tap of jump hops you onto it. */
export const SPRING_HEIGHT = 14;

/** A patch of sharp spikes sticking up out of a roof. */
export interface Spikes {
  x: number;
  w: number;
  /** The roof they're on. */
  roofY: number;
}

/** How tall the spikes are. */
export const SPIKE_HEIGHT = 10;

/** 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. */
export 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[];
  /** Moving saws: [roof number, left end of its slot, right end (both from the roof's left edge), seconds there and back]. */
  saws?: [number, number, number, number][];
  /** Spikes on a roof: [roof number, how wide the patch is, and where it starts from the roof's left edge (the middle if left out)]. */
  spikes?: ([number, number] | [number, number, number])[];
  /** Springs: [roof number, distance from the roof's left edge]. Put one before a roof that's too tall to jump. */
  springs?: [number, number][];
  /** A dragon that flies behind Rog and shoots fireballs at him. */
  dragon?: boolean;
  /** How the level ends: a checkered or gold flag, or a staircase down into the building. */
  flag: 'checkered' | 'gold' | 'stairs';
  scenery: Scenery;
}

/** A level ready to play. */
export interface Level {
  name: string;
  blocks: Block[];
  ice: Ice[];
  clotheslines: Clothesline[];
  saws: Saw[];
  spikes: Spikes[];
  springs: Spring[];
  dragon: boolean;
  startX: number;
  finishX: number;
  end: number;
  flag: 'checkered' | 'gold' | 'stairs';
  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.
    [190, 350, 195], // too wide to jump: slide on the ice before it
    [80, 200, 170],
    [100, 220, 185],
    [90, 180, 160],
    [115, 365, 200],
    [85, 160, 165],
    // Part 3: narrow roofs, wide pits and big climbs.
    [120, 150, 200],
    [90, 120, 150],
    [190, 130, 175], // too wide to jump: slide on the ice before it
    [95, 110, 130],
    [125, 275, 180],
    [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'],

    // More to jump over.
    [1, 200, 22, 30, 'chimney'],
    [2, 50, 22, 30, 'chimney'],
    [4, 50, 40, 22, 'vent'],
    [8, 50, 40, 22, 'vent'],
    [9, 200, 22, 30, 'chimney'],
    [10, 50, 22, 30, 'chimney'],
    [15, 50, 30, 40, 'tank'],  ],
  ice: [
    [2, 100],
    [5, 80],
    [6, 70],
    [18, 60],

    [4, 60],
    [12, 60],  ],
  clotheslines: [],
  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, just as long as Level 1. Short warm-up, a middle part, then a hard finish.
  roofs: [
    // Part 1: warm-up.
    [0, 460, 170],
    [70, 260, 185],
    [245, 220, 165], // clothesline
    [80, 220, 190],
    // Part 2: the middle, with ice and clotheslines.
    [100, 200, 175],
    [250, 220, 195], // clothesline
    [245, 180, 170], // clothesline
    [100, 175, 185],
    [95, 160, 160],
    [240, 260, 180], // clothesline
    [250, 200, 175], // clothesline
    [190, 160, 160], // too wide to jump: slide on the ice before it
    // Part 3: tiny roofs and big climbs.
    [190, 130, 200], // too wide to jump: slide on the ice before it
    [95, 110, 150],
    [255, 120, 170], // clothesline
    [115, 110, 135],
    [190, 120, 175], // too wide to jump: slide on the ice before it
    [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'],
    [19, 90, 22, 30, 'chimney'],

    // More to jump over.
    [4, 50, 30, 40, 'tank'],
    [5, 50, 40, 22, 'vent'],
    [10, 50, 22, 30, 'chimney'],  ],
  ice: [
    [2, 80],
    [6, 70],
    [9, 90],
    [11, 60],
    [16, 50],

    [10, 60],
    [15, 60],  ],
  clotheslines: [2, 5, 6, 9, 10, 14],
  flag: 'gold',
  scenery: {
    sky: ['#9b7fd1', '#e3a6c8', '#ffd8c9'],
    sun: '#fff0f5',
    farCity: ['#b98bb8', '#94709e'],
  },
};


// Levels 3 to 10 get harder and harder: longer, with wider gaps, narrower roofs,
// bigger climbs, more clotheslines and ice jumps, and less room for a badly timed jump.
// Some gaps (170 or wider) are too far to jump from a run: there's always ice before
// them, so slide on it and fly across.
const LEVEL_3: LevelPlan = {
  name: 'Level 3',
  // Morning.
  roofs: [
    [0, 420, 190],
    [135, 180, 215],
    [125, 200, 215],
    [105, 225, 180],
    [115, 215, 195],
    [125, 195, 210],
    [245, 155, 210],
    [105, 230, 170],
    [245, 210, 180], // clothesline
    [250, 170, 195],
    [100, 185, 160],
    [180, 265, 180],
    [140, 205, 215],
    [245, 205, 190], // clothesline
    [120, 200, 195],
    [130, 420, 215],
  ],
  props: [
    [8, 85, 36, 22, 'vent'],
    [15, 90, 22, 30, 'chimney'],

    // More to jump over.
    [1, 50, 30, 40, 'tank'],
    [2, 50, 40, 22, 'vent'],
    [3, 50, 22, 30, 'chimney'],
    [4, 50, 22, 30, 'chimney'],
    [5, 50, 30, 40, 'tank'],
    [6, 50, 40, 22, 'vent'],
    [7, 50, 22, 30, 'chimney'],
    [9, 50, 30, 40, 'tank'],
    [11, 50, 22, 30, 'chimney'],
    [12, 50, 22, 30, 'chimney'],  ],
  ice: [
    [10, 100],
    [13, 75],
    [14, 80],
  ],
  clotheslines: [6, 8, 9, 13],
  flag: 'checkered',
  scenery: {
    sky: ['#8fc6ff', '#cfe8ff', '#f4fbff'],
    sun: '#fffbe6',
    farCity: ['#a9c3dd', '#8aa7c4'],
  },
};

const LEVEL_4: LevelPlan = {
  name: 'Level 4',
  // Bright noon.
  roofs: [
    [0, 420, 190],
    [135, 210, 215],
    [245, 185, 200], // clothesline
    [245, 205, 205],
    [110, 200, 195],
    [245, 200, 175],
    [140, 190, 205],
    [130, 180, 215],
    [245, 210, 215], // clothesline
    [190, 205, 215], // too wide to jump: slide on the ice before it
    [125, 180, 215],
    [185, 270, 215],
    [110, 225, 170],
    [135, 220, 215],
    [125, 220, 205],
    [130, 170, 215],
    [110, 180, 180],
    [105, 420, 135],
  ],
  props: [
    [17, 90, 22, 30, 'chimney'],

    // More to jump over.
    [1, 50, 22, 30, 'chimney'],
    [5, 50, 22, 30, 'chimney'],
    [6, 50, 30, 40, 'tank'],
    [13, 50, 22, 30, 'chimney'],
    [14, 50, 30, 40, 'tank'],  ],
  ice: [
    [8, 85],
    [10, 95],
    [12, 70],
    [14, 55],
  ],
  clotheslines: [2, 3, 5, 8],
  spikes: [[3, 40, 50], [4, 40, 50], [7, 40, 50], [11, 40, 150], [12, 40, 50], [15, 40, 50], [16, 40, 50]],
  flag: 'checkered',
  scenery: {
    sky: ['#5ec8f2', '#a8e6ff', '#e8f9ff'],
    sun: '#ffffff',
    farCity: ['#8fbfd3', '#6fa0b8'],
  },
};

const LEVEL_5: LevelPlan = {
  name: 'Level 5',
  // Autumn evening.
  roofs: [
    [0, 420, 190],
    [115, 175, 160],
    [120, 205, 140],
    [250, 180, 150],
    [245, 210, 155], // clothesline
    [120, 160, 120],
    [245, 210, 115], // clothesline
    [190, 245, 125],
    [110, 175, 115],
    [120, 180, 125],
    [180, 285, 130],
    [135, 165, 130],
    [110, 175, 115],
    [120, 170, 115],
    [245, 155, 125],
    [130, 200, 125],
    [140, 155, 150],
    [250, 190, 155],
    [140, 195, 190],
    [130, 420, 175],
  ],
  props: [
    [4, 85, 30, 40, 'tank'],
    [19, 90, 22, 30, 'chimney'],

    // More to jump over.
    [2, 50, 22, 30, 'chimney'],
    [3, 50, 30, 40, 'tank'],
    [10, 50, 22, 30, 'chimney'],
    [11, 50, 30, 40, 'tank'],
    [14, 50, 22, 30, 'chimney'],
    [18, 50, 22, 30, 'chimney'],  ],
  ice: [
    [1, 70],
    [6, 90],
    [9, 100],
  ],
  clotheslines: [3, 4, 6, 14, 17],
  spikes: [[7, 45], [15, 45], [5, 43, 50], [8, 43, 50], [12, 43, 50], [17, 43, 50]],
  flag: 'checkered',
  scenery: {
    sky: ['#f08a4b', '#f7c08a', '#fde6c8'],
    sun: '#fff1d0',
    farCity: ['#d98a5e', '#b46a48'],
  },
};

const LEVEL_6: LevelPlan = {
  name: 'Level 6',
  // Mint spring day.
  roofs: [
    [0, 420, 190],
    [140, 195, 215],
    [120, 185, 195],
    [115, 185, 150],
    [125, 195, 165],
    [135, 185, 200],
    [245, 155, 205],
    [120, 155, 170],
    [245, 180, 175], // clothesline
    [180, 255, 185],
    [125, 190, 165],
    [250, 225, 195],
    [110, 185, 150],
    [190, 230, 150],
    [125, 175, 145],
    [245, 195, 140], // clothesline
    [135, 165, 160],
    [135, 180, 165],
    [250, 155, 180],
    [135, 135, 165],
    [130, 170, 175],
    [130, 420, 165],
  ],
  props: [
    [21, 90, 22, 30, 'chimney'],

    // More to jump over.
    [7, 50, 22, 30, 'chimney'],
    [16, 50, 30, 40, 'tank'],
    [20, 50, 30, 40, 'tank'],  ],
  ice: [
    [1, 70],
    [2, 90],
    [8, 95],
    [9, 65],
    [12, 100],
    [13, 75],
    [15, 65],
    [19, 55],
  ],
  clotheslines: [6, 8, 11, 15, 18],
  spikes: [[4, 45], [17, 45], [6, 46, 50], [9, 46, 110], [13, 46, 50], [14, 46, 50], [18, 46, 50]],
  saws: [[11, 70, 185, 3.0], [3, 70, 145, 3]],
  flag: 'checkered',
  scenery: {
    sky: ['#7fd1b9', '#c4ecd9', '#eefbf3'],
    sun: '#fbfff6',
    farCity: ['#8cbfa9', '#6d9f8a'],
  },
};

const LEVEL_7: LevelPlan = {
  name: 'Level 7',
  // Stormy grey sky.
  roofs: [
    [0, 420, 190],
    [140, 170, 215],
    [245, 125, 195], // clothesline
    [250, 200, 200],
    [245, 125, 215], // clothesline
    [135, 160, 215],
    [245, 170, 210],
    [135, 170, 200],
    [115, 185, 175],
    [120, 180, 175],
    [185, 260, 200],
    [140, 130, 205],
    [250, 165, 215],
    [125, 180, 215],
    [190, 235, 215],
    [140, 160, 215],
    [140, 180, 215],
    [190, 265, 215],
    [120, 135, 180],
    [255, 160, 175],
    [130, 175, 150],
    [140, 160, 185],
    [140, 145, 215],
    [140, 420, 215],
  ],
  props: [
    [23, 90, 22, 30, 'chimney'],

    // More to jump over.
    [1, 50, 30, 40, 'tank'],
    [12, 50, 22, 30, 'chimney'],
    [20, 50, 22, 30, 'chimney'],
    [21, 50, 30, 40, 'tank'],  ],
  ice: [
    [7, 70],
    [9, 85],
    [13, 100],
    [14, 85],
    [16, 100],
  ],
  clotheslines: [2, 3, 4, 6, 12, 19],
  spikes: [[3, 50], [17, 50], [6, 49, 50], [19, 49, 50]],
  saws: [[10, 70, 220, 2.8], [8, 70, 145, 2.8]],
  flag: 'checkered',
  scenery: {
    sky: ['#8a8fa8', '#bfc2d1', '#e4e5ec'],
    sun: '#f4f4f8',
    farCity: ['#9a9db0', '#7b7e93'],
  },
};

const LEVEL_8: LevelPlan = {
  name: 'Level 8',
  // Snowy day, lots of ice.
  roofs: [
    [0, 420, 190],
    [135, 130, 190],
    [245, 180, 215], // clothesline
    [190, 285, 215],
    [245, 155, 215], // clothesline
    [240, 225, 205],
    [190, 145, 215], // too wide to jump: slide on the ice before it
    [135, 180, 200],
    [185, 245, 205],
    [140, 155, 215],
    [140, 165, 215],
    [135, 110, 215],
    [125, 180, 195],
    [195, 260, 200],
    [190, 150, 215], // too wide to jump: slide on the ice before it
    [250, 170, 215],
    [140, 120, 215],
    [140, 165, 215],
    [135, 165, 215],
    [250, 200, 215],
    [140, 150, 215],
    [125, 140, 185],
    [240, 200, 175],
    [140, 110, 215],
    [140, 145, 215],
    [135, 420, 215],
  ],
  props: [
    [5, 90, 30, 40, 'tank'],
    [25, 90, 22, 30, 'chimney'],

    // More to jump over.
    [9, 50, 22, 30, 'chimney'],
    [10, 50, 30, 40, 'tank'],  ],
  ice: [
    [2, 85],
    [6, 80],
    [7, 105],
    [12, 90],
    [17, 80],
    [24, 65],

    [5, 60],
    [13, 60],  ],
  clotheslines: [2, 4, 5, 15, 19, 22],
  spikes: [[3, 50], [13, 50], [22, 50], [15, 52, 50]],
  saws: [[8, 70, 205, 2.6], [19, 70, 160, 2.6]],
  dragon: true,
  flag: 'checkered',
  scenery: {
    sky: ['#b8d8f0', '#e3f1fb', '#ffffff'],
    sun: '#ffffff',
    farCity: ['#b9cbdc', '#98aec4'],
  },
};

const LEVEL_9: LevelPlan = {
  name: 'Level 9',
  // Desert heat.
  roofs: [
    [0, 420, 190],
    [115, 150, 145],
    [245, 115, 125], // clothesline
    [250, 165, 155],
    [245, 100, 125], // clothesline
    [240, 225, 135],
    [130, 135, 115],
    [190, 100, 115], // too wide to jump: slide on the ice before it
    [140, 125, 160],
    [190, 180, 155], // too wide to jump: slide on the ice before it
    [185, 280, 160],
    [140, 160, 160],
    [245, 165, 160],
    [140, 110, 190],
    [125, 100, 180],
    [140, 140, 175],
    [120, 95, 135],
    [245, 210, 145],
    [130, 100, 130],
    [255, 185, 120],
    [130, 180, 115],
    [195, 250, 130],
    [135, 180, 130],
    [195, 250, 145],
    [120, 180, 115],
    [185, 250, 125],
    [135, 165, 115],
    [140, 420, 145],
  ],
  props: [
    [27, 90, 22, 30, 'chimney'],

    // More to jump over.
    [3, 50, 30, 40, 'tank'],
    [19, 50, 30, 40, 'tank'],
    [26, 50, 22, 30, 'chimney'],  ],
  ice: [
    [9, 95],
    [10, 65],
    [20, 90],
    [21, 70],
    [22, 95],
    [24, 85],
    [25, 55],

    [6, 60],
    [8, 60],  ],
  clotheslines: [2, 3, 4, 5, 12, 17, 19],
  spikes: [[5, 55], [17, 55], [12, 55, 50], [25, 55, 120]],
  saws: [[23, 70, 210, 2.4], [10, 70, 175, 2.4], [21, 70, 140, 2.4]],
  dragon: true,
  flag: 'checkered',
  scenery: {
    sky: ['#f2c25b', '#f8dc95', '#fdf2d2'],
    sun: '#fffbe8',
    farCity: ['#d8b06a', '#b98f4f'],
  },
};

const LEVEL_10: LevelPlan = {
  name: 'Level 10',
  // Golden finale: the longest and hardest, with four springs to fly over gaps too wide to jump
  // (two of them up onto tall buildings too),
  // and a staircase at the end down to the giant spider boss.
  roofs: [
    [0, 420, 190],
    [135, 180, 190],
    [190, 240, 195],
    [245, 120, 190], // clothesline
    [240, 225, 210],
    [245, 180, 215], // clothesline
    [205, 275, 215],
    [140, 125, 215],
    [255, 215, 210],
    [135, 200, 185], // spring near the edge
    [200, 120, 185], // too wide to jump: use the spring
    [255, 190, 165],
    [130, 200, 165], // spring near the edge
    [200, 180, 140], // too wide to jump: use the spring
    [195, 230, 160],
    [140, 200, 200], // spring near the edge
    [200, 90, 150], // too wide to jump: use the spring
    [240, 200, 145],
    [125, 105, 115],
    [140, 200, 145], // spring near the edge
    [200, 85, 140], // too wide to jump: use the spring
    [135, 140, 150],
    [140, 160, 180],
    [255, 165, 185],
    [140, 200, 205], // spring near the edge
    [200, 180, 75], // too wide AND too tall to jump: use the spring
    [200, 240, 205], // spring near the edge
    [200, 420, 75], // too wide AND too tall to jump: use the spring
  ],
  props: [
    [4, 80, 30, 40, 'tank'],
    [27, 90, 22, 30, 'chimney'],

    // More to jump over.
    [23, 50, 22, 30, 'chimney'],  ],
  ice: [
    [1, 95],
    [5, 90],
    [13, 105],
    [21, 55],
    [25, 90],
  ],
  clotheslines: [3, 4, 5, 8, 11, 17, 23],
  spikes: [[2, 55], [8, 55], [17, 55]],
  saws: [[6, 70, 235, 2.2], [14, 70, 190, 2.2], [11, 70, 150, 2.2]],
  // Springs near the edge of a roof, to fly over a gap too wide to jump (the last two also up
  // onto a building too tall to jump). There's some roof after each one, so jumping past it
  // lands you on the roof to walk back and try again.
  springs: [[9, 60], [12, 60], [15, 60], [19, 60], [24, 60], [26, 100]],
  dragon: true,
  flag: 'stairs',
  scenery: {
    sky: ['#e8a03c', '#f6c6a0', '#ffe8d6'],
    sun: '#fff6dc',
    farCity: ['#d49a6a', '#b07a55'],
  },
};

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

export 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 };
  });
  const saws = (plan.saws ?? []).map(([index, from, to, period]) => {
    const roof = roofs[index];
    return { x1: roof.x + from, x2: roof.x + to, roofY: roof.y, period };
  });
  const spikes = (plan.spikes ?? []).map(([index, w, offset]) => {
    const roof = roofs[index];
    return { x: offset === undefined ? roof.x + roof.w / 2 - w / 2 : roof.x + offset, w, roofY: roof.y };
  });
  const springs = (plan.springs ?? []).map(([index, offset]) => {
    const roof = roofs[index];
    const next = roofs[index + 1];
    // If there's a gap after this roof, the spring is for flying over it (and up, if the next
    // building is taller), and it throws Rog to a safe spot a little way onto the next roof.
    const overGap = !!next && next.x > roof.x + roof.w;
    return {
      x: roof.x + offset,
      roofY: roof.y,
      overGap,
      landX: next ? next.x + Math.min(45, next.w / 2) : 0,
      landY: next ? next.y : 0,
    };
  });
  for (const spring of springs) {
    blocks.push({ x: spring.x, y: spring.roofY - SPRING_HEIGHT, w: SPRING_WIDTH, h: SPRING_HEIGHT, kind: 'spring' });
  }
  return {
    name: plan.name,
    blocks,
    ice,
    clotheslines,
    saws,
    spikes,
    springs,
    dragon: plan.dragon ?? false,
    startX: 60,
    finishX: nextX - 110,
    end: nextX,
    flag: plan.flag,
    scenery: plan.scenery,
  };
}

export const LEVELS: Level[] = [LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4, LEVEL_5, LEVEL_6, LEVEL_7, LEVEL_8, LEVEL_9, LEVEL_10].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;
}