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