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/stairs-scene.ts
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();
  }
}