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.
import {
LEVELS,
PIT_DEPTH,
VIEW_H,
VIEW_W,
lineHeightAt,
roofHeightAt,
type Block,
type Clothesline,
type Ice,
type Level,
} from './level.js';
import { drawStickman, type Pose } from './stickman.js';
// Movement feel, in world units per second.
const RUN_SPEED = 220;
const RUN_ACCEL = 1600;
const AIR_ACCEL = 1100;
const ICE_SPEED = 360; // sliding on ice is faster than running
const ICE_ACCEL = 2400;
const GRAVITY = 1800;
const JUMP_SPEED = 620;
const MAX_FALL = 900;
const COYOTE_TIME = 0.1; // you can still jump just after running off an edge
const JUMP_BUFFER = 0.12; // a jump pressed just before landing still counts
const STEP = 1 / 120;
const BODY_W = 14;
const BODY_H = 36;
/** How long the "into the pit" message stays before going back to the start. */
const PIT_PAUSE = 1.3;
/** How long Rog celebrates at a flag before the next level starts. */
const LEVEL_PAUSE = 2.2;
/** Landing this fast while running gives Rog a chance to do a parkour roll. */
const ROLL_LANDING_SPEED = 450;
const ROLL_CHANCE = 0.25;
const ROLL_TIME = 0.4;
/** A clothesline holds you this long after you first land on it, then it snaps. */
const LINE_HOLD_TIME = 0.5;
interface LineState {
line: Clothesline;
/** Seconds since the stickman first landed on it, or null if nobody has. */
held: number | null;
/** The game clock when it snapped, or null if it is still whole. */
snappedAt: number | null;
}
// Clothes pegged on every line: where along the line, what it is, and its colour.
const CLOTHES: [number, 'shirt' | 'sock' | 'pants', string][] = [
[0.18, 'shirt', '#e5484d'],
[0.34, 'sock', '#ffffff'],
[0.44, 'sock', '#3e7bfa'],
[0.62, 'pants', '#3d63a8'],
[0.82, 'shirt', '#46a758'],
];
export type Phase = 'running' | 'fell' | 'won';
export interface Controls {
left: boolean;
right: boolean;
jump: boolean;
}
export class Game {
controls: Controls = { left: false, right: false, jump: false };
paused = true;
/** Called once when the stickman touches the flag on the last level. */
onWin: (seconds: number, falls: number) => void = () => {};
private levelIndex = 0;
private level: Level = LEVELS[0];
private lines: LineState[] = [];
private ctx: CanvasRenderingContext2D;
private scale = 1;
private x = 0;
private y = 0;
private vx = 0;
private vy = 0;
private onGround = false;
private facing = 1;
private coyote = 0;
private jumpBuffer = 0;
private jumpWasDown = false;
private runCycle = 0;
private rollTimer = 0;
private phase: Phase = 'running';
private phaseTimer = 0;
private cameraX = 0;
private time = 0;
private falls = 0;
private clock = 0;
private lastFrame = 0;
private leftover = 0;
constructor(private canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is not available');
this.ctx = ctx;
this.loadLevel(0);
}
get blocks(): Block[] {
return this.level.blocks;
}
get ice(): Ice[] {
return this.level.ice;
}
get isLastLevel(): boolean {
return this.levelIndex === LEVELS.length - 1;
}
/** Start the whole game again from Level 1. */
restartGame(): void {
this.loadLevel(0);
this.falls = 0;
}
loadLevel(index: number): void {
this.levelIndex = index;
this.level = LEVELS[index];
this.lines = this.level.clotheslines.map((line) => ({ line, held: null, snappedAt: null }));
this.restart();
}
/** Put the stickman back at the start of this level with a fresh timer. */
restart(): void {
this.respawn();
this.time = 0;
this.phase = 'running';
}
/** Match the canvas pixels to its on-screen size so lines stay sharp. */
resize(cssWidth: number, cssHeight: number): void {
const ratio = window.devicePixelRatio || 1;
this.canvas.width = Math.max(1, Math.round(cssWidth * ratio));
this.canvas.height = Math.max(1, Math.round(cssHeight * ratio));
this.scale = this.canvas.width / VIEW_W;
this.draw();
}
start(): void {
const frame = (now: number) => {
const dt = this.lastFrame ? Math.min((now - this.lastFrame) / 1000, 0.1) : 0;
this.lastFrame = now;
this.clock += dt;
if (!this.paused) {
this.leftover += dt;
while (this.leftover >= STEP) {
this.update(STEP);
this.leftover -= STEP;
}
}
this.draw();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
private respawn(): void {
this.x = this.level.startX;
this.y = roofHeightAt(this.blocks, this.level.startX);
this.vx = 0;
this.vy = 0;
this.onGround = true;
this.facing = 1;
this.coyote = 0;
this.jumpBuffer = 0;
this.rollTimer = 0;
this.cameraX = 0;
for (const state of this.lines) {
state.held = null;
state.snappedAt = null;
}
}
private update(dt: number): void {
if (this.phase === 'won') {
// Little celebration: slow to a stop on the finish roof, then on to the next level.
this.vx *= 0.9;
this.runCycle += dt * 4;
this.phaseTimer -= dt;
if (!this.isLastLevel && this.phaseTimer <= 0) this.loadLevel(this.levelIndex + 1);
return;
}
if (this.phase === 'fell') {
this.phaseTimer -= dt;
this.vy = Math.min(this.vy + GRAVITY * dt, MAX_FALL);
this.y += this.vy * dt;
if (this.phaseTimer <= 0) {
// Falling on Level 2 sends Rog all the way back to Level 1.
if (this.levelIndex > 0) this.loadLevel(0);
else this.restart();
}
return;
}
this.time += dt;
// Clotheslines that have been landed on snap after half a second.
for (const state of this.lines) {
if (state.held === null || state.snappedAt !== null) continue;
state.held += dt;
if (state.held >= LINE_HOLD_TIME) state.snappedAt = this.clock;
}
const { left, right, jump } = this.controls;
// Running left and right.
const want = (right ? 1 : 0) - (left ? 1 : 0);
if (this.onIce()) {
// On ice you can't stop or turn: you whoosh to the end of it.
this.vx = Math.min(ICE_SPEED, this.vx + ICE_ACCEL * dt);
this.facing = 1;
} else {
const accel = this.onGround ? RUN_ACCEL : AIR_ACCEL;
const target = want * RUN_SPEED;
// Jumping off the ice keeps its speed, unless you push the other way.
const keepSpeed = !this.onGround && this.vx > RUN_SPEED && want >= 0;
if (this.vx < target) this.vx = Math.min(target, this.vx + accel * dt);
else if (this.vx > target && !keepSpeed) this.vx = Math.max(target, this.vx - accel * dt);
if (want !== 0) this.facing = want;
}
// Jumping, with a little forgiveness at roof edges.
if (jump && !this.jumpWasDown) this.jumpBuffer = JUMP_BUFFER;
this.jumpWasDown = jump;
this.coyote = this.onGround ? COYOTE_TIME : Math.max(0, this.coyote - dt);
this.jumpBuffer = Math.max(0, this.jumpBuffer - dt);
if (this.jumpBuffer > 0 && this.coyote > 0) {
this.vy = -JUMP_SPEED;
this.jumpBuffer = 0;
this.coyote = 0;
this.onGround = false;
}
// Letting go of jump early makes a smaller hop.
if (!jump && this.vy < -JUMP_SPEED * 0.45) this.vy = -JUMP_SPEED * 0.45;
this.vy = Math.min(this.vy + GRAVITY * dt, MAX_FALL);
const wasOnGround = this.onGround;
const landingSpeed = this.vy;
this.moveX(this.vx * dt);
this.moveY(this.vy * dt);
this.rollTimer = this.onGround ? Math.max(0, this.rollTimer - dt) : 0;
if (!wasOnGround && this.onGround && landingSpeed > ROLL_LANDING_SPEED && Math.abs(this.vx) > 60 && Math.random() < ROLL_CHANCE) {
this.rollTimer = ROLL_TIME;
}
this.x = Math.max(BODY_W / 2, Math.min(this.level.end - BODY_W / 2, this.x));
if (this.onGround && Math.abs(this.vx) > 10) this.runCycle += dt * Math.abs(this.vx) * 0.055;
if (this.y > PIT_DEPTH) {
this.phase = 'fell';
this.phaseTimer = PIT_PAUSE;
this.falls += 1;
return;
}
if (this.x >= this.level.finishX) {
this.phase = 'won';
this.phaseTimer = LEVEL_PAUSE;
if (this.isLastLevel) this.onWin(this.time, this.falls);
}
const goal = Math.max(0, Math.min(this.level.end - VIEW_W, this.x - VIEW_W * 0.33));
this.cameraX += (goal - this.cameraX) * Math.min(1, dt * 10);
}
private onIce(): boolean {
return this.onGround && this.ice.some((patch) => this.y === patch.y && this.x >= patch.x && this.x <= patch.x + patch.w);
}
/** The stickman's box: x is the middle of his feet, y is where his feet are. */
private overlaps(block: Block, x: number, y: number): boolean {
return (
x + BODY_W / 2 > block.x &&
x - BODY_W / 2 < block.x + block.w &&
y > block.y &&
y - BODY_H < block.y + block.h
);
}
private moveX(dx: number): void {
this.x += dx;
for (const block of this.blocks) {
if (!this.overlaps(block, this.x, this.y)) continue;
if (dx > 0) this.x = block.x - BODY_W / 2;
else if (dx < 0) this.x = block.x + block.w + BODY_W / 2;
this.vx = 0;
}
}
private moveY(dy: number): void {
this.y += dy;
this.onGround = false;
for (const block of this.blocks) {
if (!this.overlaps(block, this.x, this.y)) continue;
if (dy > 0) {
this.y = block.y;
this.onGround = true;
} else if (dy < 0) {
this.y = block.y + block.h + BODY_H;
}
this.vy = 0;
}
if (this.onGround || dy <= 0) return;
// Land on a clothesline from above (you can jump up through it from below).
for (const state of this.lines) {
const { line } = state;
if (state.snappedAt !== null || this.x < line.x1 + 2 || this.x > line.x2 - 2) continue;
const ropeY = lineHeightAt(line, this.x);
if (this.y >= ropeY && this.y - dy <= ropeY + 1.5) {
this.y = ropeY;
this.vy = 0;
this.onGround = true;
if (state.held === null) state.held = 0;
}
}
}
private pose(): Pose {
if (this.phase === 'won') return 'cheer';
if (this.phase === 'fell') return 'fall';
if (!this.onGround) return 'jump';
if (this.rollTimer > 0) return 'roll';
if (this.onIce()) return 'slide';
if (Math.abs(this.vx) > 10) return 'run';
return 'stand';
}
// ---------- drawing ----------
private draw(): void {
const ctx = this.ctx;
ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
this.drawSky();
this.drawFarCity();
ctx.save();
ctx.translate(-Math.round(this.cameraX * 4) / 4, 0);
this.drawPitFog();
for (const block of this.blocks) this.drawBlock(block);
for (const patch of this.ice) this.drawIce(patch);
for (const state of this.lines) this.drawClothesline(state);
this.drawFinish();
const pose = this.pose();
const cycle = pose === 'roll' ? 1 - this.rollTimer / ROLL_TIME : this.runCycle;
drawStickman(ctx, this.x, this.y, this.facing, pose, cycle, this.vy);
ctx.restore();
this.drawHud();
}
private drawSky(): void {
const ctx = this.ctx;
const sky = ctx.createLinearGradient(0, 0, 0, VIEW_H);
const { scenery } = this.level;
sky.addColorStop(0, scenery.sky[0]);
sky.addColorStop(0.55, scenery.sky[1]);
sky.addColorStop(1, scenery.sky[2]);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
ctx.fillStyle = scenery.sun;
ctx.beginPath();
ctx.arc(360 - this.cameraX * 0.05, 92, 30, 0, Math.PI * 2);
ctx.fill();
}
/** Faraway buildings that slide slowly, so the city feels deep. */
private drawFarCity(): void {
const ctx = this.ctx;
const layers = [
{ speed: 0.2, color: this.level.scenery.farCity[0], base: 150, spread: 60, width: 46 },
{ speed: 0.4, color: this.level.scenery.farCity[1], base: 175, spread: 50, width: 58 },
];
for (const layer of layers) {
ctx.fillStyle = layer.color;
const offset = this.cameraX * layer.speed;
const first = Math.floor(offset / layer.width) - 1;
for (let i = first; i < first + VIEW_W / layer.width + 3; i++) {
const top = layer.base - noise(i * 7.3 + layer.speed * 100 + this.levelIndex * 31) * layer.spread;
ctx.fillRect(i * layer.width - offset, top, layer.width - 4, VIEW_H - top);
}
}
}
/** Darkness at the bottom of the gaps, so the pits look deep and scary. */
private drawPitFog(): void {
const ctx = this.ctx;
const fog = ctx.createLinearGradient(0, VIEW_H - 130, 0, VIEW_H);
fog.addColorStop(0, 'rgba(29, 23, 36, 0)');
fog.addColorStop(0.6, 'rgba(29, 23, 36, 0.85)');
fog.addColorStop(1, 'rgba(10, 6, 12, 1)');
ctx.fillStyle = fog;
ctx.fillRect(this.cameraX - 10, VIEW_H - 130, VIEW_W + 20, 130);
}
private drawBlock(block: Block): void {
const ctx = this.ctx;
if (block.x > this.cameraX + VIEW_W || block.x + block.w < this.cameraX) return;
if (block.kind === 'roof') {
ctx.fillStyle = '#4a3f55';
ctx.fillRect(block.x, block.y, block.w, block.h);
// Windows, some lit and some dark.
for (let wy = block.y + 22; wy < VIEW_H; wy += 26) {
for (let wx = block.x + 14; wx < block.x + block.w - 18; wx += 26) {
ctx.fillStyle = noise(wx * 0.37 + wy * 1.7) > 0.55 ? '#ffd27a' : '#3a3044';
ctx.fillRect(wx, wy, 10, 13);
}
}
// Roof ledge.
ctx.fillStyle = '#6e6280';
ctx.fillRect(block.x - 3, block.y, block.w + 6, 6);
// Warning stripes at the edges of each pit.
this.drawEdgeStripe(block.x - 3, block.y);
this.drawEdgeStripe(block.x + block.w - 9, block.y);
} else if (block.kind === 'chimney') {
ctx.fillStyle = '#8a4b3c';
ctx.fillRect(block.x, block.y, block.w, block.h);
ctx.fillStyle = '#6b3a2f';
ctx.fillRect(block.x - 2, block.y, block.w + 4, 5);
} else if (block.kind === 'vent') {
ctx.fillStyle = '#8f9aa6';
ctx.fillRect(block.x, block.y, block.w, block.h);
ctx.fillStyle = '#6d7782';
for (let i = 4; i < block.h - 2; i += 5) ctx.fillRect(block.x + 4, block.y + i, block.w - 8, 2);
} else {
ctx.fillStyle = '#7b5a3f';
ctx.fillRect(block.x, block.y, block.w, block.h);
ctx.fillStyle = '#5e432e';
ctx.fillRect(block.x, block.y + block.h * 0.33, block.w, 3);
ctx.fillRect(block.x, block.y + block.h * 0.66, block.w, 3);
}
}
private drawIce(patch: Ice): void {
const ctx = this.ctx;
if (patch.x > this.cameraX + VIEW_W || patch.x + patch.w + 10 < this.cameraX) return;
const left = patch.x;
const right = patch.x + patch.w + 3;
const top = patch.y - 3;
const bottom = patch.y + 6;
// Icicles hanging under the roof ledge, longer near the edge where the water dripped.
for (let ix = left + 4; ix < right - 2; ix += 3 + noise(ix * 0.37) * 6) {
const nearEdge = (ix - left) / (right - left);
const length = 3 + noise(ix * 0.71) * 9 * (0.4 + nearEdge);
const icicle = ctx.createLinearGradient(0, bottom, 0, bottom + length);
icicle.addColorStop(0, 'rgba(225, 245, 255, 0.95)');
icicle.addColorStop(1, 'rgba(160, 215, 245, 0.35)');
ctx.fillStyle = icicle;
ctx.beginPath();
ctx.moveTo(ix - 2, bottom);
ctx.lineTo(ix + 2, bottom);
ctx.lineTo(ix + 0.3, bottom + length);
ctx.closePath();
ctx.fill();
}
// The ice slab: clear and pale on top, deeper blue underneath, with the roof showing through.
const slab = ctx.createLinearGradient(0, top, 0, bottom);
slab.addColorStop(0, 'rgba(240, 252, 255, 0.95)');
slab.addColorStop(0.35, 'rgba(180, 228, 250, 0.8)');
slab.addColorStop(1, 'rgba(95, 160, 210, 0.75)');
ctx.fillStyle = slab;
ctx.beginPath();
// Lumpy left end where the ice starts, like a frozen puddle.
ctx.moveTo(left + 6, top);
ctx.lineTo(right, top);
ctx.lineTo(right, bottom);
ctx.lineTo(left, bottom);
ctx.quadraticCurveTo(left - 3, top + 3, left + 6, top);
ctx.fill();
// Bright edge along the top where light hits it.
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.fillRect(left + 5, top, patch.w - 2, 1);
// Cracks frozen inside the ice.
ctx.strokeStyle = 'rgba(70, 130, 180, 0.55)';
ctx.lineWidth = 0.6;
for (let cx = left + 12; cx < right - 10; cx += 23) {
const wiggle = noise(cx * 1.3) * 6;
ctx.beginPath();
ctx.moveTo(cx, top + 1);
ctx.lineTo(cx + 3 + wiggle * 0.5, top + 4);
ctx.lineTo(cx + 1 + wiggle, bottom - 1);
ctx.moveTo(cx + 3 + wiggle * 0.5, top + 4);
ctx.lineTo(cx + 8, top + 5);
ctx.stroke();
}
// A glare of light that slowly slides across the ice.
const glareX = left + ((this.clock * 25) % (patch.w + 40)) - 20;
ctx.save();
ctx.beginPath();
ctx.rect(left, top, right - left, bottom - top);
ctx.clip();
ctx.fillStyle = 'rgba(255, 255, 255, 0.55)';
ctx.beginPath();
ctx.moveTo(glareX, bottom);
ctx.lineTo(glareX + 6, top);
ctx.lineTo(glareX + 10, top);
ctx.lineTo(glareX + 4, bottom);
ctx.fill();
ctx.restore();
// Little star sparkles that twinkle on and off.
ctx.fillStyle = '#ffffff';
for (let sx = left + 9; sx < right - 4; sx += 17) {
const twinkle = Math.sin(this.clock * 2.5 + noise(sx) * 10);
if (twinkle < 0.6) continue;
const size = (twinkle - 0.6) * 6;
const sy = top + 1 + noise(sx * 2.1) * 3;
ctx.fillRect(sx - size, sy - 0.4, size * 2, 0.8);
ctx.fillRect(sx - 0.4, sy - size, 0.8, size * 2);
}
this.drawEdgeStripe(patch.x + patch.w - 9, patch.y);
}
private drawClothesline(state: LineState): void {
const ctx = this.ctx;
const { line } = state;
if (line.x1 > this.cameraX + VIEW_W || line.x2 < this.cameraX) return;
// Metal hooks on the walls.
ctx.fillStyle = '#9aa3ad';
ctx.fillRect(line.x1 - 4, line.y1 - 2, 5, 4);
ctx.fillRect(line.x2 - 1, line.y2 - 2, 5, 4);
ctx.strokeStyle = '#f2ede4';
ctx.lineWidth = 1.4;
ctx.lineCap = 'round';
if (state.snappedAt === null) {
// Once someone is on it, it shakes like it's about to give.
const shake = state.held === null ? 0 : Math.sin(this.clock * 70) * (0.4 + state.held * 2);
const pointAt = (t: number) => {
const x = line.x1 + (line.x2 - line.x1) * t;
return { x, y: lineHeightAt(line, x) + shake * Math.sin(Math.PI * t) };
};
ctx.beginPath();
for (let i = 0; i <= 24; i++) {
const p = pointAt(i / 24);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
for (const [t, kind, color] of CLOTHES) {
const p = pointAt(t);
this.drawClothing(kind, color, p.x, p.y);
}
return;
}
// Snapped: each half swings down and hangs from its hook, and the clothes fall.
const age = this.clock - state.snappedAt;
const drop = Math.min(1, age * 4);
const swing = Math.sin(age * 6) * Math.exp(-age * 1.5) * 8;
const halfLength = Math.min(70, (line.x2 - line.x1) / 2);
const halves: [number, number, number][] = [
[line.x1, line.y1, 1],
[line.x2, line.y2, -1],
];
for (const [hx, hy, dir] of halves) {
const endX = hx + dir * halfLength * (1 - drop) + swing * dir;
const endY = hy + halfLength * drop;
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.quadraticCurveTo(hx + dir * 6, (hy + endY) / 2, endX, endY);
ctx.stroke();
}
const fall = 0.5 * 900 * age * age;
for (const [t, kind, color] of CLOTHES) {
const x = line.x1 + (line.x2 - line.x1) * t + Math.sin(age * 5 + t * 9) * 6 * age;
const y = lineHeightAt(line, x) + fall;
if (y < VIEW_H + 20) this.drawClothing(kind, color, x, y);
}
}
/** A piece of laundry hanging with its top at (x, y). */
private drawClothing(kind: 'shirt' | 'sock' | 'pants', color: string, x: number, y: number): void {
const ctx = this.ctx;
ctx.fillStyle = color;
ctx.strokeStyle = 'rgba(29, 23, 36, 0.5)';
ctx.lineWidth = 0.8;
ctx.beginPath();
if (kind === 'shirt') {
ctx.moveTo(x - 6, y);
ctx.lineTo(x + 6, y);
ctx.lineTo(x + 10, y + 5);
ctx.lineTo(x + 6, y + 6);
ctx.lineTo(x + 6, y + 15);
ctx.lineTo(x - 6, y + 15);
ctx.lineTo(x - 6, y + 6);
ctx.lineTo(x - 10, y + 5);
} else if (kind === 'pants') {
ctx.moveTo(x - 6, y);
ctx.lineTo(x + 6, y);
ctx.lineTo(x + 7, y + 17);
ctx.lineTo(x + 2, y + 17);
ctx.lineTo(x, y + 6);
ctx.lineTo(x - 2, y + 17);
ctx.lineTo(x - 7, y + 17);
} else {
ctx.moveTo(x - 2, y);
ctx.lineTo(x + 2, y);
ctx.lineTo(x + 2, y + 8);
ctx.lineTo(x + 5, y + 10);
ctx.lineTo(x + 4, y + 12);
ctx.lineTo(x - 2, y + 11);
}
ctx.closePath();
ctx.fill();
ctx.stroke();
// Clothes peg.
ctx.fillStyle = '#c98b4a';
ctx.fillRect(x - 0.8, y - 2.5, 1.6, 4);
}
private drawEdgeStripe(x: number, y: number): void {
const ctx = this.ctx;
ctx.fillStyle = '#ffcc33';
ctx.fillRect(x, y, 12, 6);
ctx.fillStyle = '#2b2233';
ctx.fillRect(x + 4, y, 4, 6);
}
private drawFinish(): void {
const ctx = this.ctx;
const flagX = this.level.finishX;
const ground = roofHeightAt(this.blocks, flagX);
const top = ground - 70;
const wave = Math.sin(this.clock * 6) * 2;
if (this.level.flag === 'checkered') {
ctx.fillStyle = '#2b2233';
ctx.fillRect(flagX - 2, top, 4, 70);
// Checkered flag that waves a little.
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 5; col++) {
ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#1d1724';
ctx.fillRect(flagX + 2 + col * 6, top + row * 6 + (col / 4) * wave, 6, 6);
}
}
} else {
// Level 2's flag: a big gold flag with a star on a shiny pole.
ctx.fillStyle = '#f2ede4';
ctx.fillRect(flagX - 2, top - 6, 4, 76);
ctx.fillStyle = '#ffcc33';
ctx.beginPath();
ctx.arc(flagX, top - 7, 3.5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffb000';
ctx.strokeStyle = '#a86a00';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(flagX + 2, top);
ctx.quadraticCurveTo(flagX + 18, top - 3 + wave, flagX + 36, top + 2 + wave);
ctx.lineTo(flagX + 36, top + 22 + wave);
ctx.quadraticCurveTo(flagX + 18, top + 17 + wave, flagX + 2, top + 22);
ctx.closePath();
ctx.fill();
ctx.stroke();
this.drawStar(flagX + 19, top + 11 + wave * 0.6, 6);
// Sparkles around it.
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 3; i++) {
const twinkle = Math.sin(this.clock * 4 + i * 2.1);
if (twinkle < 0.3) continue;
const sx = flagX + 8 + i * 14;
const sy = top - 8 + (i % 2) * 34;
const size = twinkle * 2.5;
ctx.fillRect(sx - size, sy - 0.4, size * 2, 0.8);
ctx.fillRect(sx - 0.4, sy - size, 0.8, size * 2);
}
}
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 10px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(this.level.name.toUpperCase(), flagX, top - 14);
}
private drawStar(x: number, y: number, radius: number): void {
const ctx = this.ctx;
ctx.fillStyle = '#fff6d6';
ctx.beginPath();
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? radius : radius * 0.45;
const angle = -Math.PI / 2 + (i * Math.PI) / 5;
ctx.lineTo(x + Math.cos(angle) * r, y + Math.sin(angle) * r);
}
ctx.closePath();
ctx.fill();
}
private drawHud(): void {
const ctx = this.ctx;
// Progress bar along the top so you can see how far the finish is.
const barX = 150;
const barW = VIEW_W - 170;
const { startX, finishX } = this.level;
const progress = Math.max(0, Math.min(1, (this.x - startX) / (finishX - startX)));
ctx.fillStyle = 'rgba(43, 34, 51, 0.35)';
ctx.fillRect(barX, 14, barW, 6);
ctx.fillStyle = '#2b2233';
ctx.fillRect(barX, 14, barW * progress, 6);
ctx.fillRect(barX + barW - 2, 9, 3, 16);
ctx.fillStyle = '#2b2233';
ctx.font = 'bold 12px system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`${this.level.name} ${this.time.toFixed(1)} s`, barX - 10, 22);
if (this.phase === 'won' && !this.isLastLevel) {
ctx.fillStyle = 'rgba(29, 23, 36, 0.75)';
ctx.fillRect(0, VIEW_H / 2 - 30, VIEW_W, 60);
ctx.fillStyle = '#ffcc33';
ctx.textAlign = 'center';
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText(`${this.level.name} complete!`, VIEW_W / 2, VIEW_H / 2 - 2);
ctx.fillStyle = '#ffffff';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText(`Get ready for ${LEVELS[this.levelIndex + 1].name}...`, VIEW_W / 2, VIEW_H / 2 + 18);
}
if (this.phase === 'fell') {
ctx.fillStyle = 'rgba(29, 23, 36, 0.75)';
ctx.fillRect(0, VIEW_H / 2 - 30, VIEW_W, 60);
ctx.fillStyle = '#ffcc33';
ctx.textAlign = 'center';
ctx.font = 'bold 22px system-ui, sans-serif';
ctx.fillText('Rog fell in the pit!', VIEW_W / 2, VIEW_H / 2 - 2);
ctx.fillStyle = '#ffffff';
ctx.font = '12px system-ui, sans-serif';
ctx.fillText(this.levelIndex > 0 ? 'All the way back to Level 1!' : 'Back to the start...', VIEW_W / 2, VIEW_H / 2 + 18);
}
}
}
/** A repeatable "random" number from 0 to 1, so the city looks the same every time. */
function noise(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453;
return value - Math.floor(value);
}
export { VIEW_W, VIEW_H };
