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 {
LEVELS,
PIT_DEPTH,
SAW_RADIUS,
SPIKE_HEIGHT,
SPRING_HEIGHT,
SPRING_WIDTH,
VIEW_H,
VIEW_W,
lineHeightAt,
roofHeightAt,
sawCenter,
type Block,
type Spring,
type Clothesline,
type Ice,
type Level,
} from './level.js';
import { stickmanShape, type Pose, type Shape } from './stickman.js';
import { BossFight } from './boss.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;
/** The dragon: how far behind Rog it flies, how often it breathes fire, and how fast fireballs go. */
const DRAGON_BEHIND = 130;
const DRAGON_ABOVE = 95;
const FIRE_EVERY = 5;
/** Its mouth glows this long before each fireball, as a warning. */
const FIRE_WARNING = 0.8;
const FIREBALL_SPEED = 330;
export const FIREBALL_RADIUS = 7;
/** How long a fireball's explosion lasts, in seconds. */
export const EXPLOSION_TIME = 1.1;
/** How fast a spring launches Rog upwards: about twice as high as a normal jump. */
const SPRING_SPEED = 850;
/** Holding a direction when you bounce on a spring flings Rog that way this fast, to fly over wide gaps. */
const SPRING_PUSH = 280;
/** A clothesline holds you this long after you first land on it, then it snaps. */
const LINE_HOLD_TIME = 0.5;
export 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.
export 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' | 'stairs' | 'boss';
/** How long the trip down the staircase (ending on the BOSS LEVEL screen) lasts, in seconds. */
export const STAIRS_TIME = 10;
/**
* What happens when Rog loses (falls in a pit, gets chopped, burned...):
* easy = back to the start of the last building he stood on, normal = restart the level,
* hard = back one level.
*/
export type Difficulty = 'easy' | 'normal' | 'hard';
type KnockOut = 'pit' | 'saw' | 'spikes' | 'fire';
export interface Dragon {
x: number;
y: number;
/** Seconds since its last fireball. */
timer: number;
}
/** Where a fireball blew up, and how long ago. `ground` is true if it hit the top of a roof. */
export interface Explosion {
x: number;
y: number;
age: number;
ground: boolean;
}
export interface Fireball {
x: number;
y: number;
vx: number;
vy: number;
age: number;
}
/** Everything the 3D view needs to draw one frame. */
export interface GameView {
level: Level;
levelIndex: number;
x: number;
y: number;
facing: number;
shape: Shape;
pose: Pose;
/** How far through the running step, or through a roll (0 to 1). */
cycle: number;
/** Going up (negative) or down (positive). */
vy: number;
lines: LineState[];
clock: number;
/** Seconds since this level (re)started; the saws move along with it. */
levelTime: number;
/** The spring Rog last bounced on, and when (game clock), so it can boing. */
springBounce: { x: number; at: number } | null;
/** The dragon, if this level has one, and how close it is to breathing fire (0 to 1). */
dragon: (Dragon & { warn: number }) | null;
fireballs: Fireball[];
explosions: Explosion[];
phase: Phase;
}
/** Draws the world (in 3D). The game itself only draws the scores on top. */
export interface WorldView {
render(view: GameView): void;
/** Only the 3D views draw the boss fight themselves; the flat view leaves it to the HUD. */
renderBoss?(fight: BossFight): (x: number, y: number) => { x: number; y: number } | null;
resize(cssWidth: number, cssHeight: number): void;
}
export interface Controls {
left: boolean;
right: boolean;
jump: boolean;
/** Only used in the boss fight. */
punch: boolean;
}
export class Game {
controls: Controls = { left: false, right: false, jump: false, punch: false };
paused = true;
difficulty: Difficulty = 'normal';
/** Rog's parkour roll when he lands. On in 2D, off in first-person 3D (see view-mode.ts). */
rollsEnabled = true;
private cssSize = { w: 1, h: 1 };
private notice: { text: string; until: number } | null = null;
/** Draws the trip down the staircase over the whole screen (see stairs-scene.ts). */
stairsScene: ((ctx: CanvasRenderingContext2D, seconds: number) => void) | null = null;
/** Draws the boss fight over the whole screen (see boss-scene.ts). */
bossScene: ((ctx: CanvasRenderingContext2D, fight: BossFight) => void) | null = null;
/** In the 3D views: just the health bars, messages and damage numbers on top. */
bossHud: ((ctx: CanvasRenderingContext2D, fight: BossFight, project: (x: number, y: number) => { x: number; y: number } | null) => void) | null = null;
/** The spider fight at the end of Level 10, while it's going on. */
boss: BossFight | null = null;
/** 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 hudCtx: 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';
/** What knocked Rog out. */
private fellBy: KnockOut = 'pit';
/** The last roof Rog stood on, so Easy can put him back there. */
private lastRoof: Block | null = null;
private springBounce: { x: number; at: number } | null = null;
/** True while Rog is flying up from a spring. */
private springing = false;
/** After a gap spring: the forward speed it gives, kept until he lands. */
private springPush = 0;
/** Where Rog flops down after being knocked out on a roof (below the screen if he was over a gap). */
private flopY = PIT_DEPTH * 2;
private dragon: Dragon | null = null;
private fireballs: Fireball[] = [];
private explosions: Explosion[] = [];
private phaseTimer = 0;
private time = 0;
private falls = 0;
private clock = 0;
private lastFrame = 0;
private leftover = 0;
constructor(
private hud: HTMLCanvasElement,
private world: WorldView | null,
) {
const ctx = hud.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is not available');
this.hudCtx = 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;
}
/** True while one of the dragon's fireballs is flying through the air. */
get fireballFlying(): boolean {
return this.phase === 'running' && this.fireballs.length > 0;
}
/** True during the spider fight, when the controls work side-on whatever the view. */
get inBoss(): boolean {
return this.phase === 'boss';
}
loadLevel(index: number): void {
this.boss = null;
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 canvases' pixels to their on-screen size so everything stays sharp. */
/** Swap how the world is drawn (2D, first person or third person) while playing. */
setWorld(world: WorldView): void {
this.world = world;
world.resize(this.cssSize.w, this.cssSize.h);
}
/** Show a short message near the top of the screen for a moment. */
showNotice(text: string): void {
this.notice = { text, until: this.clock + 1.6 };
}
resize(cssWidth: number, cssHeight: number): void {
this.cssSize = { w: cssWidth, h: cssHeight };
const ratio = window.devicePixelRatio || 1;
this.hud.width = Math.max(1, Math.round(cssWidth * ratio));
this.hud.height = Math.max(1, Math.round(cssHeight * ratio));
this.scale = this.hud.width / VIEW_W;
this.world?.resize(cssWidth, cssHeight);
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);
}
/** Put Rog on the roof at `x` (the start of the level unless told otherwise), ready to run. */
private respawn(x = this.level.startX): void {
this.springPush = 0;
this.x = x;
this.y = roofHeightAt(this.blocks, x);
this.lastRoof = null;
this.vx = 0;
this.vy = 0;
this.onGround = true;
this.facing = 1;
this.coyote = 0;
this.jumpBuffer = 0;
this.rollTimer = 0;
for (const state of this.lines) {
state.held = null;
state.snappedAt = null;
}
// The dragon starts just off the left of the screen, behind Rog.
this.dragon = this.level.dragon ? { x: this.x - DRAGON_BEHIND - 80, y: this.y - DRAGON_ABOVE, timer: 0 } : null;
this.fireballs = [];
this.explosions = [];
}
private update(dt: number): void {
if (this.phase === 'stairs') {
// Down the staircase and into the ground. Afterwards, on to the next level.
this.phaseTimer += dt;
if (this.phaseTimer >= STAIRS_TIME) {
// At the bottom of the stairs: the boss fight!
this.phase = 'boss';
this.boss = new BossFight();
}
return;
}
if (this.phase === 'boss' && this.boss) {
this.boss.update(dt, this.controls);
if (this.boss.finished) {
if (this.boss.result === 'lost') {
// The spider won: try the fight again.
this.boss = new BossFight();
} else {
this.boss = null;
if (this.isLastLevel) this.onWin(this.time, this.falls);
else this.loadLevel(this.levelIndex + 1);
}
}
return;
}
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.updateExplosions(dt);
this.phaseTimer -= dt;
this.vy = Math.min(this.vy + GRAVITY * dt, MAX_FALL);
this.y += this.vy * dt;
if (this.y > this.flopY) {
this.y = this.flopY;
this.vy = 0;
}
if (this.phaseTimer <= 0) {
if (this.difficulty === 'easy') {
// Back to the start of the last building he stood on. The timer keeps going.
const roof = this.lastRoof;
this.respawn(roof && roof.x > this.level.startX ? roof.x + 20 : this.level.startX);
this.phase = 'running';
} else if (this.difficulty === 'normal' || this.levelIndex === 0) {
this.restart();
} else {
this.loadLevel(this.levelIndex - 1);
}
}
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.
// (Not after a spring, though: there you have to keep holding the key to keep going.)
const keepSpeed = !this.onGround && this.vx > RUN_SPEED && want >= 0 && this.springPush === 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;
// Flying off a gap spring: while you hold the arrow key that way, Rog flies at just the speed
// that lands him on the next roof. Let go and he slows down and drops.
if (this.springPush !== 0 && want === Math.sign(this.springPush)) this.vx = this.springPush;
}
// 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 (but a spring bounce always goes all the way up).
if (this.vy >= 0) this.springing = false;
if (!jump && !this.springing && 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 (this.onGround) {
// Landed: the push from a gap spring is used up.
this.springPush = 0;
this.lastRoof = this.blocks.find((b) => b.kind === 'roof' && this.y === b.y && this.x >= b.x && this.x <= b.x + b.w) ?? this.lastRoof;
}
if (this.onGround) {
// BOING! Only landing right on top of a spring bounces Rog up.
// (Walking into its side just bumps it, and jumping over it just goes over it.)
const spring = this.level.springs.find(
(s: Spring) => this.y === s.roofY - SPRING_HEIGHT && this.x + BODY_W / 2 > s.x && this.x - BODY_W / 2 < s.x + SPRING_WIDTH,
);
if (spring) {
this.vy = -SPRING_SPEED;
if (spring.overGap) {
// Work out how long he'll be in the air, and fly him just fast enough to land on the next roof.
const drop = this.y - spring.landY;
const air = (SPRING_SPEED + Math.sqrt(SPRING_SPEED * SPRING_SPEED - 2 * GRAVITY * drop)) / GRAVITY;
this.springPush = (spring.landX - this.x) / air;
// Sideways only if you're holding the arrow key that way.
if (want === Math.sign(this.springPush)) this.vx = this.springPush;
else if (want === 0) this.vx = 0;
this.facing = Math.sign(this.springPush) || 1;
} else if (want !== 0) {
this.vx = want * Math.max(Math.abs(this.vx), SPRING_PUSH);
}
this.onGround = false;
this.coyote = 0;
this.springBounce = { x: spring.x, at: this.clock };
this.springing = true;
}
}
if (!wasOnGround && this.onGround && landingSpeed > ROLL_LANDING_SPEED && Math.abs(this.vx) > 60 && this.rollsEnabled && 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.knockOut('pit');
return;
}
if (this.hitSaw()) {
// Chopped! Rog gets knocked up into the air and tumbles away.
this.knockOut('saw');
this.vy = -420;
return;
}
this.updateDragon(dt);
if (this.hitByFireball()) {
this.knockOut('fire');
this.vy = -420;
return;
}
if (this.onSpikes()) {
// Ouch! Rog bounces up off the spikes and tumbles away.
this.knockOut('spikes');
this.vy = -420;
return;
}
if (this.x >= this.level.finishX && this.level.flag === 'stairs') {
this.phase = 'stairs';
this.phaseTimer = 0;
this.fireballs = [];
return;
}
if (this.x >= this.level.finishX) {
this.phase = 'won';
this.phaseTimer = LEVEL_PAUSE;
if (this.isLastLevel) this.onWin(this.time, this.falls);
}
}
private knockOut(by: KnockOut): void {
this.phase = 'fell';
this.fellBy = by;
// Knocked out on a roof: he pops up and flops back down onto it, instead of falling through the building.
const roofY = roofHeightAt(this.blocks, this.x);
this.flopY = by !== 'pit' && this.y <= roofY && roofY < VIEW_H ? roofY : PIT_DEPTH * 2;
this.phaseTimer = PIT_PAUSE;
this.falls += 1;
}
/** Does Rog's body touch a saw blade? (A little smaller than it looks, to be fair.) */
private hitSaw(): boolean {
const reach = SAW_RADIUS * 0.8;
return this.level.saws.some((saw) => {
const c = sawCenter(saw, this.time);
const nearX = Math.max(this.x - BODY_W / 2, Math.min(c.x, this.x + BODY_W / 2));
const nearY = Math.max(this.y - BODY_H, Math.min(c.y, this.y));
return Math.hypot(c.x - nearX, c.y - nearY) < reach;
});
}
/** The dragon flies along behind Rog, and every few seconds shoots a fireball straight at him. */
private updateDragon(dt: number): void {
const dragon = this.dragon;
if (dragon) {
// Never further left than the edge of the screen, so you can always see it.
const goalX = Math.max(this.x - DRAGON_BEHIND, 30);
// Stays on screen even when Rog is up on a very tall roof.
const goalY = Math.max(28, Math.min(this.y, VIEW_H) - DRAGON_ABOVE) + Math.sin(this.clock * 3) * 6;
dragon.x += (goalX - dragon.x) * Math.min(1, dt * 2.5);
dragon.y += (goalY - dragon.y) * Math.min(1, dt * 2.5);
const before = dragon.timer;
dragon.timer += dt;
// Too close to Rog (at the very start of the level) to aim properly, or Rog is flying up
// from a spring and can't dodge: it waits before winding up.
if (this.x - dragon.x < 100 || this.springing) dragon.timer = Math.min(dragon.timer, FIRE_EVERY - FIRE_WARNING - 0.01);
if (before < FIRE_EVERY - FIRE_WARNING && dragon.timer >= FIRE_EVERY - FIRE_WARNING) {
this.showNotice('🔥 The dragon is about to breathe fire!');
}
if (dragon.timer >= FIRE_EVERY) {
dragon.timer -= FIRE_EVERY;
const mouth = { x: dragon.x + 40, y: dragon.y + 2 };
// Aim where Rog will be when the fireball catches up, so running straight on doesn't dodge it.
let flight = 0;
for (let i = 0; i < 6; i++) {
flight = Math.hypot(this.x + this.vx * flight - mouth.x, this.y - BODY_H / 2 - mouth.y) / FIREBALL_SPEED;
}
const dx = this.x + this.vx * flight - mouth.x;
const dy = this.y - BODY_H / 2 - mouth.y;
const length = Math.hypot(dx, dy) || 1;
this.fireballs.push({ ...mouth, vx: (dx / length) * FIREBALL_SPEED, vy: (dy / length) * FIREBALL_SPEED, age: 0 });
}
}
// Fireballs fly straight and explode when they hit a roof or a wall. Old ones just fizzle out.
for (const ball of this.fireballs) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
ball.age += dt;
}
this.fireballs = this.fireballs.filter((ball) => {
if (ball.age >= 3) return false;
const block = this.blocks.find((b) => ball.x > b.x && ball.x < b.x + b.w && ball.y > b.y && ball.y < b.y + b.h);
if (!block) return true;
// Came down onto the top: explode on the roof. Otherwise it hit the side of something.
const onTop = ball.y - ball.vy * dt <= block.y + 1;
this.explosions.push({ x: ball.x, y: onTop ? block.y : ball.y, age: 0, ground: onTop });
return false;
});
this.updateExplosions(dt);
}
private updateExplosions(dt: number): void {
for (const blast of this.explosions) blast.age += dt;
this.explosions = this.explosions.filter((blast) => blast.age < EXPLOSION_TIME);
}
private hitByFireball(): boolean {
const hit = this.fireballs.find((ball) => {
const nearX = Math.max(this.x - BODY_W / 2, Math.min(ball.x, this.x + BODY_W / 2));
const nearY = Math.max(this.y - BODY_H, Math.min(ball.y, this.y));
return Math.hypot(ball.x - nearX, ball.y - nearY) < FIREBALL_RADIUS;
});
if (!hit) return false;
// It explodes right on Rog.
this.fireballs = this.fireballs.filter((ball) => ball !== hit);
this.explosions.push({ x: hit.x, y: hit.y, age: 0, ground: false });
return true;
}
/** Are Rog's feet in a spike patch? (The very edges are safe, to be fair.) */
private onSpikes(): boolean {
return this.level.spikes.some(
(patch) =>
this.y > patch.roofY - SPIKE_HEIGHT + 2 &&
this.y <= patch.roofY &&
this.x + BODY_W / 2 > patch.x + 3 &&
this.x - BODY_W / 2 < patch.x + patch.w - 3,
);
}
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 get view(): GameView {
const pose = this.pose();
const cycle = pose === 'roll' ? 1 - this.rollTimer / ROLL_TIME : this.runCycle;
return {
level: this.level,
levelIndex: this.levelIndex,
x: this.x,
y: this.y,
facing: this.facing,
shape: stickmanShape(pose, cycle, this.vy),
pose,
cycle,
vy: this.vy,
lines: this.lines,
clock: this.clock,
levelTime: this.time,
springBounce: this.springBounce,
dragon: this.dragon
? { ...this.dragon, warn: Math.max(0, (this.dragon.timer - (FIRE_EVERY - FIRE_WARNING)) / FIRE_WARNING) }
: null,
fireballs: this.fireballs,
explosions: this.explosions,
phase: this.phase,
};
}
private draw(): void {
const ctx = this.hudCtx;
if (this.phase === 'boss' && this.boss) {
// In first and third person the cave is drawn in 3D; in 2D it's all drawn flat on top.
const project = this.world?.renderBoss?.(this.boss);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.hud.width, this.hud.height);
ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
if (project && this.bossHud) this.bossHud(ctx, this.boss, project);
else this.bossScene?.(ctx, this.boss);
return;
}
this.world?.render(this.view);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, this.hud.width, this.hud.height);
ctx.setTransform(this.scale, 0, 0, this.scale, 0, 0);
if (this.phase === 'stairs' && this.stairsScene) {
// The staircase scene covers the whole screen, whatever the view.
this.stairsScene(ctx, this.phaseTimer);
return;
}
this.drawHud();
}
private drawHud(): void {
const ctx = this.hudCtx;
// 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';
// Under the right end of the bar, so the Menu button never covers it.
ctx.textAlign = 'right';
ctx.fillText(`${this.level.name} ${this.time.toFixed(1)} s`, barX + barW, 38);
if (this.notice && this.clock < this.notice.until) {
ctx.globalAlpha = Math.min(1, (this.notice.until - this.clock) * 3);
ctx.font = 'bold 13px system-ui, sans-serif';
const width = ctx.measureText(this.notice.text).width + 24;
ctx.fillStyle = 'rgba(29, 23, 36, 0.8)';
ctx.fillRect(VIEW_W / 2 - width / 2, 52, width, 24);
ctx.fillStyle = '#ffcc33';
ctx.textAlign = 'center';
ctx.fillText(this.notice.text, VIEW_W / 2, 69);
ctx.globalAlpha = 1;
}
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';
const message = {
pit: 'Rog fell in the pit!',
saw: 'Rog was chopped by a saw!',
spikes: 'Rog fell on spikes!',
fire: 'Rog was hit by a fireball!',
}[this.fellBy];
ctx.fillText(message, VIEW_W / 2, VIEW_H / 2 - 2);
ctx.fillStyle = '#ffffff';
ctx.font = '12px system-ui, sans-serif';
const next =
this.difficulty === 'easy'
? 'Back to the last building...'
: this.difficulty === 'normal' || this.levelIndex === 0
? 'Back to the start...'
: `Back to ${LEVELS[this.levelIndex - 1].name}!`;
ctx.fillText(next, VIEW_W / 2, VIEW_H / 2 + 18);
}
}
}
export { VIEW_W, VIEW_H };
