SOURCE / PINNED RELEASE
Made of little things.
super-platformer
- Release
- b61593abc2c6…
- Author-recorded commit
- bbd35ca510f6…
- License
- LICENSE
- Author’s source reference
- nostr://npub16uawymqtmdg4j6zcl6snt5apzfp4ek2g8lpasnqa75tspdpglcqsgpatvc/wss%3A%2F%2Fgit.napplet.soy%2F/n-01288b1ab38
Archive hash verified: d2529eb9ad1961f2…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* A tiny tile-based platformer engine.
*
* World units are pixels; one tile is TILE px. Levels are authored as ASCII
* rows:
* # solid ground = solid platform
* o coin ^ spike
* P spawn G goal
* ? power-up crate c checkpoint
* w walker enemy j jumper enemy
* f flyer enemy b brute enemy (two stomps)
* B boss (many stomps)
* Moving platforms and saw hazards are declared per level with `movers` and
* `hazards`.
*
* Stomp an enemy from above to defeat it and collect a coin. Touching one from
* the side or below is fatal. Break crates to gain a temporary power-up, touch
* checkpoints to set your respawn, and defeat the boss to clear the last level.
* The renderer scales the world to fill the frame and follows the player.
*/
import { sfx } from './audio.js';
export const TILE = 24;
const VIEW_ROWS = 9;
const MIN_VIEW_TILES = 14;
const FIXED_STEP = 1 / 120;
const PLAYER_W = 15;
const PLAYER_H = 21;
const GRAVITY = 1250;
const MAX_FALL = 900;
const MAX_RUN = 145;
const RUN_ACCEL = 1300;
const RUN_DECEL = 1700;
const AIR_ACCEL = 1000;
const JUMP_VELOCITY = 520;
const JUMP_CUT = 200;
const COYOTE_TIME = 0.1;
const JUMP_BUFFER_TIME = 0.12;
const STOMP_BOUNCE = 380;
const SPEED_MULTIPLIER = 1.55;
const SHOT_SPEED = 360;
const SHOT_COOLDOWN = 0.22;
const MAGNET_RADIUS = 92;
export type GameStatus =
| 'ready'
| 'playing'
| 'paused'
| 'levelComplete'
| 'allComplete'
| 'gameOver';
export type InputAction = 'left' | 'right' | 'jump' | 'fire';
export type PowerType = 'magnet' | 'fire' | 'shield' | 'doubleJump' | 'speed';
export interface PowerInfo {
type: PowerType;
timeLeft: number;
}
export interface MoverDefinition {
/** Top-left tile position. */
x: number;
y: number;
/** Width in tiles. */
w: number;
/** Height in tiles (default 0.55). */
h?: number;
axis: 'x' | 'y';
/** Travel distance in tiles. */
range: number;
/** Tiles per second. */
speed: number;
/** 0..1 phase offset. */
phase?: number;
}
export interface SawDefinition {
/** Centre tile position. */
x: number;
y: number;
/** Radius in tiles (default 0.6). */
r?: number;
axis?: 'x' | 'y';
/** Travel distance in tiles. */
range?: number;
speed?: number;
phase?: number;
}
export interface LevelDefinition {
name: string;
rows: string[];
movers?: MoverDefinition[];
hazards?: SawDefinition[];
}
export const LEVELS: LevelDefinition[] = [
{
name: 'Meadow',
rows: [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' oow f ooo f ooj ',
' P === ? w b ===? ^ ? ^ === c G ',
'#################### ################## #####################',
],
hazards: [
{ x: 8, y: 10 },
{ x: 57, y: 10 },
],
},
{
name: 'Cavern',
rows: [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ooo f oow f ooj f oob ooo ',
' P ===b ? ^ === ^ j===? ? ^=== w === c G ',
'############## ########### ########## ########### ############',
],
hazards: [
{ x: 4, y: 10 },
{ x: 51, y: 10 },
{ x: 59, y: 10 },
],
},
{
name: 'Spires',
rows: [
' ',
' ',
' ',
' ',
' ',
' ',
' f f ',
' ',
' oow ooj oob f ',
' === ooo === ooo === ooo ',
' P ===? w=== ? ^ ===? ^=== c ^=== j === G ',
'################## ################## #######################',
],
hazards: [
{ x: 4, y: 10 },
{ x: 50, y: 10 },
],
movers: [
{ x: 20, y: 8, w: 2, axis: 'y', range: 3, speed: 1.8, phase: 0 },
],
},
{
name: 'Foundry',
rows: [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' f f f ',
' ooo oow ooj oob ooo ',
' P === ^ === ^ j ===w c ^ ^=== b === w G ',
'################ ############# ############## #####################',
],
hazards: [
{ x: 4, y: 10 },
{ x: 20, y: 10 },
{ x: 37, y: 10 },
{ x: 63, y: 10 },
],
movers: [
{ x: 26, y: 9, w: 2, axis: 'x', range: 3, speed: 2.2, phase: 0 },
],
},
{
name: 'Ruins',
rows: [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' f f ',
' oow ooo oob ooj ',
' === ooj === === oow === oob f ooo ',
' P === === ^ ===c b=== ===? ^ j=== ^ === B === w G ',
'########################## ################ ##########################',
],
hazards: [
{ x: 8, y: 10 },
{ x: 36, y: 10 },
],
},
{
name: 'Gauntlet',
rows: [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' f f ',
' f oob f oow f ',
' oow === ooj === oob ooo ',
' P ===j ^ b===b ^===w w===c ^===? ^ B===c G ',
'############## ########### ############ ########### ########## #########',
],
hazards: [
{ x: 4, y: 10 },
],
},
];
export interface HudState {
levelIndex: number;
levelCount: number;
levelName: string;
coins: number;
coinTotal: number;
time: number;
deaths: number;
hearts: number;
status: GameStatus;
power: PowerType | null;
powerTime: number;
}
export interface CompletionInfo {
levelIndex: number;
levelCount: number;
levelName: string;
levelTime: number;
totalTime: number;
isLast: boolean;
deaths: number;
}
export interface GameCallbacks {
onHud(state: HudState): void;
onStatusChange(status: GameStatus, info: CompletionInfo): void;
}
interface Coin {
col: number;
row: number;
x: number;
y: number;
taken: boolean;
}
interface Crate {
col: number;
row: number;
broken: boolean;
power: PowerType;
wobble: number;
}
interface Checkpoint {
col: number;
row: number;
active: boolean;
}
interface Saw {
x: number;
y: number;
r: number;
axis: 'x' | 'y' | null;
originX: number;
originY: number;
range: number;
speed: number;
phase: number;
angle: number;
}
interface Shot {
x: number;
y: number;
vx: number;
vy: number;
life: number;
}
type EnemyType = 'walker' | 'jumper' | 'flyer' | 'brute' | 'boss';
interface EnemySpawn {
type: EnemyType;
col: number;
row: number;
}
interface EnemyStats {
w: number;
h: number;
speed: number;
hp: number;
coins: number;
color: string;
shade: string;
}
const ENEMY_STATS: Record<EnemyType, EnemyStats> = {
walker: { w: 18, h: 18, speed: 55, hp: 1, coins: 1, color: '#e5484d', shade: '#b4232c' },
jumper: { w: 18, h: 18, speed: 28, hp: 1, coins: 1, color: '#a259ff', shade: '#7b34d6' },
flyer: { w: 20, h: 15, speed: 70, hp: 1, coins: 1, color: '#2fb344', shade: '#1f8a33' },
brute: { w: 26, h: 26, speed: 36, hp: 2, coins: 2, color: '#8e1f2b', shade: '#661520' },
boss: { w: 44, h: 44, speed: 34, hp: 5, coins: 10, color: '#7a1f2b', shade: '#4d1119' },
};
interface Body {
x: number;
y: number;
vx: number;
vy: number;
w: number;
h: number;
onGround: boolean;
}
interface Enemy extends Body {
type: EnemyType;
dir: number;
hp: number;
maxHp: number;
coinValue: number;
alive: boolean;
squash: number;
hitFlash: number;
baseY: number;
originX: number;
timer: number;
bob: number;
}
interface Mover {
x: number;
y: number;
w: number;
h: number;
axis: 'x' | 'y';
originX: number;
originY: number;
range: number;
speed: number;
phase: number;
dx: number;
dy: number;
prevX: number;
prevY: number;
}
interface Pop {
x: number;
y: number;
vy: number;
life: number;
max: number;
text: string;
color: string;
}
interface ParsedLevel {
name: string;
cols: number;
rows: number;
tiles: Uint8Array;
coins: Coin[];
crates: Crate[];
checkpoints: Checkpoint[];
enemies: EnemySpawn[];
movers: MoverDefinition[];
hazards: SawDefinition[];
spawn: { x: number; y: number };
goal: { x: number; y: number };
}
interface Palette {
skyTop: string;
skyBottom: string;
hillFar: string;
hillNear: string;
solid: string;
solidTop: string;
spike: string;
coin: string;
coinShine: string;
player: string;
playerShine: string;
eye: string;
pupil: string;
goal: string;
goalFlag: string;
mover: string;
moverTop: string;
saw: string;
sawShine: string;
crate: string;
crateDark: string;
}
const FALLBACK_PALETTE: Palette = {
skyTop: '#eef3f8',
skyBottom: '#dbe6f2',
hillFar: '#d3dde9',
hillNear: '#bccbdc',
solid: '#8a97a8',
solidTop: '#a9b5c5',
spike: '#e5484d',
coin: '#f5b301',
coinShine: '#ffe08a',
player: '#2563eb',
playerShine: '#7ba4f6',
eye: '#ffffff',
pupil: '#17212b',
goal: '#2563eb',
goalFlag: '#7ba4f6',
mover: '#c98a3a',
moverTop: '#e0aa5c',
saw: '#8b93a3',
sawShine: '#c9d1de',
crate: '#c98a3a',
crateDark: '#8a5a22',
};
type Rgb = [number, number, number];
function parseColor(input: string): Rgb | null {
const value = input.trim();
if (!value) return null;
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value);
if (hex) {
const raw = hex[1];
const full = raw.length === 3 ? raw.split('').map((c) => c + c).join('') : raw;
return [
Number.parseInt(full.slice(0, 2), 16),
Number.parseInt(full.slice(2, 4), 16),
Number.parseInt(full.slice(4, 6), 16),
];
}
const rgb = /^rgba?\(([^)]+)\)$/i.exec(value);
if (rgb) {
const parts = rgb[1].split(/[\s,/]+/).filter(Boolean).map(Number);
if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
return [parts[0], parts[1], parts[2]];
}
}
return null;
}
function mix(a: Rgb, b: Rgb, t: number): Rgb {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
function css(color: Rgb): string {
return `rgb(${color[0]} ${color[1]} ${color[2]})`;
}
function luminance(color: Rgb): number {
return (0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]) / 255;
}
export function buildPalette(bgRaw: string, fgRaw: string, primaryRaw: string): Palette {
const bg = parseColor(bgRaw) ?? [244, 246, 248];
const fg = parseColor(fgRaw) ?? [23, 33, 43];
const primary = parseColor(primaryRaw) ?? [37, 99, 235];
const dark = luminance(bg) < 0.5;
return {
skyTop: css(bg),
skyBottom: css(mix(bg, primary, dark ? 0.08 : 0.12)),
hillFar: css(mix(bg, fg, dark ? 0.12 : 0.06)),
hillNear: css(mix(bg, fg, dark ? 0.22 : 0.12)),
solid: css(mix(bg, fg, dark ? 0.34 : 0.24)),
solidTop: css(mix(bg, fg, dark ? 0.5 : 0.36)),
spike: '#e5484d',
coin: '#f5b301',
coinShine: '#ffe08a',
player: css(primary),
playerShine: css(mix(primary, [255, 255, 255], 0.38)),
eye: '#ffffff',
pupil: css(mix(primary, fg, 0.55)),
goal: css(primary),
goalFlag: css(mix(primary, [255, 255, 255], 0.25)),
mover: '#c98a3a',
moverTop: '#e0aa5c',
saw: css(mix(bg, fg, dark ? 0.72 : 0.62)),
sawShine: css(mix(bg, fg, dark ? 0.95 : 0.85)),
crate: '#c98a3a',
crateDark: '#8a5a22',
};
}
const POWER_POOL: PowerType[] = ['magnet', 'fire', 'shield', 'doubleJump', 'speed'];
function parseLevel(definition: LevelDefinition): ParsedLevel {
const rows = definition.rows.length;
const cols = Math.max(...definition.rows.map((row) => row.length));
const tiles = new Uint8Array(cols * rows);
const coins: Coin[] = [];
const crates: Crate[] = [];
const checkpoints: Checkpoint[] = [];
const enemies: EnemySpawn[] = [];
let spawn = { x: 1, y: rows - 2 };
let goal = { x: cols - 2, y: rows - 2 };
const enemyChars: Record<string, EnemyType> = {
w: 'walker',
j: 'jumper',
f: 'flyer',
b: 'brute',
B: 'boss',
};
for (let row = 0; row < rows; row += 1) {
const line = definition.rows[row];
for (let col = 0; col < cols; col += 1) {
const ch = line[col] ?? ' ';
const index = row * cols + col;
if (ch === '#' || ch === '=') tiles[index] = 1;
else if (ch === '^') tiles[index] = 2;
else if (ch === 'o') {
coins.push({ col, row, x: (col + 0.5) * TILE, y: (row + 0.5) * TILE, taken: false });
} else if (ch === '?') {
crates.push({
col,
row,
broken: false,
power: POWER_POOL[(col * 3 + row) % POWER_POOL.length],
wobble: 0,
});
} else if (ch === 'c') {
checkpoints.push({ col, row, active: false });
} else if (ch === 'P') spawn = { x: col, y: row };
else if (ch === 'G') goal = { x: col, y: row };
else if (enemyChars[ch]) enemies.push({ type: enemyChars[ch], col, row });
}
}
return {
name: definition.name,
cols,
rows,
tiles,
coins,
crates,
checkpoints,
enemies,
movers: definition.movers ?? [],
hazards: definition.hazards ?? [],
spawn,
goal,
};
}
function approach(current: number, target: number, delta: number): number {
if (current < target) return Math.min(current + delta, target);
if (current > target) return Math.max(current - delta, target);
return current;
}
function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
radius: number,
): void {
const r = Math.max(0, Math.min(radius, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
export class Platformer {
private readonly canvas: HTMLCanvasElement;
private readonly ctx: CanvasRenderingContext2D;
private readonly callbacks: GameCallbacks;
private level: ParsedLevel;
private levelIndex = 0;
private status: GameStatus = 'ready';
private player = {
x: 0,
y: 0,
vx: 0,
vy: 0,
w: PLAYER_W,
h: PLAYER_H,
onGround: false,
facing: 1,
stretch: 0,
walk: 0,
};
private enemies: Enemy[] = [];
private movers: Mover[] = [];
private ridingMover: Mover | null = null;
private saws: Saw[] = [];
private shots: Shot[] = [];
private pops: Pop[] = [];
private coinTotal = 0;
private power: PowerInfo | null = null;
private extraJump = false;
private fireCooldown = 0;
private respawnPoint: { x: number; y: number } | null = null;
private lastSafe: { x: number; y: number } | null = null;
private input = { left: false, right: false, jump: false, fire: false };
private coyote = 0;
private jumpBuffer = 0;
private dead = false;
private deadTimer = 0;
private grace = 0;
private shake = 0;
private levelTime = 0;
private totalTime = 0;
private elapsed = 0;
private simTime = 0;
private deaths = 0;
private hearts = 3;
private coinsCollected = 0;
private palette: Palette = FALLBACK_PALETTE;
private scale = 1;
private viewW = 0;
private viewH = 0;
private camX = 0;
private camY = 0;
private cssW = 0;
private cssH = 0;
private dpr = 1;
private raf = 0;
private last = 0;
private accumulator = 0;
private lastHudKey = '';
private destroyed = false;
constructor(canvas: HTMLCanvasElement, callbacks: GameCallbacks) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D canvas context unavailable');
this.ctx = ctx;
this.callbacks = callbacks;
this.level = parseLevel(LEVELS[0]);
this.respawnPoint = null;
this.resetEntities();
this.refreshPalette();
this.resize();
this.last = performance.now();
this.raf = requestAnimationFrame(this.frame);
}
// ── Public control surface ──────────────────────────────────────────
press(action: InputAction): void {
if (action === 'jump') {
this.input.jump = true;
this.jumpBuffer = JUMP_BUFFER_TIME;
} else {
this.input[action] = true;
}
}
release(action: InputAction): void {
this.input[action] = false;
}
releaseAll(): void {
this.input.left = false;
this.input.right = false;
this.input.jump = false;
this.input.fire = false;
}
primary(): void {
switch (this.status) {
case 'ready':
this.startRun();
break;
case 'paused':
this.setStatus('playing');
break;
case 'levelComplete':
this.advance();
break;
case 'allComplete':
this.startRun();
break;
case 'gameOver':
this.retryLevel();
break;
default:
break;
}
}
togglePause(): void {
if (this.status === 'playing') this.setStatus('paused');
else if (this.status === 'paused') this.setStatus('playing');
}
restartLevel(): void {
if (this.status === 'gameOver') {
this.retryLevel();
return;
}
if (this.status === 'allComplete') {
this.startRun();
return;
}
this.resetLevelState();
this.setStatus('playing');
}
startRun(): void {
this.totalTime = 0;
this.deaths = 0;
this.hearts = 3;
this.loadLevel(0);
this.setStatus('playing');
}
resize(): void {
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
if (width <= 0 || height <= 0) return;
this.dpr = Math.min(window.devicePixelRatio || 1, 3);
this.cssW = width;
this.cssH = height;
const pixelW = Math.round(width * this.dpr);
const pixelH = Math.round(height * this.dpr);
if (this.canvas.width !== pixelW) this.canvas.width = pixelW;
if (this.canvas.height !== pixelH) this.canvas.height = pixelH;
this.scale = Math.min(height / (VIEW_ROWS * TILE), width / (MIN_VIEW_TILES * TILE));
this.viewW = width / this.scale;
this.viewH = height / this.scale;
this.updateCamera();
}
refreshPalette(): void {
const style = getComputedStyle(document.documentElement);
this.palette = buildPalette(
style.getPropertyValue('--bg'),
style.getPropertyValue('--fg'),
style.getPropertyValue('--primary'),
);
}
destroy(): void {
this.destroyed = true;
cancelAnimationFrame(this.raf);
}
// ── Run / level state ───────────────────────────────────────────────
private loadLevel(index: number): void {
this.levelIndex = index;
this.hearts = 3;
this.level = parseLevel(LEVELS[index]);
this.respawnPoint = null;
this.lastSafe = null;
this.resetEntities();
}
/** Restart the current level from the very beginning (checkpoint cleared). */
private resetLevelState(): void {
this.level = parseLevel(LEVELS[this.levelIndex]);
this.respawnPoint = null;
this.lastSafe = null;
this.resetEntities();
}
/** Retry the current level from the beginning with a full set of hearts. */
private retryLevel(): void {
this.hearts = 3;
this.resetLevelState();
this.setStatus('playing');
}
/**
* Continue after losing a heart: keep coins, enemies, crates and saws as they
* are and put the player back on the last checkpoint (or where they last
* stood safely). The level is not reset.
*/
private respawnPlayer(): void {
this.resetPlayer();
const point = this.respawnPoint ?? this.lastSafe;
if (point) {
this.player.x = point.x;
this.player.y = point.y;
}
// Brief invulnerability so respawning next to a hazard is survivable.
this.grace = 1.5;
}
/**
* Reset coins, crates, enemies, saws and power-ups and put the player back on
* the last checkpoint (or the spawn). Checkpoints stay lit between deaths.
*/
private resetEntities(): void {
for (const coin of this.level.coins) {
coin.taken = false;
coin.x = (coin.col + 0.5) * TILE;
coin.y = (coin.row + 0.5) * TILE;
}
for (const crate of this.level.crates) crate.broken = false;
this.enemies = this.level.enemies.map((spawn) => this.createEnemy(spawn));
this.movers = this.level.movers.map((definition) => this.createMover(definition));
this.saws = this.level.hazards.map((definition) => this.createSaw(definition));
this.shots = [];
this.power = null;
this.extraJump = false;
this.fireCooldown = 0;
this.ridingMover = null;
this.coinsCollected = 0;
this.levelTime = 0;
this.dead = false;
this.deadTimer = 0;
this.pops = [];
this.coinTotal =
this.level.coins.length + this.enemies.reduce((sum, enemy) => sum + enemy.coinValue, 0);
this.resetPlayer();
if (this.respawnPoint) {
this.player.x = this.respawnPoint.x;
this.player.y = this.respawnPoint.y;
}
}
private createEnemy(spawn: EnemySpawn): Enemy {
const stats = ENEMY_STATS[spawn.type];
const ground = spawn.type !== 'flyer';
const y = ground
? (spawn.row + 1) * TILE - stats.h
: spawn.row * TILE + (TILE - stats.h) / 2;
return {
type: spawn.type,
x: spawn.col * TILE + (TILE - stats.w) / 2,
y,
vx: 0,
vy: 0,
w: stats.w,
h: stats.h,
onGround: false,
dir: -1,
hp: stats.hp,
maxHp: stats.hp,
coinValue: stats.coins,
alive: true,
squash: 0,
hitFlash: 0,
baseY: y,
originX: spawn.col * TILE + (TILE - stats.w) / 2,
timer: 0.6 + (spawn.col % 3) * 0.25,
bob: (spawn.col % 4) * 0.5,
};
}
private createMover(definition: MoverDefinition): Mover {
const h = (definition.h ?? 0.55) * TILE;
const x = definition.x * TILE;
const y = definition.y * TILE;
return {
x,
y,
w: definition.w * TILE,
h,
axis: definition.axis,
originX: x,
originY: y,
range: definition.range * TILE,
speed: definition.speed,
phase: definition.phase ?? 0,
dx: 0,
dy: 0,
prevX: x,
prevY: y,
};
}
private createSaw(definition: SawDefinition): Saw {
const x = (definition.x + 0.5) * TILE;
const y = (definition.y + 0.5) * TILE;
return {
x,
y,
r: (definition.r ?? 0.6) * TILE,
axis: definition.axis ?? null,
originX: x,
originY: y,
range: (definition.range ?? 0) * TILE,
speed: definition.speed ?? 0,
phase: definition.phase ?? 0,
angle: 0,
};
}
private resetPlayer(): void {
const player = this.player;
player.x = this.level.spawn.x * TILE + (TILE - PLAYER_W) / 2;
player.y = (this.level.spawn.y + 1) * TILE - PLAYER_H;
player.vx = 0;
player.vy = 0;
player.onGround = false;
player.facing = 1;
player.stretch = 0;
player.walk = 0;
this.coyote = 0;
this.jumpBuffer = 0;
this.dead = false;
this.deadTimer = 0;
this.ridingMover = null;
this.power = null;
this.extraJump = false;
this.fireCooldown = 0;
this.shots = [];
}
private advance(): void {
if (this.levelIndex >= LEVELS.length - 1) {
this.startRun();
return;
}
this.loadLevel(this.levelIndex + 1);
this.setStatus('playing');
}
private setStatus(status: GameStatus): void {
this.status = status;
this.callbacks.onStatusChange(status, this.completionInfo());
this.emitHud(true);
}
private completionInfo(): CompletionInfo {
return {
levelIndex: this.levelIndex,
levelCount: LEVELS.length,
levelName: this.level.name,
levelTime: this.levelTime,
totalTime: this.totalTime + this.levelTime,
isLast: this.levelIndex >= LEVELS.length - 1,
deaths: this.deaths,
};
}
private completeLevel(): void {
if (this.status !== 'playing') return;
this.totalTime += this.levelTime;
sfx.complete();
this.setStatus(this.levelIndex >= LEVELS.length - 1 ? 'allComplete' : 'levelComplete');
}
private die(force = false): void {
if (this.dead || (this.invulnerable() && !force)) return;
this.dead = true;
this.deadTimer = 0.6;
this.deaths += 1;
this.hearts = Math.max(0, this.hearts - 1);
this.shake = 9;
this.player.vy = 0;
this.player.vx = 0;
sfx.hurt();
this.emitHud(true);
}
private spawnPop(x: number, y: number, text: string, color: string): void {
this.pops.push({ x, y, vy: -46, life: 0.8, max: 0.8, text, color });
}
// ── Simulation ──────────────────────────────────────────────────────
private step(h: number): void {
this.simTime += h;
if (this.dead) {
this.deadTimer -= h;
this.updateMovers();
this.updateSaws(h);
this.updateShots(h);
this.updatePops(h);
if (this.deadTimer <= 0) {
if (this.hearts <= 0) {
this.dead = false;
this.setStatus('gameOver');
} else {
this.respawnPlayer();
}
}
return;
}
this.updateMovers();
if (this.ridingMover && this.player.onGround) {
this.player.x += this.ridingMover.dx;
}
this.ridingMover = null;
this.grace = Math.max(0, this.grace - h);
const player = this.player;
const dir = (this.input.right ? 1 : 0) - (this.input.left ? 1 : 0);
if (dir !== 0) player.facing = dir;
if (this.power) {
this.power.timeLeft -= h;
if (this.power.timeLeft <= 0) {
this.power = null;
this.emitHud(true);
}
}
const speedMul = this.power?.type === 'speed' ? SPEED_MULTIPLIER : 1;
const maxRun = MAX_RUN * speedMul;
const accel = (player.onGround ? RUN_ACCEL : AIR_ACCEL) * speedMul;
if (dir !== 0) player.vx = approach(player.vx, dir * maxRun, accel * h);
else player.vx = approach(player.vx, 0, RUN_DECEL * h);
if (player.onGround) {
this.coyote = COYOTE_TIME;
this.extraJump = true;
} else {
this.coyote = Math.max(0, this.coyote - h);
}
this.jumpBuffer = Math.max(0, this.jumpBuffer - h);
if (this.jumpBuffer > 0) {
if (this.coyote > 0) {
player.vy = -JUMP_VELOCITY;
player.onGround = false;
this.jumpBuffer = 0;
this.coyote = 0;
sfx.jump();
} else if (this.power?.type === 'doubleJump' && this.extraJump) {
player.vy = -JUMP_VELOCITY * 0.92;
this.extraJump = false;
this.jumpBuffer = 0;
this.spawnPop(player.x + PLAYER_W / 2, player.y + PLAYER_H, 'hop!', this.palette.playerShine);
sfx.jump();
}
}
if (!this.input.jump && player.vy < -JUMP_CUT) player.vy = -JUMP_CUT;
this.fireCooldown = Math.max(0, this.fireCooldown - h);
if (this.input.fire && this.power?.type === 'fire' && this.fireCooldown <= 0) {
this.fireCooldown = SHOT_COOLDOWN;
this.shots.push({
x: player.x + PLAYER_W / 2 + player.facing * 10,
y: player.y + PLAYER_H * 0.42,
vx: player.facing * SHOT_SPEED,
vy: 0,
life: 0.9,
});
sfx.fire();
}
player.vy = Math.min(player.vy + GRAVITY * h, MAX_FALL);
const falling = player.vy > 0;
const prevBottom = player.y + PLAYER_H;
player.onGround = false;
this.moveBodyX(player, h);
this.moveBodyY(player, h);
if (!player.onGround && falling) {
for (const mover of this.movers) {
if (player.x + PLAYER_W > mover.x && player.x < mover.x + mover.w) {
const feet = player.y + PLAYER_H;
if (prevBottom <= mover.y + 3 && feet >= mover.y) {
player.y = mover.y - PLAYER_H;
player.vy = 0;
player.onGround = true;
this.ridingMover = mover;
break;
}
}
}
}
if (this.ridingMover) player.y = this.ridingMover.y - PLAYER_H;
if (player.onGround) {
player.walk += Math.abs(player.vx) * h * 0.22;
this.lastSafe = { x: player.x, y: player.y };
}
const targetStretch = player.onGround
? 0
: Math.max(-0.08, Math.min(0.12, player.vy / 2600));
player.stretch += (targetStretch - player.stretch) * Math.min(1, h * 16);
this.breakCrates();
this.checkCheckpoints();
this.applyMagnet(h);
this.updateSaws(h);
this.updateShots(h);
this.updateEnemies(h);
this.collectCoins();
this.checkEnemies();
this.checkSpikes();
this.checkSaws();
this.checkGoal();
this.updatePops(h);
if (player.y > this.level.rows * TILE + 80) this.die(true);
}
private checkCheckpoints(): void {
const player = this.player;
for (const checkpoint of this.level.checkpoints) {
if (checkpoint.active) continue;
const cx = checkpoint.col * TILE + 4;
const cy = checkpoint.row * TILE + 4;
const cw = TILE - 8;
const ch = TILE - 4;
if (
player.x < cx + cw &&
player.x + PLAYER_W > cx &&
player.y < cy + ch &&
player.y + PLAYER_H > cy
) {
checkpoint.active = true;
this.respawnPoint = {
x: checkpoint.col * TILE + (TILE - PLAYER_W) / 2,
y: (checkpoint.row + 1) * TILE - PLAYER_H,
};
this.spawnPop((checkpoint.col + 0.5) * TILE, checkpoint.row * TILE, 'Checkpoint!', this.palette.goalFlag);
sfx.checkpoint();
this.emitHud(true);
}
}
}
private invulnerable(): boolean {
return this.power?.type === 'shield' || this.grace > 0;
}
private grantPower(type: PowerType, col: number, row: number): void {
const durations: Record<PowerType, number> = {
magnet: 9,
fire: 9,
shield: 8,
doubleJump: 12,
speed: 8,
};
const labels: Record<PowerType, string> = {
magnet: 'Coin magnet!',
fire: 'Fire shot!',
shield: 'Shield!',
doubleJump: 'Double jump!',
speed: 'Speed!',
};
this.power = { type, timeLeft: durations[type] };
this.extraJump = true;
this.fireCooldown = 0;
this.shake = Math.max(this.shake, 3);
this.spawnPop((col + 0.5) * TILE, (row + 0.4) * TILE, labels[type], this.palette.coinShine);
sfx.power();
this.emitHud(true);
}
private breakCrates(): void {
const player = this.player;
const pad = 3;
const left = Math.floor((player.x - pad) / TILE);
const right = Math.floor((player.x + PLAYER_W + pad - 0.01) / TILE);
const top = Math.floor((player.y - pad) / TILE);
const bottom = Math.floor((player.y + PLAYER_H + pad - 0.01) / TILE);
for (const crate of this.level.crates) {
if (crate.broken) continue;
if (crate.col < left || crate.col > right || crate.row < top || crate.row > bottom) continue;
crate.broken = true;
this.grantPower(crate.power, crate.col, crate.row);
}
}
private applyMagnet(h: number): void {
if (this.power?.type !== 'magnet') return;
const cx = this.player.x + PLAYER_W / 2;
const cy = this.player.y + PLAYER_H / 2;
for (const coin of this.level.coins) {
if (coin.taken) continue;
const dx = cx - coin.x;
const dy = cy - coin.y;
const dist = Math.hypot(dx, dy);
if (dist < MAGNET_RADIUS && dist > 0.5) {
const pull = Math.min(280 * h, dist);
coin.x += (dx / dist) * pull;
coin.y += (dy / dist) * pull;
}
}
}
private updateSaws(h: number): void {
for (const saw of this.saws) {
saw.angle += h * 9;
if (saw.axis && saw.range > 0 && saw.speed > 0) {
const period = (saw.range * 2) / saw.speed;
const phaseT = (this.simTime / period + saw.phase) % 1;
const triangle = phaseT < 0.5 ? phaseT * 2 : (1 - phaseT) * 2;
const offset = triangle * saw.range;
if (saw.axis === 'x') {
saw.x = saw.originX + offset;
saw.y = saw.originY;
} else {
saw.y = saw.originY + offset;
saw.x = saw.originX;
}
}
}
}
private updateShots(h: number): void {
for (const shot of this.shots) {
shot.x += shot.vx * h;
shot.y += shot.vy * h;
shot.life -= h;
if (shot.life <= 0) continue;
if (this.solidAt(Math.floor(shot.x / TILE), Math.floor(shot.y / TILE))) {
shot.life = 0;
continue;
}
for (const enemy of this.enemies) {
if (!enemy.alive) continue;
if (
shot.x > enemy.x &&
shot.x < enemy.x + enemy.w &&
shot.y > enemy.y &&
shot.y < enemy.y + enemy.h
) {
enemy.hp -= 1;
enemy.hitFlash = 0.18;
shot.life = 0;
if (enemy.hp <= 0) {
enemy.alive = false;
enemy.squash = 0.3;
this.coinsCollected += enemy.coinValue;
this.spawnPop(
enemy.x + enemy.w / 2,
enemy.y,
`+${enemy.coinValue}`,
this.palette.coinShine,
);
this.emitHud(true);
}
break;
}
}
}
this.shots = this.shots.filter((shot) => shot.life > 0);
}
private checkSaws(): void {
if (this.invulnerable()) return;
const player = this.player;
for (const saw of this.saws) {
const nx = Math.max(player.x, Math.min(saw.x, player.x + PLAYER_W));
const ny = Math.max(player.y, Math.min(saw.y, player.y + PLAYER_H));
const dx = saw.x - nx;
const dy = saw.y - ny;
const radius = Math.max(2, saw.r - 1);
if (dx * dx + dy * dy < radius * radius) {
this.die();
return;
}
}
}
private solidAt(col: number, row: number): boolean {
if (col < 0 || col >= this.level.cols) return true;
if (row < 0 || row >= this.level.rows) return false;
return this.level.tiles[row * this.level.cols + col] === 1;
}
private moveBodyX(body: Body, h: number): void {
body.x += body.vx * h;
const top = Math.floor(body.y / TILE);
const bottom = Math.floor((body.y + body.h - 0.01) / TILE);
if (body.vx > 0) {
const col = Math.floor((body.x + body.w - 0.01) / TILE);
for (let row = top; row <= bottom; row += 1) {
if (this.solidAt(col, row)) {
body.x = col * TILE - body.w;
body.vx = 0;
break;
}
}
} else if (body.vx < 0) {
const col = Math.floor(body.x / TILE);
for (let row = top; row <= bottom; row += 1) {
if (this.solidAt(col, row)) {
body.x = (col + 1) * TILE;
body.vx = 0;
break;
}
}
}
}
private moveBodyY(body: Body, h: number): void {
body.y += body.vy * h;
const left = Math.floor(body.x / TILE);
const right = Math.floor((body.x + body.w - 0.01) / TILE);
if (body.vy > 0) {
const row = Math.floor((body.y + body.h - 0.01) / TILE);
for (let col = left; col <= right; col += 1) {
if (this.solidAt(col, row)) {
body.y = row * TILE - body.h;
body.vy = 0;
body.onGround = true;
break;
}
}
} else if (body.vy < 0) {
const row = Math.floor(body.y / TILE);
for (let col = left; col <= right; col += 1) {
if (this.solidAt(col, row)) {
body.y = (row + 1) * TILE;
body.vy = 0;
break;
}
}
}
}
private updateMovers(): void {
for (const mover of this.movers) {
mover.prevX = mover.x;
mover.prevY = mover.y;
const period = (mover.range * 2) / mover.speed;
const phaseT = (this.simTime / period + mover.phase) % 1;
const triangle = phaseT < 0.5 ? phaseT * 2 : (1 - phaseT) * 2;
const offset = triangle * mover.range;
if (mover.axis === 'x') {
mover.x = mover.originX + offset;
mover.y = mover.originY;
} else {
mover.y = mover.originY + offset;
mover.x = mover.originX;
}
mover.dx = mover.x - mover.prevX;
mover.dy = mover.y - mover.prevY;
}
}
private updateEnemies(h: number): void {
for (const enemy of this.enemies) {
if (!enemy.alive) {
if (enemy.squash > 0) enemy.squash = Math.max(0, enemy.squash - h);
continue;
}
if (enemy.hitFlash > 0) enemy.hitFlash = Math.max(0, enemy.hitFlash - h);
if (enemy.type === 'flyer') {
enemy.bob += h;
enemy.x += enemy.dir * ENEMY_STATS.flyer.speed * h;
const aheadX = enemy.dir > 0 ? enemy.x + enemy.w + 2 : enemy.x - 2;
const row = Math.floor((enemy.y + enemy.h / 2) / TILE);
if (this.solidAt(Math.floor(aheadX / TILE), row)) enemy.dir *= -1;
const patrol = 2 * TILE;
const minX = Math.max(TILE, enemy.originX - patrol);
const maxX = Math.min(this.level.cols * TILE - enemy.w - TILE, enemy.originX + patrol);
if (enemy.x < minX) {
enemy.x = minX;
enemy.dir = 1;
} else if (enemy.x > maxX) {
enemy.x = maxX;
enemy.dir = -1;
}
enemy.y = enemy.baseY + Math.sin(enemy.bob * 2.4) * 7;
enemy.onGround = false;
continue;
}
const bruteSpeed = enemy.hp < enemy.maxHp ? 1.7 : 1;
const speed =
enemy.type === 'boss'
? ENEMY_STATS.boss.speed
: enemy.type === 'brute'
? ENEMY_STATS.brute.speed * bruteSpeed
: enemy.type === 'jumper'
? ENEMY_STATS.jumper.speed
: ENEMY_STATS.walker.speed;
enemy.vy = Math.min(enemy.vy + GRAVITY * h, MAX_FALL);
enemy.vx = enemy.dir * speed;
if ((enemy.type === 'jumper' || enemy.type === 'boss') && enemy.onGround) {
enemy.timer -= h;
if (enemy.timer <= 0) {
enemy.vy = enemy.type === 'boss' ? -470 : -430;
enemy.timer = enemy.type === 'boss' ? 2.4 : 1.5;
}
}
enemy.onGround = false;
const wasVx = enemy.vx;
this.moveBodyX(enemy, h);
if (wasVx !== 0 && enemy.vx === 0) enemy.dir *= -1;
this.moveBodyY(enemy, h);
this.avoidLedge(enemy);
if (enemy.y > this.level.rows * TILE + 100) enemy.alive = false;
}
}
private avoidLedge(enemy: Enemy): void {
if (!enemy.onGround) return;
const frontX = enemy.dir > 0 ? enemy.x + enemy.w + 1 : enemy.x - 1;
const col = Math.floor(frontX / TILE);
const rowBelow = Math.floor((enemy.y + enemy.h + 1) / TILE);
if (!this.solidAt(col, rowBelow)) enemy.dir *= -1;
}
private collectCoins(): void {
const cx = this.player.x + PLAYER_W / 2;
const cy = this.player.y + PLAYER_H / 2;
for (const coin of this.level.coins) {
if (coin.taken) continue;
if (Math.abs(cx - coin.x) < 12 && Math.abs(cy - coin.y) < 13) {
coin.taken = true;
this.coinsCollected += 1;
this.spawnPop(coin.x, coin.y, '+1', this.palette.coinShine);
sfx.coin();
this.emitHud(true);
}
}
}
private killEnemy(enemy: Enemy, bounce: boolean): void {
enemy.alive = false;
enemy.squash = 0.3;
this.coinsCollected += enemy.coinValue;
this.spawnPop(
enemy.x + enemy.w / 2,
enemy.y + enemy.h / 2,
`+${enemy.coinValue}`,
this.palette.coinShine,
);
this.shake = Math.max(this.shake, enemy.type === 'boss' ? 9 : 4);
if (enemy.type === 'boss') {
sfx.bossDown();
this.spawnPop(enemy.x + enemy.w / 2, enemy.y - 12, 'Boss defeated!', this.palette.spike);
} else {
sfx.stomp();
}
if (bounce) {
this.player.vy = -STOMP_BOUNCE;
this.player.onGround = false;
}
this.emitHud(true);
}
private checkEnemies(): void {
const player = this.player;
for (const enemy of this.enemies) {
if (!enemy.alive) continue;
if (
player.x >= enemy.x + enemy.w ||
player.x + PLAYER_W <= enemy.x ||
player.y >= enemy.y + enemy.h ||
player.y + PLAYER_H <= enemy.y
) {
continue;
}
const stomping = player.vy > 0 && player.y + PLAYER_H < enemy.y + enemy.h * 0.6;
if (!stomping) {
if (this.invulnerable()) {
this.killEnemy(enemy, true);
continue;
}
this.die();
return;
}
enemy.hp -= 1;
enemy.hitFlash = 0.18;
player.vy = -STOMP_BOUNCE;
player.onGround = false;
if (enemy.hp <= 0) {
this.killEnemy(enemy, false);
} else {
player.y = enemy.y - PLAYER_H;
if (enemy.type === 'boss') sfx.bossHit();
else sfx.stomp();
}
}
}
private checkSpikes(): void {
if (this.invulnerable()) return;
const player = this.player;
const left = Math.floor(player.x / TILE);
const right = Math.floor((player.x + PLAYER_W - 0.01) / TILE);
const top = Math.floor(player.y / TILE);
const bottom = Math.floor((player.y + PLAYER_H - 0.01) / TILE);
for (let row = top; row <= bottom; row += 1) {
for (let col = left; col <= right; col += 1) {
if (row < 0 || row >= this.level.rows) continue;
if (col < 0 || col >= this.level.cols) continue;
if (this.level.tiles[row * this.level.cols + col] !== 2) continue;
const sx = col * TILE + 5;
const sy = row * TILE + 9;
const sw = TILE - 10;
const sh = TILE - 9;
if (
player.x < sx + sw &&
player.x + PLAYER_W > sx &&
player.y < sy + sh &&
player.y + PLAYER_H > sy
) {
this.die();
return;
}
}
}
}
private checkGoal(): void {
const goal = this.level.goal;
const gx = goal.x * TILE + 3;
const gy = goal.y * TILE + 2;
const gw = TILE - 6;
const gh = TILE - 2;
const player = this.player;
if (
player.x < gx + gw &&
player.x + PLAYER_W > gx &&
player.y < gy + gh &&
player.y + PLAYER_H > gy
) {
this.completeLevel();
}
}
private updatePops(h: number): void {
for (const pop of this.pops) {
pop.life -= h;
pop.y += pop.vy * h;
pop.vy += 130 * h;
}
this.pops = this.pops.filter((pop) => pop.life > 0);
}
// ── Camera & frame ──────────────────────────────────────────────────
private updateCamera(): void {
const levelW = this.level.cols * TILE;
const levelH = this.level.rows * TILE;
const targetX = this.player.x + PLAYER_W / 2 - this.viewW / 2;
if (levelW <= this.viewW) this.camX = (levelW - this.viewW) / 2;
else this.camX = Math.max(0, Math.min(levelW - this.viewW, targetX));
if (levelH <= this.viewH) {
this.camY = levelH - this.viewH;
} else {
const targetY = this.player.y + PLAYER_H / 2 - this.viewH / 2;
this.camY = Math.max(0, Math.min(levelH - this.viewH, targetY));
}
}
private readonly frame = (now: number): void => {
if (this.destroyed) return;
this.raf = requestAnimationFrame(this.frame);
const dt = Math.min(0.05, Math.max(0, (now - this.last) / 1000));
this.last = now;
this.elapsed += dt;
if (this.status === 'playing') {
this.accumulator += dt;
while (this.accumulator >= FIXED_STEP) {
this.step(FIXED_STEP);
if (!this.dead) this.levelTime += FIXED_STEP;
this.accumulator -= FIXED_STEP;
}
} else {
this.accumulator = 0;
}
if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 26);
this.updateCamera();
this.render();
this.emitHud(false);
};
// ── Rendering ───────────────────────────────────────────────────────
private render(): void {
const ctx = this.ctx;
const palette = this.palette;
const { cssW, cssH, dpr, scale } = this;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const sky = ctx.createLinearGradient(0, 0, 0, cssH);
sky.addColorStop(0, palette.skyTop);
sky.addColorStop(1, palette.skyBottom);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, cssW, cssH);
let shakeX = 0;
let shakeY = 0;
if (this.shake > 0) {
shakeX = (Math.random() - 0.5) * this.shake;
shakeY = (Math.random() - 0.5) * this.shake;
}
const groundScreenY = ((this.level.rows - 1) * TILE - this.camY) * scale;
this.drawHills(ctx, cssW, cssH, groundScreenY, 0.35, 30, palette.hillFar);
this.drawHills(ctx, cssW, cssH, groundScreenY + 6, 0.6, 18, palette.hillNear);
ctx.setTransform(
dpr * scale,
0,
0,
dpr * scale,
(-this.camX * scale + shakeX) * dpr,
(-this.camY * scale + shakeY) * dpr,
);
this.drawTiles(ctx);
this.drawMovers(ctx);
this.drawSaws(ctx);
this.drawCoins(ctx);
this.drawCrates(ctx);
this.drawCheckpoints(ctx);
this.drawGoal(ctx);
for (const enemy of this.enemies) {
if (enemy.alive || enemy.squash > 0) this.drawEnemy(ctx, enemy);
}
this.drawShots(ctx);
if (!this.dead) this.drawPlayer(ctx);
if (this.power?.type === 'shield') this.drawShield(ctx);
this.drawPops(ctx);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
private drawHills(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
baseline: number,
parallax: number,
amplitude: number,
color: string,
): void {
ctx.fillStyle = color;
ctx.beginPath();
const offset = -this.camX * this.scale * parallax;
ctx.moveTo(0, height);
for (let x = 0; x <= width + 8; x += 8) {
const t = (x - offset) / 150;
const y = baseline - (amplitude + amplitude * Math.sin(t));
ctx.lineTo(x, y);
}
ctx.lineTo(width, height);
ctx.closePath();
ctx.fill();
}
private drawTiles(ctx: CanvasRenderingContext2D): void {
const { cols, rows } = this.level;
const c0 = Math.max(0, Math.floor(this.camX / TILE) - 1);
const c1 = Math.min(cols - 1, Math.ceil((this.camX + this.viewW) / TILE) + 1);
const r0 = Math.max(0, Math.floor(this.camY / TILE) - 1);
const r1 = Math.min(rows - 1, Math.ceil((this.camY + this.viewH) / TILE) + 1);
for (let row = r0; row <= r1; row += 1) {
for (let col = c0; col <= c1; col += 1) {
const kind = this.level.tiles[row * cols + col];
const x = col * TILE;
const y = row * TILE;
if (kind === 1) {
ctx.fillStyle = this.palette.solid;
ctx.fillRect(x, y, TILE, TILE);
if (row === 0 || this.level.tiles[(row - 1) * cols + col] !== 1) {
ctx.fillStyle = this.palette.solidTop;
ctx.fillRect(x, y, TILE, 4);
}
} else if (kind === 2) {
this.drawSpike(ctx, x, y);
}
}
}
}
private drawSpike(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.fillStyle = this.palette.spike;
const base = y + TILE;
for (const [ax, bx] of [
[x + 2, x + TILE / 2],
[x + TILE / 2, x + TILE - 2],
]) {
ctx.beginPath();
ctx.moveTo(ax, base);
ctx.lineTo((ax + bx) / 2, y + 4);
ctx.lineTo(bx, base);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.18)';
ctx.fillRect(x + 2, base - 3, TILE - 4, 3);
}
private drawMovers(ctx: CanvasRenderingContext2D): void {
for (const mover of this.movers) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
ctx.fillRect(mover.x + 2, mover.y + mover.h, mover.w - 4, 3);
ctx.fillStyle = this.palette.mover;
roundRectPath(ctx, mover.x, mover.y, mover.w, mover.h, 4);
ctx.fill();
ctx.fillStyle = this.palette.moverTop;
roundRectPath(ctx, mover.x + 2, mover.y + 2, mover.w - 4, mover.h * 0.45, 3);
ctx.fill();
ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
for (let i = 1; i < Math.round(mover.w / TILE); i += 1) {
ctx.beginPath();
ctx.arc(mover.x + i * TILE, mover.y + mover.h / 2, 1.6, 0, Math.PI * 2);
ctx.fill();
}
const cx = mover.x + mover.w / 2;
const cy = mover.y + mover.h / 2;
const pulse = 0.6 + 0.4 * Math.sin(this.elapsed * 5);
ctx.globalAlpha = pulse;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
if (mover.axis === 'x') {
const s = Math.sign(mover.x - mover.prevX) || 1;
ctx.moveTo(cx + s * 5, cy);
ctx.lineTo(cx - s * 3, cy - 3.5);
ctx.lineTo(cx - s * 3, cy + 3.5);
} else {
const s = Math.sign(mover.y - mover.prevY) || 1;
ctx.moveTo(cx, cy + s * 4);
ctx.lineTo(cx - 3.5, cy - s * 3);
ctx.lineTo(cx + 3.5, cy - s * 3);
}
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1;
}
}
private drawCoins(ctx: CanvasRenderingContext2D): void {
for (const coin of this.level.coins) {
if (coin.taken) continue;
const bob = Math.sin(this.elapsed * 4 + coin.col) * 1.6;
const cx = coin.x;
const cy = coin.y + bob;
const r = 5.5;
ctx.fillStyle = this.palette.coin;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.palette.coinShine;
ctx.beginPath();
ctx.arc(cx - 1.4, cy - 1.4, r * 0.42, 0, Math.PI * 2);
ctx.fill();
}
}
private drawCheckpoints(ctx: CanvasRenderingContext2D): void {
for (const checkpoint of this.level.checkpoints) {
const x = (checkpoint.col + 0.5) * TILE;
const y = checkpoint.row * TILE;
const base = (checkpoint.row + 1) * TILE;
ctx.fillStyle = checkpoint.active ? this.palette.goalFlag : this.palette.mover;
ctx.fillRect(x - 1.5, y + 3, 3, TILE - 3);
ctx.fillStyle = checkpoint.active ? this.palette.goal : this.palette.moverTop;
const wave = checkpoint.active ? Math.sin(this.elapsed * 6) * 1.2 : 0;
ctx.beginPath();
ctx.moveTo(x + 1.5, y + 4);
ctx.lineTo(x + 11, y + 8 + wave);
ctx.lineTo(x + 1.5, y + 12);
ctx.closePath();
ctx.fill();
ctx.fillStyle = checkpoint.active ? this.palette.goalFlag : this.palette.mover;
ctx.fillRect(x - 5, base - 3, 10, 3);
}
}
private drawSaws(ctx: CanvasRenderingContext2D): void {
for (const saw of this.saws) {
ctx.save();
ctx.translate(saw.x, saw.y);
ctx.rotate(saw.angle);
const teeth = 8;
ctx.fillStyle = this.palette.saw;
ctx.beginPath();
for (let i = 0; i < teeth; i += 1) {
const a = (i / teeth) * Math.PI * 2;
const a2 = ((i + 0.5) / teeth) * Math.PI * 2;
ctx.lineTo(Math.cos(a) * saw.r, Math.sin(a) * saw.r);
ctx.lineTo(Math.cos(a2) * saw.r * 0.72, Math.sin(a2) * saw.r * 0.72);
}
ctx.closePath();
ctx.fill();
ctx.fillStyle = this.palette.sawShine;
ctx.beginPath();
ctx.arc(0, 0, saw.r * 0.42, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.palette.saw;
ctx.beginPath();
ctx.arc(0, 0, saw.r * 0.16, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
private drawCrates(ctx: CanvasRenderingContext2D): void {
for (const crate of this.level.crates) {
if (crate.broken) continue;
const x = crate.col * TILE;
const y = crate.row * TILE;
const pad = 2;
const size = TILE - pad * 2;
const pulse = 0.5 + 0.5 * Math.sin(this.elapsed * 4 + crate.col);
ctx.fillStyle = this.palette.crateDark;
roundRectPath(ctx, x + pad, y + pad, size, size, 4);
ctx.fill();
ctx.fillStyle = this.palette.crate;
roundRectPath(ctx, x + pad + 2, y + pad + 2, size - 4, size - 4, 3);
ctx.fill();
ctx.strokeStyle = this.palette.crateDark;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + pad + 2, y + TILE / 2);
ctx.lineTo(x + TILE - pad - 2, y + TILE / 2);
ctx.moveTo(x + TILE / 2, y + pad + 2);
ctx.lineTo(x + TILE / 2, y + TILE - pad - 2);
ctx.stroke();
ctx.globalAlpha = 0.55 + pulse * 0.45;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 12px ui-sans-serif, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('?', x + TILE / 2, y + TILE / 2 + 1);
ctx.globalAlpha = 1;
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
}
private drawShots(ctx: CanvasRenderingContext2D): void {
for (const shot of this.shots) {
const r = 5;
ctx.fillStyle = '#ff8a3d';
ctx.beginPath();
ctx.arc(shot.x, shot.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ffe08a';
ctx.beginPath();
ctx.arc(shot.x - 1, shot.y - 1, r * 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.5;
ctx.fillStyle = '#ff5a1f';
ctx.beginPath();
ctx.arc(shot.x - Math.sign(shot.vx) * 6, shot.y, r * 0.7, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
private drawShield(ctx: CanvasRenderingContext2D): void {
const player = this.player;
const cx = player.x + PLAYER_W / 2;
const cy = player.y + PLAYER_H / 2;
const pulse = 1 + Math.sin(this.elapsed * 8) * 0.08;
ctx.strokeStyle = this.palette.playerShine;
ctx.lineWidth = 2;
ctx.globalAlpha = 0.75;
ctx.beginPath();
ctx.arc(cx, cy, (PLAYER_W * 0.95) * pulse, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
}
private drawGoal(ctx: CanvasRenderingContext2D): void {
const goal = this.level.goal;
const x = (goal.x + 0.5) * TILE;
const y = goal.y * TILE;
const base = (goal.y + 1) * TILE;
ctx.fillStyle = this.palette.goal;
ctx.fillRect(x - 1.5, y + 2, 3, TILE - 2);
ctx.fillStyle = this.palette.goalFlag;
const wave = Math.sin(this.elapsed * 5) * 1.5;
ctx.beginPath();
ctx.moveTo(x + 1.5, y + 3);
ctx.lineTo(x + 13, y + 7 + wave);
ctx.lineTo(x + 1.5, y + 12);
ctx.closePath();
ctx.fill();
ctx.fillStyle = this.palette.goal;
ctx.fillRect(x - 6, base - 3, 12, 3);
}
private drawPlayer(ctx: CanvasRenderingContext2D): void {
const player = this.player;
const w = PLAYER_W;
const h = PLAYER_H;
const cx = player.x + w / 2;
const bottom = player.y + h;
const sy = 1 + player.stretch;
const sx = 1 - player.stretch * 0.7;
const step = Math.sin(player.walk) * (player.onGround ? 1 : 0);
ctx.save();
ctx.translate(cx, bottom);
ctx.scale(sx, sy);
ctx.translate(0, -h / 2);
ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
roundRectPath(ctx, -w / 2 - 3 + step * 2, h / 2 - 3, 6, 3, 1.5);
ctx.fill();
roundRectPath(ctx, w / 2 - 3 - step * 2, h / 2 - 3, 6, 3, 1.5);
ctx.fill();
ctx.fillStyle = this.palette.player;
roundRectPath(ctx, -w / 2, -h / 2, w, h, 5);
ctx.fill();
ctx.fillStyle = this.palette.playerShine;
roundRectPath(ctx, -w / 2 + 2.5, -h / 2 + 2.5, w * 0.42, h * 0.5, 3);
ctx.fill();
const eyeY = -h / 2 + 7;
const look = player.facing * 1.4;
for (const ex of [-3.6, 3.6]) {
ctx.fillStyle = this.palette.eye;
ctx.beginPath();
ctx.arc(ex, eyeY, 3.1, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.palette.pupil;
ctx.beginPath();
ctx.arc(ex + look, eyeY + 0.4, 1.6, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
// ── Enemy rendering ─────────────────────────────────────────────────
private drawEnemy(ctx: CanvasRenderingContext2D, enemy: Enemy): void {
const w = enemy.w;
const h = enemy.h;
const cx = enemy.x + w / 2;
const bottom = enemy.y + h;
const dying = !enemy.alive;
const progress = dying ? 1 - enemy.squash / 0.3 : 0;
const scaleY = dying ? 1 - progress * 0.85 : 1;
const alpha = dying ? Math.max(0, 1 - progress) : 1;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(cx, bottom);
ctx.scale(1, scaleY);
ctx.translate(0, -h / 2);
ctx.fillStyle = 'rgba(0, 0, 0, 0.16)';
ctx.beginPath();
ctx.ellipse(0, h / 2 - 1, w * 0.5, 2.6, 0, 0, Math.PI * 2);
ctx.fill();
if (enemy.type === 'flyer') {
this.drawFlyer(ctx, enemy, w, h);
} else if (enemy.type === 'jumper') {
this.drawJumper(ctx, enemy, w, h);
} else if (enemy.type === 'brute') {
this.drawBrute(ctx, enemy, w, h);
} else if (enemy.type === 'boss') {
this.drawBoss(ctx, enemy, w, h);
} else {
this.drawWalker(ctx, enemy, w, h);
}
if (enemy.hitFlash > 0) {
ctx.globalAlpha = alpha * Math.min(1, enemy.hitFlash / 0.18) * 0.75;
ctx.fillStyle = '#ffffff';
roundRectPath(ctx, -w / 2, -h / 2, w, h, 6);
ctx.fill();
ctx.globalAlpha = alpha;
}
ctx.restore();
}
private drawEvilFace(
ctx: CanvasRenderingContext2D,
w: number,
h: number,
look: number,
): void {
const eyeY = -h * 0.12;
const eyeX = w * 0.22;
const eyeR = Math.max(2.6, w * 0.16);
for (const side of [-1, 1]) {
ctx.fillStyle = this.palette.eye;
ctx.beginPath();
ctx.arc(side * eyeX, eyeY, eyeR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = this.palette.pupil;
ctx.beginPath();
ctx.arc(side * eyeX + look, eyeY + eyeR * 0.15, eyeR * 0.52, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = this.palette.pupil;
ctx.lineWidth = Math.max(1.6, w * 0.1);
ctx.lineCap = 'round';
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * eyeX - side * eyeR, eyeY - eyeR * 1.1);
ctx.lineTo(side * eyeX + side * eyeR * 0.7, eyeY - eyeR * 1.7);
ctx.stroke();
}
ctx.beginPath();
ctx.arc(0, h * 0.22, w * 0.2, Math.PI * 1.15, Math.PI * 1.85);
ctx.stroke();
}
private drawWalker(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
const stats = ENEMY_STATS.walker;
const step = Math.sin(this.elapsed * 9 + enemy.x * 0.1) * 1.4;
ctx.fillStyle = stats.shade;
roundRectPath(ctx, -w * 0.42 + step, h / 2 - 4, w * 0.34, 4, 2);
ctx.fill();
roundRectPath(ctx, w * 0.08 - step, h / 2 - 4, w * 0.34, 4, 2);
ctx.fill();
ctx.fillStyle = stats.color;
roundRectPath(ctx, -w / 2, -h / 2, w, h, 6);
ctx.fill();
ctx.fillStyle = stats.color;
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * w * 0.34, -h / 2 + 1);
ctx.lineTo(side * w * 0.5, -h / 2 - 4);
ctx.lineTo(side * w * 0.14, -h / 2 + 1);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.22)';
roundRectPath(ctx, -w / 2 + 2, -h / 2 + 2, w * 0.36, h * 0.4, 3);
ctx.fill();
this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
}
private drawJumper(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
const stats = ENEMY_STATS.jumper;
const squash = enemy.onGround ? 1 : 0.92;
ctx.fillStyle = stats.shade;
for (let i = 0; i < 3; i += 1) {
ctx.beginPath();
ctx.ellipse(0, h / 2 - 1 - i * 2.2, w * 0.32, 1.6, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.save();
ctx.scale(1, squash);
ctx.fillStyle = stats.color;
roundRectPath(ctx, -w / 2, -h / 2 - 2, w, h + 2, 7);
ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 255, 0.24)';
roundRectPath(ctx, -w / 2 + 2, -h / 2, w * 0.36, h * 0.4, 3);
ctx.fill();
ctx.strokeStyle = stats.shade;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(0, -h / 2 - 2);
ctx.lineTo(0, -h / 2 - 6);
ctx.stroke();
ctx.fillStyle = stats.shade;
ctx.beginPath();
ctx.arc(0, -h / 2 - 7, 2.4, 0, Math.PI * 2);
ctx.fill();
this.drawEvilFace(ctx, w, h + 2, enemy.dir * 1.2);
ctx.restore();
}
private drawFlyer(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
const stats = ENEMY_STATS.flyer;
const flap = Math.sin(this.elapsed * 16 + enemy.bob) * 0.5;
ctx.fillStyle = 'rgba(255, 255, 255, 0.75)';
for (const side of [-1, 1]) {
ctx.save();
ctx.translate(side * w * 0.4, -h * 0.1);
ctx.rotate(side * (0.5 + flap));
ctx.beginPath();
ctx.ellipse(0, 0, w * 0.34, h * 0.2, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
ctx.fillStyle = stats.color;
roundRectPath(ctx, -w / 2, -h / 2, w, h, 7);
ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 255, 0.24)';
roundRectPath(ctx, -w / 2 + 2, -h / 2 + 2, w * 0.34, h * 0.4, 3);
ctx.fill();
ctx.strokeStyle = stats.shade;
ctx.lineWidth = 1.4;
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * w * 0.2, -h / 2);
ctx.lineTo(side * w * 0.3, -h / 2 - 5);
ctx.stroke();
ctx.fillStyle = stats.shade;
ctx.beginPath();
ctx.arc(side * w * 0.3, -h / 2 - 6, 1.8, 0, Math.PI * 2);
ctx.fill();
}
this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
}
private drawBrute(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
const stats = ENEMY_STATS.brute;
const angry = enemy.hp < enemy.maxHp;
ctx.fillStyle = stats.shade;
roundRectPath(ctx, -w * 0.46, h / 2 - 5, w * 0.38, 5, 2);
ctx.fill();
roundRectPath(ctx, w * 0.08, h / 2 - 5, w * 0.38, 5, 2);
ctx.fill();
ctx.fillStyle = stats.color;
roundRectPath(ctx, -w / 2, -h / 2, w, h, 8);
ctx.fill();
ctx.fillStyle = stats.shade;
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * w * 0.32, -h / 2 + 1);
ctx.lineTo(side * w * 0.52, -h / 2 - 7);
ctx.lineTo(side * w * 0.1, -h / 2 + 1);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
roundRectPath(ctx, -w / 2 + 3, -h / 2 + 3, w * 0.34, h * 0.38, 3);
ctx.fill();
this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
ctx.fillStyle = '#ffffff';
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * w * 0.16, h * 0.3);
ctx.lineTo(side * w * 0.06, h * 0.3);
ctx.lineTo(side * w * 0.11, h * 0.14);
ctx.closePath();
ctx.fill();
}
if (angry) {
ctx.fillStyle = '#ffd166';
ctx.beginPath();
ctx.arc(0, -h * 0.34, 2, 0, Math.PI * 2);
ctx.fill();
}
}
private drawBoss(ctx: CanvasRenderingContext2D, enemy: Enemy, w: number, h: number): void {
const stats = ENEMY_STATS.boss;
const hurt = enemy.hp < enemy.maxHp;
// Stubby arms.
ctx.fillStyle = stats.shade;
for (const side of [-1, 1]) {
roundRectPath(ctx, side * w * 0.5 - (side > 0 ? 0 : 6), -h * 0.15, 6, h * 0.4, 3);
ctx.fill();
}
// Feet.
roundRectPath(ctx, -w * 0.44, h / 2 - 6, w * 0.36, 6, 3);
ctx.fill();
roundRectPath(ctx, w * 0.08, h / 2 - 6, w * 0.36, 6, 3);
ctx.fill();
// Body.
ctx.fillStyle = stats.color;
roundRectPath(ctx, -w / 2, -h / 2, w, h, 10);
ctx.fill();
// Big horns.
ctx.fillStyle = stats.shade;
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * w * 0.3, -h / 2 + 1);
ctx.lineTo(side * w * 0.6, -h / 2 - 12);
ctx.lineTo(side * w * 0.06, -h / 2 + 1);
ctx.closePath();
ctx.fill();
}
// Crown when hurt (rage mode).
if (hurt) {
ctx.fillStyle = '#ffd166';
ctx.beginPath();
ctx.moveTo(-w * 0.22, -h / 2 + 2);
ctx.lineTo(-w * 0.14, -h / 2 - 8);
ctx.lineTo(-w * 0.05, -h / 2 - 1);
ctx.lineTo(0, -h / 2 - 10);
ctx.lineTo(w * 0.05, -h / 2 - 1);
ctx.lineTo(w * 0.14, -h / 2 - 8);
ctx.lineTo(w * 0.22, -h / 2 + 2);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
roundRectPath(ctx, -w / 2 + 4, -h / 2 + 4, w * 0.32, h * 0.34, 4);
ctx.fill();
this.drawEvilFace(ctx, w, h, enemy.dir * 1.2);
// Teeth.
ctx.fillStyle = '#ffffff';
for (const side of [-1, 1]) {
for (let i = 0; i < 2; i += 1) {
const tx = side * (w * 0.2) - side * i * w * 0.1;
ctx.beginPath();
ctx.moveTo(tx, h * 0.32);
ctx.lineTo(tx - side * w * 0.08, h * 0.32);
ctx.lineTo(tx - side * w * 0.04, h * 0.14);
ctx.closePath();
ctx.fill();
}
}
// Health pips.
const pipW = 5;
const total = enemy.maxHp;
const startX = -((total - 1) * pipW) / 2;
for (let i = 0; i < total; i += 1) {
ctx.fillStyle = i < enemy.hp ? '#ffd166' : 'rgba(0, 0, 0, 0.35)';
ctx.fillRect(startX + i * pipW - 2, -h / 2 - 18, 4, 4);
}
}
private drawPops(ctx: CanvasRenderingContext2D): void {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (const pop of this.pops) {
const alpha = Math.max(0, pop.life / pop.max);
ctx.globalAlpha = alpha;
ctx.fillStyle = pop.color;
ctx.font = 'bold 11px ui-sans-serif, system-ui, sans-serif';
ctx.fillText(pop.text, pop.x, pop.y);
}
ctx.globalAlpha = 1;
ctx.textAlign = 'start';
ctx.textBaseline = 'alphabetic';
}
// ── HUD ─────────────────────────────────────────────────────────────
private emitHud(force: boolean): void {
const power = this.power?.type ?? null;
const powerTime = this.power ? Math.max(0, this.power.timeLeft) : 0;
const state: HudState = {
levelIndex: this.levelIndex,
levelCount: LEVELS.length,
levelName: this.level.name,
coins: this.coinsCollected,
coinTotal: this.coinTotal,
time: this.levelTime,
deaths: this.deaths,
hearts: this.hearts,
status: this.status,
power,
powerTime,
};
const key = `${state.levelIndex}|${state.coins}|${state.coinTotal}|${state.time.toFixed(1)}|${state.deaths}|${state.hearts}|${state.status}|${power}|${powerTime.toFixed(1)}`;
if (!force && key === this.lastHudKey) return;
this.lastHudKey = key;
this.callbacks.onHud(state);
}
}
