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.
import { STAIRS_TIME, VIEW_H, VIEW_W } from './game.js';
import { drawStickman } from './render2d.js';
/**
* The end of Level 10: the building is cut open and Rog walks down a zig-zag
* staircase, floor after floor, past the street and down into the dirt and rock.
* Then it goes dark and BOSS LEVEL appears. Drawn over the whole screen, in any view.
*/
const FLOOR_H = 70;
const BUILDING_FLOORS = 4;
const UNDERGROUND_FLIGHTS = 3;
const FLIGHTS = BUILDING_FLOORS + UNDERGROUND_FLIGHTS;
const GROUND_Y = BUILDING_FLOORS * FLOOR_H;
/** Walking across the roof to the top of the stairs. */
const WALK_IN = 0.8;
const FLIGHT_TIME = 0.75;
const STAIRS_DONE = WALK_IN + FLIGHTS * FLIGHT_TIME;
const DARK_START = STAIRS_DONE + 0.1;
const TITLE_START = STAIRS_DONE + 0.7;
const LEFT = 150;
const RIGHT = 330;
const STEPS = 8;
/** Where Rog's feet are (in scene units, 0 = roof level) and which way he faces. */
function rogAt(t: number): { x: number; y: number; facing: number } {
if (t < WALK_IN) return { x: 40 + ((LEFT - 40) * t) / WALK_IN, y: 0, facing: 1 };
const flight = Math.min(FLIGHTS - 1, Math.floor((t - WALK_IN) / FLIGHT_TIME));
const along = Math.min(1, (t - WALK_IN - flight * FLIGHT_TIME) / FLIGHT_TIME);
const goingRight = flight % 2 === 0;
const from = goingRight ? LEFT : RIGHT;
const to = goingRight ? RIGHT : LEFT;
// Step by step down the stairs.
const step = Math.min(STEPS, Math.floor(along * STEPS + 0.5));
return { x: from + (to - from) * along, y: flight * FLOOR_H + (step / STEPS) * FLOOR_H, facing: goingRight ? 1 : -1 };
}
function noise(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
}
export function drawStairsScene(ctx: CanvasRenderingContext2D, t: number): void {
const rog = rogAt(Math.min(t, STAIRS_DONE));
// The camera follows Rog down, keeping him a little above the middle of the screen.
const camY = Math.max(-150, rog.y - 150);
ctx.save();
ctx.fillStyle = '#1d1724';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
ctx.translate(0, -camY);
// Sky above the roof.
const sky = ctx.createLinearGradient(0, -200, 0, 0);
// Level 10's golden evening sky.
sky.addColorStop(0, '#e8a03c');
sky.addColorStop(1, '#ffe8d6');
ctx.fillStyle = sky;
ctx.fillRect(0, -200, VIEW_W, 200);
// Inside the building: a dark stairwell between two walls with windows.
ctx.fillStyle = '#2f2838';
ctx.fillRect(0, 0, VIEW_W, GROUND_Y);
for (let floor = 0; floor < BUILDING_FLOORS; floor++) {
const top = floor * FLOOR_H;
for (const wx of [30, 420]) {
const lit = noise(floor * 3 + wx) > 0.4;
ctx.fillStyle = lit ? '#ffd27a' : '#1d1724';
ctx.fillRect(wx, top + 18, 30, 34);
ctx.strokeStyle = '#4a3f55';
ctx.lineWidth = 3;
ctx.strokeRect(wx, top + 18, 30, 34);
}
// A little ceiling lamp on each floor.
ctx.fillStyle = '#ffe9a8';
ctx.beginPath();
ctx.arc(VIEW_W / 2, top + 10, 4, 0, Math.PI * 2);
ctx.fill();
const glow = ctx.createRadialGradient(VIEW_W / 2, top + 10, 0, VIEW_W / 2, top + 10, 90);
glow.addColorStop(0, 'rgba(255, 233, 168, 0.18)');
glow.addColorStop(1, 'rgba(255, 233, 168, 0)');
ctx.fillStyle = glow;
ctx.fillRect(VIEW_W / 2 - 90, top, 180, FLOOR_H);
}
// Outer walls.
ctx.fillStyle = '#4a3f55';
ctx.fillRect(0, 0, 14, GROUND_Y);
ctx.fillRect(VIEW_W - 14, 0, 14, GROUND_Y);
// Under the street: dirt that gets darker the deeper it goes, with rocks, roots and an old bone.
const dirtBottom = GROUND_Y + UNDERGROUND_FLIGHTS * FLOOR_H + 400;
const dirt = ctx.createLinearGradient(0, GROUND_Y, 0, dirtBottom);
dirt.addColorStop(0, '#6b4a2f');
dirt.addColorStop(0.4, '#4a3220');
dirt.addColorStop(1, '#1a120c');
ctx.fillStyle = dirt;
ctx.fillRect(0, GROUND_Y, VIEW_W, dirtBottom - GROUND_Y);
for (let i = 0; i < 40; i++) {
const rx = noise(i * 7.1) * VIEW_W;
const ry = GROUND_Y + 20 + noise(i * 3.3) * (dirtBottom - GROUND_Y - 40);
ctx.fillStyle = noise(i) > 0.5 ? '#7d6a5a' : '#5b4a3e';
ctx.beginPath();
ctx.ellipse(rx, ry, 5 + noise(i * 2) * 9, 3 + noise(i * 5) * 5, noise(i * 9), 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = '#3b2a1c';
ctx.lineWidth = 2;
for (let i = 0; i < 6; i++) {
const rx = 20 + noise(i * 11) * (VIEW_W - 40);
ctx.beginPath();
ctx.moveTo(rx, GROUND_Y + 12);
ctx.quadraticCurveTo(rx + 20, GROUND_Y + 40, rx + (noise(i) - 0.5) * 50, GROUND_Y + 70 + noise(i * 4) * 40);
ctx.stroke();
}
// An old bone in the dirt.
ctx.fillStyle = '#e8e0cf';
const bx = 70;
const by = GROUND_Y + 150;
ctx.fillRect(bx, by - 2, 28, 4);
for (const [cx, cy] of [[bx, by - 3], [bx, by + 3], [bx + 28, by - 3], [bx + 28, by + 3]]) {
ctx.beginPath();
ctx.arc(cx, cy, 3.5, 0, Math.PI * 2);
ctx.fill();
}
// The street and pavement at ground level.
ctx.fillStyle = '#3a3044';
ctx.fillRect(0, GROUND_Y - 4, VIEW_W, 10);
ctx.fillStyle = '#ffcc33';
for (let x = 10; x < VIEW_W; x += 40) ctx.fillRect(x, GROUND_Y, 18, 2);
// The roof and its stairwell opening.
ctx.fillStyle = '#6e6280';
ctx.fillRect(0, -4, LEFT - 10, 8);
ctx.fillRect(LEFT + 30, -4, VIEW_W - LEFT - 30, 8);
// Floors between the flights (with the gap the stairs go through).
for (let floor = 1; floor <= FLIGHTS; floor++) {
const y = floor * FLOOR_H;
const underground = y > GROUND_Y;
const gapAtRight = floor % 2 === 1;
ctx.fillStyle = underground ? '#3b2a1c' : '#6e6280';
if (floor !== BUILDING_FLOORS) {
if (gapAtRight) ctx.fillRect(14, y - 3, RIGHT - 24, 6);
else ctx.fillRect(LEFT + 30, y - 3, VIEW_W - LEFT - 44, 6);
}
}
// The zig-zag staircase: steps of grey concrete in the building, rough stone underground.
for (let flight = 0; flight < FLIGHTS; flight++) {
const top = flight * FLOOR_H;
const underground = top >= GROUND_Y;
const goingRight = flight % 2 === 0;
const stepW = (RIGHT - LEFT) / STEPS;
ctx.fillStyle = underground ? '#7a6656' : '#8a7a90';
ctx.beginPath();
ctx.moveTo(goingRight ? LEFT : RIGHT, top);
for (let s = 0; s < STEPS; s++) {
const x0 = goingRight ? LEFT + s * stepW : RIGHT - s * stepW;
const x1 = goingRight ? x0 + stepW : x0 - stepW;
const y1 = top + ((s + 1) / STEPS) * FLOOR_H;
ctx.lineTo(x1, top + (s / STEPS) * FLOOR_H);
ctx.lineTo(x1, y1);
}
ctx.lineTo(goingRight ? RIGHT : LEFT, top + FLOOR_H + 8);
ctx.lineTo(goingRight ? LEFT : RIGHT, top + 8);
ctx.closePath();
ctx.fill();
// Handrail.
ctx.strokeStyle = underground ? '#4a3a2e' : '#c9d1d9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(goingRight ? LEFT : RIGHT, top - 22);
ctx.lineTo(goingRight ? RIGHT : LEFT, top + FLOOR_H - 22);
ctx.stroke();
}
// Rog, walking down.
drawStickman(ctx, rog.x, rog.y, rog.facing, t < STAIRS_DONE ? 'run' : 'stand', t * 14, 0);
ctx.restore();
// "down... down... down..." as he goes.
const flight = Math.floor((t - WALK_IN) / FLIGHT_TIME);
if (t > WALK_IN && t < STAIRS_DONE) {
const words = Math.min(3, Math.floor(flight / 2) + 1);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px system-ui, sans-serif';
ctx.textAlign = 'left';
ctx.lineWidth = 4;
ctx.strokeStyle = '#1d1724';
const text = Array.from({ length: words }, () => 'down...').join(' ');
ctx.strokeText(text, 20, 34);
ctx.fillText(text, 20, 34);
}
// Everything goes dark, then: BOSS LEVEL.
if (t > DARK_START) {
ctx.fillStyle = `rgba(10, 6, 12, ${Math.min(1, (t - DARK_START) / 0.6)})`;
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
}
if (t > TITLE_START) {
const since = t - TITLE_START;
const appear = Math.min(1, since / 0.4);
const shake = since < 1 ? (1 - since) * 4 : 0.6;
const dx = (noise(Math.floor(t * 30)) - 0.5) * shake * 2;
const dy = (noise(Math.floor(t * 30) + 9) - 0.5) * shake * 2;
const fade = Math.min(1, (STAIRS_TIME - t) / 0.5);
ctx.save();
ctx.globalAlpha = appear * fade;
ctx.textAlign = 'center';
ctx.font = `900 ${Math.round(40 + (1 - appear) * 30)}px system-ui, sans-serif`;
ctx.lineWidth = 6;
ctx.strokeStyle = '#3a0a10';
ctx.strokeText('BOSS LEVEL', VIEW_W / 2 + dx, VIEW_H / 2 + dy);
ctx.fillStyle = '#e5484d';
ctx.fillText('BOSS LEVEL', VIEW_W / 2 + dx, VIEW_H / 2 + dy);
if (since > 0.8) {
ctx.globalAlpha = Math.min(1, (since - 0.8) / 0.5) * fade;
ctx.font = 'italic 14px system-ui, sans-serif';
ctx.fillStyle = '#f2ede4';
ctx.fillText("It's waiting...", VIEW_W / 2, VIEW_H / 2 + 34);
}
ctx.restore();
}
}
