SOURCE / PINNED RELEASE
Made of little things.
blockfall
- Release
- a6bb863994d9…
- Author-recorded commit
- e6ab5899ddac…
- License
- LICENSE
- Author’s source reference
- nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-afafae55ef1
Archive hash verified: ba8de5cbee33e11e…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
export type Phase = 'ready' | 'flying' | 'reloading' | 'clear' | 'over';
export interface GameStats {
score: number;
level: number;
rockets: number;
fuel: number;
phase: Phase;
}
export interface GameBanner {
title: string;
sub: string;
tone: 'info' | 'win' | 'lose';
}
export interface GameOptions {
effects: boolean;
guide: boolean;
}
interface GameCallbacks {
onStats: (stats: GameStats) => void;
onBanner: (banner: GameBanner) => void;
}
interface Block {
x: number;
y: number;
w: number;
h: number;
angle: number;
vx: number;
vy: number;
av: number;
hp: number;
maxHp: number;
awake: boolean;
alive: boolean;
flash: number;
tone: number;
mass: number;
invMass: number;
inertia: number;
invInertia: number;
restTimer: number;
supported: boolean;
}
interface Rocket {
x: number;
y: number;
vx: number;
vy: number;
angle: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
kind: 'dust' | 'spark' | 'shard';
tone: string;
}
interface Shockwave {
x: number;
y: number;
life: number;
maxLife: number;
maxRadius: number;
}
interface TrailPoint {
x: number;
y: number;
life: number;
}
interface Floater {
x: number;
y: number;
life: number;
text: string;
}
interface Star {
x: number;
y: number;
size: number;
alpha: number;
}
interface SkylinePiece {
x: number;
w: number;
h: number;
}
const WORLD_W = 1120;
const WORLD_H = 600;
const VIEW_MIN_H = 430;
const GROUND_Y = 548;
const PAD_X = 82;
const PAD_Y = GROUND_Y - 26;
const ROCKET_RADIUS = 9;
const MAX_FUEL = 100;
const ROCKETS_PER_LEVEL = 8;
const GRAVITY = 270;
const THRUST = 700;
const LAUNCH_SPEED = 388;
const ROTATE_SPEED = 2.9;
const FUEL_RATE = 21;
const BLOCK_GRAVITY = 900;
const MAX_SHARDS = 320;
const PHOSPHOR_DIM = '#3f8a70';
const AMBER = '#ffb457';
const AMBER_HOT = '#ffe0a3';
const HAZARD = '#ff5f4a';
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
function mulberry32(seed: number): () => number {
let state = seed;
return () => {
state += 0x6d2b79f5;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function overlapX(a: Block, b: Block): number {
return Math.min(a.x + a.w / 2, b.x + b.w / 2) - Math.max(a.x - a.w / 2, b.x - b.w / 2);
}
interface Corner {
x: number;
y: number;
}
interface ContactPoint {
x: number;
y: number;
}
export class Game {
private readonly canvas: HTMLCanvasElement;
private readonly ctx: CanvasRenderingContext2D;
private readonly callbacks: GameCallbacks;
private blocks: Block[] = [];
private rocket: Rocket | null = null;
private particles: Particle[] = [];
private shocks: Shockwave[] = [];
private trail: TrailPoint[] = [];
private floaters: Floater[] = [];
private stars: Star[] = [];
private skyline: SkylinePiece[] = [];
private phase: Phase = 'ready';
private level = 1;
private score = 0;
private rockets = ROCKETS_PER_LEVEL;
private fuel = MAX_FUEL;
private time = 0;
private reloadTimer = 0;
private clearTimer = 0;
private supportTimer = 0;
private trailTimer = 0;
private shake = 0;
private flash = 0;
private effects = true;
private guide = true;
private left = false;
private right = false;
private thrustHeld = false;
private disposed = false;
private cssW = 0;
private cssH = 0;
private dpr = 1;
private resizeObserver: ResizeObserver | null = null;
private raf = 0;
private last = 0;
private statsTimer = 0;
constructor(canvas: HTMLCanvasElement, callbacks: GameCallbacks) {
this.canvas = canvas;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas 2D is unavailable');
this.ctx = ctx;
this.callbacks = callbacks;
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas);
}
this.resize();
this.seedStars();
this.startLevel(1, false);
this.callbacks.onStats(this.stats());
this.callbacks.onBanner({
title: 'SECTOR 01',
sub: 'Aim the rail · bring the skyline down',
tone: 'info',
});
this.last = performance.now();
this.raf = requestAnimationFrame(this.loop);
}
setOptions(options: GameOptions): void {
this.effects = options.effects;
this.guide = options.guide;
}
setSteer(left: boolean, right: boolean): void {
this.left = left;
this.right = right;
}
pressPrimary(): void {
if (this.phase === 'ready') {
this.launch();
this.thrustHeld = true;
} else if (this.phase === 'flying') {
this.thrustHeld = true;
}
}
releasePrimary(): void {
this.thrustHeld = false;
}
restart(): void {
this.score = 0;
this.level = 1;
this.startLevel(1, true);
}
dispose(): void {
this.disposed = true;
cancelAnimationFrame(this.raf);
this.resizeObserver?.disconnect();
}
private readonly loop = (now: number): void => {
if (this.disposed) return;
const dt = clamp((now - this.last) / 1000, 0.001, 0.05);
this.last = now;
this.update(dt);
this.draw();
this.statsTimer += dt;
if (this.statsTimer > 0.08) {
this.statsTimer = 0;
this.callbacks.onStats(this.stats());
}
this.raf = requestAnimationFrame(this.loop);
};
private stats(): GameStats {
return {
score: this.score,
level: this.level,
rockets: this.rockets,
fuel: Math.max(0, Math.round(this.fuel)),
phase: this.phase,
};
}
private resize(): void {
const width = this.canvas.clientWidth;
const height = this.canvas.clientHeight;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (width === this.cssW && height === this.cssH && dpr === this.dpr) return;
this.cssW = width;
this.cssH = height;
this.dpr = dpr;
if (width > 0 && height > 0) {
this.canvas.width = Math.round(width * dpr);
this.canvas.height = Math.round(height * dpr);
}
}
private seedStars(): void {
const rng = mulberry32(9127);
this.stars = [];
for (let i = 0; i < 110; i += 1) {
this.stars.push({
x: rng() * WORLD_W,
y: -640 + rng() * 1060,
size: 0.6 + rng() * 1.6,
alpha: 0.1 + rng() * 0.45,
});
}
}
private startLevel(level: number, announce: boolean): void {
this.level = level;
this.phase = 'ready';
this.rockets = ROCKETS_PER_LEVEL;
this.blocks = this.buildLevel(level);
this.particles = [];
this.shocks = [];
this.trail = [];
this.floaters = [];
this.deploy();
this.buildSkyline(level);
if (announce) {
this.callbacks.onBanner({
title: `SECTOR ${String(level).padStart(2, '0')}`,
sub: `${this.rockets} rockets · no survivors`,
tone: 'info',
});
}
}
private buildLevel(level: number): Block[] {
const rng = mulberry32(level * 977 + 41);
const blocks: Block[] = [];
const target = Math.min(5, 3 + Math.ceil(level / 2));
const pattern = ['scaffold', 'skybridge', 'stack', 'ziggurat', 'scaffold', 'stack', 'ziggurat'];
const limit = WORLD_W - 28;
let x = 322;
let cursor = (level - 1) % pattern.length;
for (let building = 0; building < target; building += 1) {
let style = pattern[cursor % pattern.length];
cursor += 1;
if (x + this.structureWidth(style) > limit) {
const fallbacks = ['scaffold', 'stack'];
style = fallbacks.find((candidate) => x + this.structureWidth(candidate) <= limit) ?? '';
if (!style) break;
}
let width = 0;
if (style === 'scaffold') width = this.addScaffold(blocks, x, level, rng);
else if (style === 'skybridge') width = this.addSkybridge(blocks, x, level, rng);
else if (style === 'ziggurat') width = this.addZiggurat(blocks, x, level, rng);
else width = this.addBrickStack(blocks, x, level, rng);
x += width + 12 + Math.floor(rng() * 10);
}
if (blocks.length === 0) {
for (let col = 0; col < 4; col += 1) {
this.addBlock(blocks, 540 + col * 44, GROUND_Y, 44, 24, rng);
}
}
return blocks;
}
private structureWidth(style: string): number {
if (style === 'scaffold') return 162;
if (style === 'skybridge') return 222;
if (style === 'ziggurat') return 192;
return 122;
}
private addScaffold(
blocks: Block[],
x: number,
level: number,
rng: () => number,
): number {
const span = 136 + Math.floor(rng() * 3) * 12;
const pillarW = 22;
const pillarH = 46 + Math.floor(rng() * 3) * 5;
const beamH = 16;
const floors = Math.min(4, 2 + Math.floor(level / 2));
let bottom = GROUND_Y;
for (let tier = 0; tier < floors; tier += 1) {
this.addBlock(blocks, x, bottom, pillarW, pillarH, rng);
this.addBlock(blocks, x + span - pillarW, bottom, pillarW, pillarH, rng);
this.addBlock(blocks, x, bottom - pillarH, span, beamH, rng);
const beamTop = bottom - pillarH - beamH;
const wallW = (span - pillarW * 2 - 12) / 2;
this.addBlock(blocks, x + pillarW + 4, beamTop, wallW, 22, rng);
this.addBlock(blocks, x + span - pillarW - 4 - wallW, beamTop, wallW, 22, rng);
bottom = beamTop;
}
this.addBlock(blocks, x + span / 2 - 11, bottom - 22, 22, 18, rng);
return span;
}
private addSkybridge(
blocks: Block[],
x: number,
level: number,
rng: () => number,
): number {
const span = 196 + Math.floor(rng() * 3) * 12;
const rows = 3 + Math.min(3, Math.floor(level / 2)) + (rng() < 0.4 ? 1 : 0);
const towerW = 84;
const w = 42;
const h = 22;
const towerTop = GROUND_Y - rows * h;
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < 2; col += 1) {
this.addBlock(blocks, x + col * w, GROUND_Y - row * h, w, h, rng);
this.addBlock(
blocks,
x + span - towerW + col * w,
GROUND_Y - row * h,
w,
h,
rng,
);
}
}
this.addBlock(blocks, x, towerTop, span, 16, rng);
const bridgeTop = towerTop - 16;
this.addBlock(blocks, x + span / 2 - 40, bridgeTop, 32, 24, rng);
this.addBlock(blocks, x + span / 2 - 4, bridgeTop, 32, 24, rng);
this.addBlock(blocks, x + span / 2 - 9, bridgeTop - 24, 18, 16, rng);
return span;
}
private addZiggurat(
blocks: Block[],
x: number,
level: number,
rng: () => number,
): number {
const base = 4 + (level >= 3 && rng() < 0.6 ? 1 : 0);
const w = 38;
const h = 22;
const width = base * w;
let bottom = GROUND_Y;
for (let row = 0; row < base; row += 1) {
const cols = base - row;
const left = x + (width - cols * w) / 2;
for (let col = 0; col < cols; col += 1) {
this.addBlock(blocks, left + col * w, bottom, w, h, rng);
}
bottom -= h;
}
this.addBlock(blocks, x + width / 2 - 9, bottom, 18, 18, rng);
return width;
}
private addBrickStack(
blocks: Block[],
x: number,
level: number,
rng: () => number,
): number {
const cols = 2 + (rng() < 0.55 ? 1 : 0);
const rows = 4 + Math.min(4, Math.floor(level / 2)) + Math.floor(rng() * 2);
const w = 40;
const h = 22;
const width = cols * w;
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < cols; col += 1) {
this.addBlock(blocks, x + col * w, GROUND_Y - row * h, w, h, rng);
}
}
const top = GROUND_Y - rows * h;
this.addBlock(blocks, x, top, width, 14, rng);
this.addBlock(blocks, x + 2, top - 14, 22, 18, rng);
this.addBlock(blocks, x + width - 24, top - 14, 22, 18, rng);
return width;
}
private addBlock(
blocks: Block[],
left: number,
bottom: number,
w: number,
h: number,
rng: () => number,
): void {
const hp = rng() < 0.14 ? 1.2 : 0.8;
const mass = w * h;
blocks.push({
x: left + w / 2,
y: bottom - h / 2,
w,
h,
angle: 0,
vx: 0,
vy: 0,
av: 0,
hp,
maxHp: hp,
awake: false,
alive: true,
flash: 0,
tone: Math.floor(rng() * 3),
mass,
invMass: 0,
inertia: (mass * (w * w + h * h)) / 12,
invInertia: 0,
restTimer: 0,
supported: false,
});
}
private wake(block: Block): void {
if (!block.alive || block.awake) return;
block.awake = true;
block.invMass = 1 / block.mass;
block.invInertia = 1 / block.inertia;
block.restTimer = 0;
}
private sleep(block: Block): void {
block.awake = false;
block.vx = 0;
block.vy = 0;
block.av = 0;
block.invMass = 0;
block.invInertia = 0;
block.restTimer = 0;
}
private corners(block: Block): Corner[] {
const cos = Math.cos(block.angle);
const sin = Math.sin(block.angle);
const hw = block.w / 2;
const hh = block.h / 2;
return [
{ x: block.x + hw * cos - hh * sin, y: block.y + hw * sin + hh * cos },
{ x: block.x - hw * cos - hh * sin, y: block.y - hw * sin + hh * cos },
{ x: block.x - hw * cos + hh * sin, y: block.y - hw * sin - hh * cos },
{ x: block.x + hw * cos + hh * sin, y: block.y + hw * sin - hh * cos },
];
}
private pointInBlock(px: number, py: number, block: Block): boolean {
const cos = Math.cos(-block.angle);
const sin = Math.sin(-block.angle);
const dx = px - block.x;
const dy = py - block.y;
const lx = dx * cos - dy * sin;
const ly = dx * sin + dy * cos;
return Math.abs(lx) < block.w / 2 && Math.abs(ly) < block.h / 2;
}
private obbContact(a: Block, b: Block): { nx: number; ny: number; depth: number } | null {
const axes: Array<{ x: number; y: number }> = [];
for (const block of [a, b]) {
const cos = Math.cos(block.angle);
const sin = Math.sin(block.angle);
axes.push({ x: cos, y: sin }, { x: -sin, y: cos });
}
let bestDepth = Number.POSITIVE_INFINITY;
let bestX = 0;
let bestY = 0;
const dx = a.x - b.x;
const dy = a.y - b.y;
for (const axis of axes) {
const radiusA = this.projectionRadius(a, axis.x, axis.y);
const radiusB = this.projectionRadius(b, axis.x, axis.y);
const distance = Math.abs(dx * axis.x + dy * axis.y);
const overlap = radiusA + radiusB - distance;
if (overlap <= 0) return null;
if (overlap < bestDepth) {
bestDepth = overlap;
bestX = axis.x;
bestY = axis.y;
}
}
if (dx * bestX + dy * bestY < 0) {
bestX = -bestX;
bestY = -bestY;
}
return { nx: bestX, ny: bestY, depth: bestDepth };
}
private projectionRadius(block: Block, axisX: number, axisY: number): number {
const cos = Math.cos(block.angle);
const sin = Math.sin(block.angle);
const ux = cos;
const uy = sin;
const vx = -sin;
const vy = cos;
return (
Math.abs(axisX * ux + axisY * uy) * (block.w / 2) +
Math.abs(axisX * vx + axisY * vy) * (block.h / 2)
);
}
private contactPoints(a: Block, b: Block): ContactPoint[] {
const points: ContactPoint[] = [];
const add = (x: number, y: number): void => {
for (const point of points) {
if (Math.hypot(point.x - x, point.y - y) < 1.2) return;
}
points.push({ x, y });
};
for (const corner of this.corners(a)) {
if (this.pointInBlock(corner.x, corner.y, b)) add(corner.x, corner.y);
}
for (const corner of this.corners(b)) {
if (this.pointInBlock(corner.x, corner.y, a)) add(corner.x, corner.y);
}
if (points.length === 0) {
let best = Number.POSITIVE_INFINITY;
let bestA = this.corners(a)[0];
let bestB = this.corners(b)[0];
for (const cornerA of this.corners(a)) {
for (const cornerB of this.corners(b)) {
const distance = Math.hypot(cornerA.x - cornerB.x, cornerA.y - cornerB.y);
if (distance < best) {
best = distance;
bestA = cornerA;
bestB = cornerB;
}
}
}
add((bestA.x + bestB.x) / 2, (bestA.y + bestB.y) / 2);
}
return points.slice(0, 2);
}
private buildSkyline(level: number): void {
const rng = mulberry32(level * 3301 + 7);
this.skyline = [];
let x = -20;
while (x < WORLD_W + 40) {
const w = 34 + rng() * 86;
const h = 26 + rng() * 104;
this.skyline.push({ x, w, h });
x += w + 5 + rng() * 14;
}
}
private deploy(): void {
this.rocket = {
x: PAD_X,
y: PAD_Y,
vx: 0,
vy: 0,
angle: -0.62,
};
this.phase = 'ready';
this.fuel = MAX_FUEL;
this.trail = [];
this.thrustHeld = false;
this.trailTimer = 0;
}
private launch(): void {
const rocket = this.rocket;
if (!rocket || this.phase !== 'ready') return;
rocket.vx = Math.cos(rocket.angle) * LAUNCH_SPEED;
rocket.vy = Math.sin(rocket.angle) * LAUNCH_SPEED;
this.rockets = Math.max(0, this.rockets - 1);
this.phase = 'flying';
this.trail = [];
this.shake = Math.max(this.shake, 2);
this.spawnBurst(rocket.x, rocket.y, 8, 80, 'dust');
this.callbacks.onStats(this.stats());
}
private update(dt: number): void {
this.time += dt;
this.shake = Math.max(0, this.shake - dt * 26);
this.flash = Math.max(0, this.flash - dt * 2.6);
this.updateParticles(dt);
this.updateBlocks(dt);
if (this.phase === 'ready') {
const rocket = this.rocket;
if (rocket) {
const steer = (this.right ? 1 : 0) - (this.left ? 1 : 0);
rocket.angle += steer * ROTATE_SPEED * dt;
}
} else if (this.phase === 'flying') {
this.updateFlight(dt);
} else if (this.phase === 'reloading') {
this.reloadTimer -= dt;
if (this.reloadTimer <= 0) {
if (this.blocks.every((block) => !block.alive)) {
this.winLevel();
} else if (this.rockets <= 0) {
this.gameOver();
} else {
this.deploy();
}
}
} else if (this.phase === 'clear') {
this.clearTimer -= dt;
if (this.clearTimer <= 0) {
this.startLevel(this.level + 1, true);
}
}
this.supportTimer += dt;
if (this.supportTimer > 0.07) {
this.supportTimer = 0;
this.checkSupport();
}
if (
(this.phase === 'flying' || this.phase === 'ready' || this.phase === 'reloading') &&
this.blocks.length > 0 &&
this.blocks.every((block) => !block.alive)
) {
this.winLevel();
}
for (const block of this.blocks) {
block.flash = Math.max(0, block.flash - dt * 3.5);
}
}
private updateFlight(dt: number): void {
const rocket = this.rocket;
if (!rocket) {
this.finishSortie();
return;
}
const steer = (this.right ? 1 : 0) - (this.left ? 1 : 0);
rocket.angle += steer * ROTATE_SPEED * dt;
if (this.thrustHeld && this.fuel > 0) {
rocket.vx += Math.cos(rocket.angle) * THRUST * dt;
rocket.vy += Math.sin(rocket.angle) * THRUST * dt;
this.fuel = Math.max(0, this.fuel - FUEL_RATE * dt);
if (this.effects) this.spawnExhaust(rocket);
}
rocket.vy += GRAVITY * dt;
const drag = Math.pow(0.9995, dt * 60);
rocket.vx *= drag;
rocket.vy *= drag;
const speed = Math.hypot(rocket.vx, rocket.vy);
if (speed > 920) {
rocket.vx = (rocket.vx / speed) * 920;
rocket.vy = (rocket.vy / speed) * 920;
}
rocket.x += rocket.vx * dt;
rocket.y += rocket.vy * dt;
this.trailTimer -= dt;
if (this.trailTimer <= 0 && this.effects) {
this.trail.push({ x: rocket.x, y: rocket.y, life: 1 });
if (this.trail.length > 60) this.trail.shift();
this.trailTimer = 0.028;
}
this.collideBlocks(rocket);
if (!this.rocket) return;
if (rocket.y + ROCKET_RADIUS >= GROUND_Y) {
this.explode(rocket.x, GROUND_Y - 5, 1.5);
return;
}
if (rocket.x > WORLD_W + 140 || rocket.x < -180 || rocket.y < -900) {
this.rocket = null;
this.finishSortie();
}
}
private collideBlocks(rocket: Rocket): void {
for (const block of this.blocks) {
if (!block.alive) continue;
const cos = Math.cos(block.angle);
const sin = Math.sin(block.angle);
const dx = rocket.x - block.x;
const dy = rocket.y - block.y;
const lx = dx * cos + dy * sin;
const ly = -dx * sin + dy * cos;
const hw = block.w / 2;
const hh = block.h / 2;
const nearestX = clamp(lx, -hw, hw);
const nearestY = clamp(ly, -hh, hh);
const offX = lx - nearestX;
const offY = ly - nearestY;
const dist = Math.hypot(offX, offY);
if (dist >= ROCKET_RADIUS) continue;
const pointX = block.x + nearestX * cos - nearestY * sin;
const pointY = block.y + nearestX * sin + nearestY * cos;
this.explode(pointX, pointY, 1.85);
return;
}
}
private updateBlocks(dt: number): void {
const blocks = this.blocks;
let awake = 0;
for (const block of blocks) {
if (!block.alive || !block.awake) continue;
awake += 1;
block.vy += BLOCK_GRAVITY * dt;
block.x += block.vx * dt;
block.y += block.vy * dt;
block.angle += block.av * dt;
block.vx *= Math.pow(0.999, dt * 60);
block.vy *= Math.pow(0.9995, dt * 60);
block.av *= Math.pow(0.985, dt * 60);
block.av = clamp(block.av, -9, 9);
block.supported = false;
}
if (awake === 0) return;
for (let iteration = 0; iteration < 6; iteration += 1) {
const firstPass = iteration === 0;
for (const block of blocks) {
if (!block.alive || !block.awake) continue;
this.resolveGround(block, firstPass);
}
for (let i = 0; i < blocks.length; i += 1) {
const a = blocks[i];
if (!a.alive || !a.awake) continue;
for (let j = 0; j < blocks.length; j += 1) {
if (i === j) continue;
const b = blocks[j];
if (!b.alive) continue;
if (b.awake && j < i) continue;
this.resolvePair(a, b, firstPass);
}
}
}
for (const block of blocks) {
if (!block.alive || !block.awake) continue;
const speed = Math.hypot(block.vx, block.vy);
if (block.supported && speed < 11 && Math.abs(block.av) < 0.24) {
block.restTimer += dt;
if (block.restTimer > 0.5) this.sleep(block);
} else {
block.restTimer = 0;
}
}
}
private resolveGround(block: Block, allowDamage: boolean): void {
const corners = this.corners(block);
const hits = corners.filter((corner) => corner.y > GROUND_Y);
if (hits.length === 0) return;
let penetration = 0;
for (const corner of hits) {
penetration = Math.max(penetration, corner.y - GROUND_Y);
}
block.y -= penetration;
block.supported = true;
for (const corner of hits) {
this.applyContact(block, null, corner.x, GROUND_Y, 0, -1, 0.1, allowDamage);
}
}
private resolvePair(a: Block, b: Block, allowDamage: boolean): void {
if (!a.alive || !b.alive) return;
const contact = this.obbContact(a, b);
if (!contact) return;
const { nx, ny, depth } = contact;
const closure = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (!b.awake && closure < -90) this.wake(b);
const totalInv = a.invMass + b.invMass;
if (totalInv <= 0) return;
const correction = (Math.min(depth, 9) * 0.55) / totalInv;
a.x += nx * correction * a.invMass;
a.y += ny * correction * a.invMass;
if (b.awake) {
b.x -= nx * correction * b.invMass;
b.y -= ny * correction * b.invMass;
}
for (const point of this.contactPoints(a, b)) {
this.applyContact(a, b, point.x, point.y, nx, ny, 0.03, allowDamage);
}
}
private applyContact(
a: Block,
b: Block | null,
px: number,
py: number,
nx: number,
ny: number,
restitution: number,
allowDamage: boolean,
): void {
if (!a.alive) return;
const dynamicB = b !== null && b.awake && b.alive;
const invMassB = dynamicB ? b.invMass : 0;
const invInertiaB = dynamicB ? b.invInertia : 0;
const rAx = px - a.x;
const rAy = py - a.y;
const rBx = b ? px - b.x : 0;
const rBy = b ? py - b.y : 0;
const vAx = a.vx - a.av * rAy;
const vAy = a.vy + a.av * rAx;
const vBx = dynamicB ? b.vx - b.av * rBy : 0;
const vBy = dynamicB ? b.vy + b.av * rBx : 0;
const rvx = vAx - vBx;
const rvy = vAy - vBy;
const vn = rvx * nx + rvy * ny;
if (vn > 0) return;
const impact = -vn;
const rnA = rAx * ny - rAy * nx;
const rnB = rBx * ny - rBy * nx;
const effective =
a.invMass + invMassB + rnA * rnA * a.invInertia + rnB * rnB * invInertiaB;
if (effective <= 0) return;
const bounce = impact > 120 ? restitution : 0;
const j = (-(1 + bounce) * vn) / effective;
a.vx += j * nx * a.invMass;
a.vy += j * ny * a.invMass;
a.av += rnA * j * a.invInertia;
if (dynamicB) {
b.vx -= j * nx * b.invMass;
b.vy -= j * ny * b.invMass;
b.av -= rnB * j * b.invInertia;
}
const tx = -ny;
const ty = nx;
const rvTx = a.vx - a.av * rAy - (dynamicB ? b.vx - b.av * rBy : 0);
const rvTy = a.vy + a.av * rAx - (dynamicB ? b.vy + b.av * rBx : 0);
const vt = rvTx * tx + rvTy * ty;
const rtA = rAx * ty - rAy * tx;
const rtB = rBx * ty - rBy * tx;
const effectiveT =
a.invMass + invMassB + rtA * rtA * a.invInertia + rtB * rtB * invInertiaB;
if (effectiveT > 0) {
const limit = 0.62 * Math.abs(j);
const jt = clamp(-vt / effectiveT, -limit, limit);
a.vx += jt * tx * a.invMass;
a.vy += jt * ty * a.invMass;
a.av += rtA * jt * a.invInertia;
if (dynamicB) {
b.vx -= jt * tx * b.invMass;
b.vy -= jt * ty * b.invMass;
b.av -= rtB * jt * b.invInertia;
}
}
if (ny < -0.25) a.supported = true;
if (dynamicB && ny > 0.25) b.supported = true;
if (allowDamage && impact > 300) {
this.damageBlock(a, (impact - 300) * 0.0016, px, py, 0);
if (b && b.alive) {
this.damageBlock(b, (impact - 300) * 0.001, px, py, 0);
}
}
}
private checkSupport(): void {
for (const block of this.blocks) {
if (!block.alive || block.awake) continue;
if (block.y + block.h / 2 >= GROUND_Y - 2) continue;
const bottom = block.y + block.h / 2;
let supported = false;
for (const other of this.blocks) {
if (other === block || !other.alive) continue;
const top = other.y - other.h / 2;
if (Math.abs(top - bottom) < 3 && overlapX(block, other) > 5) {
supported = true;
break;
}
}
if (!supported) {
this.wake(block);
block.av = (Math.random() - 0.5) * 0.35;
if (this.effects && this.particles.length < MAX_SHARDS - 4) {
this.spawnBurst(block.x, block.y + block.h / 2, 2, 26, 'dust');
}
}
}
}
private damageArea(
x: number,
y: number,
radius: number,
power: number,
impulse: number,
): void {
for (const block of this.blocks) {
if (!block.alive) continue;
const distance = Math.hypot(block.x - x, block.y - y);
if (distance > radius + Math.max(block.w, block.h) * 0.45) continue;
const falloff = clamp(1 - distance / (radius * 1.5), 0.22, 1);
this.damageBlock(block, power * falloff, x, y, impulse * falloff);
}
}
private damageBlock(block: Block, amount: number, originX: number, originY: number, impulse: number): void {
if (!block.alive) return;
block.hp -= amount;
block.flash = 1;
this.wake(block);
if (impulse > 0) {
const dx = block.x - originX;
const dy = block.y - originY;
const distance = Math.hypot(dx, dy) || 1;
const massFactor = clamp(900 / block.mass, 0.35, 1.5);
block.vx += (dx / distance) * impulse * 0.55 * massFactor;
block.vy += (dy / distance) * impulse * 0.55 * massFactor - impulse * 0.04;
block.av += (Math.random() - 0.5) * impulse * 0.0035 * massFactor;
}
if (block.hp <= 0) this.destroyBlock(block);
}
private destroyBlock(block: Block): void {
if (!block.alive) return;
block.alive = false;
this.score += 100;
for (const other of this.blocks) {
if (!other.alive || other === block) continue;
const dx = other.x - block.x;
const dy = other.y - block.y;
if (Math.hypot(dx, dy) < 96) this.wake(other);
}
if (this.effects) {
this.spawnBurst(block.x, block.y, 7, 150, 'shard');
}
if (this.floaters.length < 18) {
this.floaters.push({
x: block.x,
y: block.y,
life: 0.85,
text: '+100',
});
}
}
private explode(x: number, y: number, power: number): void {
this.rocket = null;
this.shake = Math.max(this.shake, 18 * Math.min(power, 2));
this.flash = Math.max(this.flash, Math.min(1, 0.48 * power));
if (this.effects) {
this.shocks.push({
x,
y,
life: 0.65,
maxLife: 0.65,
maxRadius: 120 + 70 * power,
});
this.shocks.push({
x,
y,
life: 0.34,
maxLife: 0.34,
maxRadius: 55 + 40 * power,
});
this.spawnBurst(x, y, Math.min(46, Math.round(24 * power)), 330 * power, 'spark');
this.spawnBurst(x, y, Math.min(26, Math.round(15 * power)), 165 * power, 'dust');
this.spawnBurst(x, y, Math.min(18, Math.round(10 * power)), 260 * power, 'shard');
}
this.damageArea(x, y, 100 + 50 * power, 0.62 * power, 360 * power);
this.finishSortie();
}
private finishSortie(): void {
if (this.phase !== 'flying') return;
this.thrustHeld = false;
if (this.blocks.length > 0 && this.blocks.every((block) => !block.alive)) {
this.winLevel();
return;
}
this.phase = 'reloading';
this.reloadTimer = this.rockets <= 0 ? 2.6 : 1;
}
private winLevel(): void {
if (this.phase === 'clear' || this.phase === 'over') return;
const bonus = 500 + this.rockets * 150;
this.score += bonus;
this.phase = 'clear';
this.clearTimer = 2.4;
this.rocket = null;
this.callbacks.onStats(this.stats());
this.callbacks.onBanner({
title: 'SECTOR CLEAR',
sub: `+${bonus} bonus · next sector inbound`,
tone: 'win',
});
}
private gameOver(): void {
this.phase = 'over';
this.rocket = null;
this.callbacks.onStats(this.stats());
this.callbacks.onBanner({
title: 'RANGE CLOSED',
sub: `Out of rockets · ${this.score} points`,
tone: 'lose',
});
}
private spawnExhaust(rocket: Rocket): void {
const back = rocket.angle + Math.PI;
const spread = (Math.random() - 0.5) * 0.5;
const speed = 90 + Math.random() * 120;
this.particles.push({
x: rocket.x + Math.cos(back) * 10,
y: rocket.y + Math.sin(back) * 10,
vx: rocket.vx * 0.35 + Math.cos(back + spread) * speed,
vy: rocket.vy * 0.35 + Math.sin(back + spread) * speed,
life: 0.28,
maxLife: 0.28,
size: 2 + Math.random() * 3,
kind: 'dust',
tone: Math.random() < 0.3 ? AMBER_HOT : AMBER,
});
}
private spawnBurst(x: number, y: number, count: number, speed: number, kind: Particle['kind']): void {
for (let i = 0; i < count; i += 1) {
if (this.particles.length > MAX_SHARDS) break;
const angle = Math.random() * Math.PI * 2;
const velocity = speed * (0.3 + Math.random() * 0.8);
this.particles.push({
x,
y,
vx: Math.cos(angle) * velocity,
vy: Math.sin(angle) * velocity - (kind === 'dust' ? 40 : 20),
life: 0.4 + Math.random() * 0.7,
maxLife: 1.1,
size: kind === 'shard' ? 2 + Math.random() * 4 : 1.5 + Math.random() * 2.5,
kind,
tone:
kind === 'spark'
? Math.random() < 0.4
? AMBER_HOT
: HAZARD
: kind === 'shard'
? Math.random() < 0.5
? PHOSPHOR_DIM
: '#8fb7a4'
: '#6d8c7d',
});
}
}
private updateParticles(dt: number): void {
for (const particle of this.particles) {
particle.life -= dt;
particle.vy += (particle.kind === 'dust' ? 120 : 620) * dt;
particle.x += particle.vx * dt;
particle.y += particle.vy * dt;
particle.vx *= Math.pow(0.985, dt * 60);
if (particle.y > GROUND_Y) {
particle.y = GROUND_Y;
particle.vy *= -0.25;
particle.vx *= 0.8;
}
}
this.particles = this.particles.filter((particle) => particle.life > 0);
for (const shock of this.shocks) shock.life -= dt;
this.shocks = this.shocks.filter((shock) => shock.life > 0);
for (const point of this.trail) point.life -= dt * 0.9;
this.trail = this.trail.filter((point) => point.life > 0);
for (const floater of this.floaters) {
floater.life -= dt;
floater.y -= dt * 26;
}
this.floaters = this.floaters.filter((floater) => floater.life > 0);
}
private draw(): void {
if (this.cssW < 2 || this.cssH < 2) return;
const { ctx } = this;
const scale = Math.min(this.cssW / WORLD_W, this.cssH / VIEW_MIN_H);
const ox = (this.cssW - WORLD_W * scale) / 2;
const ty = this.cssH - WORLD_H * scale;
const topWorld = -ty / scale;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#03080a';
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
ctx.setTransform(this.dpr * scale, 0, 0, this.dpr * scale, this.dpr * ox, this.dpr * ty);
const shakeX = this.shake > 0.1 ? (Math.random() - 0.5) * this.shake : 0;
const shakeY = this.shake > 0.1 ? (Math.random() - 0.5) * this.shake : 0;
ctx.translate(shakeX, shakeY);
this.drawSky(topWorld);
this.drawSkyline();
this.drawGround();
if (this.guide) this.drawGuide();
this.drawPad();
this.drawBlocks();
this.drawTrail();
if (this.rocket) this.drawRocket(this.rocket);
this.drawParticles();
this.drawShocks();
this.drawFloaters();
if (this.flash > 0.01) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = `rgba(255,214,150,${(this.flash * 0.22).toFixed(3)})`;
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
}
private drawSky(topWorld: number): void {
const { ctx } = this;
const bottom = GROUND_Y + 20;
const gradient = ctx.createLinearGradient(0, Math.min(topWorld, -200), 0, bottom);
gradient.addColorStop(0, '#040b0f');
gradient.addColorStop(0.62, '#081a1a');
gradient.addColorStop(1, '#0d2a23');
ctx.fillStyle = gradient;
ctx.fillRect(-200, topWorld - 40, WORLD_W + 400, bottom - topWorld + 60);
ctx.fillStyle = '#9ce9c6';
for (const star of this.stars) {
if (star.y < topWorld || star.y > GROUND_Y - 60) continue;
ctx.globalAlpha = star.alpha;
ctx.fillRect(star.x, star.y, star.size, star.size);
}
ctx.globalAlpha = 1;
ctx.strokeStyle = 'rgba(125,243,184,0.05)';
ctx.lineWidth = 1;
const startX = 0;
for (let x = startX; x <= WORLD_W; x += 50) {
ctx.beginPath();
ctx.moveTo(x, topWorld);
ctx.lineTo(x, GROUND_Y);
ctx.stroke();
}
const gridTop = Math.floor(topWorld / 50) * 50;
for (let y = gridTop; y <= GROUND_Y; y += 50) {
ctx.globalAlpha = y % 200 === 0 ? 0.75 : 0.45;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(WORLD_W, y);
ctx.stroke();
}
ctx.globalAlpha = 1;
const ringX = 816;
const ringY = 128;
ctx.strokeStyle = 'rgba(125,243,184,0.09)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(ringX, ringY, 48, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(ringX - 58, ringY);
ctx.lineTo(ringX + 58, ringY);
ctx.moveTo(ringX, ringY - 58);
ctx.lineTo(ringX, ringY + 58);
ctx.stroke();
const glare = ctx.createRadialGradient(220, 300, 10, 220, 300, 460);
glare.addColorStop(0, 'rgba(125,243,184,0.05)');
glare.addColorStop(1, 'rgba(125,243,184,0)');
ctx.fillStyle = glare;
ctx.fillRect(0, topWorld, WORLD_W, GROUND_Y - topWorld);
}
private drawSkyline(): void {
const { ctx } = this;
ctx.fillStyle = '#081614';
for (const piece of this.skyline) {
ctx.fillRect(piece.x, GROUND_Y - piece.h, piece.w, piece.h);
}
ctx.fillStyle = 'rgba(125,243,184,0.16)';
for (const piece of this.skyline) {
if (piece.h < 52) continue;
ctx.fillRect(piece.x, GROUND_Y - piece.h, piece.w, 1);
}
ctx.strokeStyle = 'rgba(125,243,184,0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, GROUND_Y - 0.5);
ctx.lineTo(WORLD_W, GROUND_Y - 0.5);
ctx.stroke();
}
private drawGround(): void {
const { ctx } = this;
const gradient = ctx.createLinearGradient(0, GROUND_Y, 0, WORLD_H);
gradient.addColorStop(0, '#0b1f1a');
gradient.addColorStop(1, '#050d0c');
ctx.fillStyle = gradient;
ctx.fillRect(0, GROUND_Y, WORLD_W, WORLD_H - GROUND_Y + 80);
ctx.strokeStyle = 'rgba(125,243,184,0.55)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, GROUND_Y);
ctx.lineTo(WORLD_W, GROUND_Y);
ctx.stroke();
ctx.fillStyle = 'rgba(255,180,87,0.45)';
for (let x = 8; x < WORLD_W; x += 44) {
ctx.fillRect(x, GROUND_Y + 7, 18, 2.5);
}
ctx.strokeStyle = 'rgba(125,243,184,0.13)';
ctx.lineWidth = 1;
for (let x = 120; x < WORLD_W; x += 120) {
ctx.beginPath();
ctx.moveTo(x, GROUND_Y + 1);
ctx.lineTo(x - 12, GROUND_Y + 20);
ctx.stroke();
}
}
private drawGuide(): void {
const rocket = this.rocket;
if (this.phase !== 'ready' || !rocket) return;
const { ctx } = this;
let x = rocket.x;
let y = rocket.y;
let vx = Math.cos(rocket.angle) * LAUNCH_SPEED;
let vy = Math.sin(rocket.angle) * LAUNCH_SPEED;
const step = 0.06;
for (let i = 0; i < 70; i += 1) {
x += vx * step;
y += vy * step;
vy += GRAVITY * step;
if (y > GROUND_Y || x > WORLD_W + 40 || x < -40) break;
if (i % 3 === 0) {
const alpha = 0.5 * (1 - i / 70);
ctx.fillStyle = `rgba(255,180,87,${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.arc(x, y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
}
private drawPad(): void {
const { ctx } = this;
ctx.fillStyle = '#102720';
ctx.fillRect(PAD_X - 24, GROUND_Y - 6, 48, 6);
ctx.strokeStyle = PHOSPHOR_DIM;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(PAD_X - 20, GROUND_Y - 6);
ctx.lineTo(PAD_X - 20, GROUND_Y - 14);
ctx.lineTo(PAD_X + 20, GROUND_Y - 14);
ctx.lineTo(PAD_X + 20, GROUND_Y - 6);
ctx.stroke();
ctx.fillStyle = 'rgba(255,180,87,0.5)';
ctx.fillRect(PAD_X - 16, GROUND_Y - 12, 8, 2);
ctx.fillRect(PAD_X - 4, GROUND_Y - 12, 8, 2);
ctx.fillRect(PAD_X + 8, GROUND_Y - 12, 8, 2);
ctx.strokeStyle = 'rgba(125,243,184,0.3)';
ctx.beginPath();
ctx.moveTo(PAD_X + 10, GROUND_Y - 18);
ctx.lineTo(PAD_X + 26, GROUND_Y - 18);
ctx.stroke();
}
private drawBlocks(): void {
const { ctx } = this;
for (const block of this.blocks) {
if (!block.alive) continue;
const damage = 1 - block.hp / block.maxHp;
const w = block.w;
const h = block.h;
ctx.save();
ctx.translate(block.x, block.y);
ctx.rotate(block.angle);
ctx.fillStyle =
block.tone === 0 ? '#143129' : block.tone === 1 ? '#183a30' : '#112a25';
ctx.fillRect(-w / 2, -h / 2, w, h);
ctx.strokeStyle = damage > 0.55 ? 'rgba(255,95,74,0.65)' : 'rgba(125,243,184,0.38)';
ctx.lineWidth = 1;
ctx.strokeRect(-w / 2 + 0.75, -h / 2 + 0.75, w - 1.5, h - 1.5);
ctx.fillStyle = 'rgba(125,243,184,0.22)';
ctx.fillRect(-w / 2 + 2, -h / 2 + 2, w - 4, 1);
if (w > 26) {
ctx.fillStyle = 'rgba(8,20,17,0.6)';
ctx.fillRect(-w / 2 + 4, h * 0.02, w - 8, 1);
ctx.fillStyle = 'rgba(125,243,184,0.2)';
ctx.fillRect(-w / 2 + 5, h * 0.02 + 2, 2, 2);
ctx.fillRect(w / 2 - 8, h * 0.02 + 2, 2, 2);
}
if (damage > 0.28) {
ctx.strokeStyle = 'rgba(255,95,74,0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(-w * 0.28, -h / 2 + 2);
ctx.lineTo(-w * 0.12, -h * 0.05);
ctx.lineTo(-w * 0.2, h / 2 - 2);
ctx.stroke();
}
if (damage > 0.6) {
ctx.beginPath();
ctx.moveTo(w * 0.2, -h / 2 + 1);
ctx.lineTo(w * 0.08, h * 0.1);
ctx.lineTo(w * 0.28, h / 2 - 2);
ctx.stroke();
}
if (block.flash > 0) {
ctx.fillStyle = `rgba(255,224,163,${(block.flash * 0.22).toFixed(3)})`;
ctx.fillRect(-w / 2, -h / 2, w, h);
}
ctx.restore();
}
}
private drawTrail(): void {
if (this.trail.length < 2) return;
const { ctx } = this;
ctx.strokeStyle = 'rgba(255,180,87,0.16)';
ctx.lineWidth = 2;
ctx.beginPath();
const first = this.trail[0];
ctx.moveTo(first.x, first.y);
for (const point of this.trail) ctx.lineTo(point.x, point.y);
ctx.stroke();
for (const point of this.trail) {
ctx.fillStyle = `rgba(255,196,120,${(point.life * 0.25).toFixed(3)})`;
ctx.beginPath();
ctx.arc(point.x, point.y, 1.8, 0, Math.PI * 2);
ctx.fill();
}
}
private drawRocket(rocket: Rocket): void {
const { ctx } = this;
ctx.save();
ctx.translate(rocket.x, rocket.y);
ctx.rotate(rocket.angle);
if (this.phase === 'flying' && this.thrustHeld && this.fuel > 0) {
const flame = 14 + Math.random() * 12 + Math.sin(this.time * 40) * 3;
const gradient = ctx.createLinearGradient(-8, 0, -8 - flame, 0);
gradient.addColorStop(0, '#fff2c9');
gradient.addColorStop(0.4, AMBER);
gradient.addColorStop(1, 'rgba(255,95,74,0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.moveTo(-8, -4.5);
ctx.lineTo(-8 - flame, 0);
ctx.lineTo(-8, 4.5);
ctx.closePath();
ctx.fill();
}
ctx.fillStyle = '#23372f';
ctx.beginPath();
ctx.moveTo(-8, -4);
ctx.lineTo(-13, -11);
ctx.lineTo(-6, -7);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.moveTo(-8, 4);
ctx.lineTo(-13, 11);
ctx.lineTo(-6, 7);
ctx.closePath();
ctx.fill();
ctx.fillStyle = AMBER_HOT;
ctx.beginPath();
ctx.moveTo(14, 0);
ctx.lineTo(2, 6.4);
ctx.lineTo(-7, 6);
ctx.lineTo(-9, 0);
ctx.lineTo(-7, -6);
ctx.lineTo(2, -6.4);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#8a4c12';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#2b6f7c';
ctx.beginPath();
ctx.arc(3, 0, 2.1, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.35 + 0.15 * Math.sin(this.time * 8);
ctx.strokeStyle = AMBER;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(0, 0, 15, 0, Math.PI * 2);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
}
private drawParticles(): void {
const { ctx } = this;
for (const particle of this.particles) {
const alpha = clamp(particle.life / Math.max(particle.maxLife, 0.001), 0, 1);
ctx.globalAlpha = alpha;
if (particle.kind === 'spark') {
ctx.strokeStyle = particle.tone;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(particle.x, particle.y);
ctx.lineTo(particle.x - particle.vx * 0.02, particle.y - particle.vy * 0.02);
ctx.stroke();
} else if (particle.kind === 'shard') {
ctx.fillStyle = particle.tone;
ctx.fillRect(
particle.x - particle.size / 2,
particle.y - particle.size / 2,
particle.size,
particle.size * 0.7,
);
} else {
ctx.fillStyle = particle.tone;
ctx.beginPath();
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1;
}
private drawShocks(): void {
const { ctx } = this;
for (const shock of this.shocks) {
const progress = 1 - shock.life / shock.maxLife;
ctx.globalAlpha = (1 - progress) * 0.7;
ctx.strokeStyle = progress < 0.4 ? AMBER_HOT : HAZARD;
ctx.lineWidth = 2 * (1 - progress) + 0.5;
ctx.beginPath();
ctx.arc(shock.x, shock.y, shock.maxRadius * progress, 0, Math.PI * 2);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
private drawFloaters(): void {
const { ctx } = this;
ctx.font = '600 12px "SFMono-Regular", ui-monospace, monospace';
ctx.textAlign = 'center';
for (const floater of this.floaters) {
ctx.globalAlpha = clamp(floater.life / 0.85, 0, 1);
ctx.fillStyle = AMBER_HOT;
ctx.fillText(floater.text, floater.x, floater.y);
}
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
}
}
