SOURCE / PINNED RELEASE
Made of little things.
Sickman Survival
- Release
- 7335c9fdf0a8…
- Author-recorded commit
- cbd2eeafa70d…
- License
- LICENSE
- Author’s source reference
- nostr://npub1ye5ptcxfyyxl5vjvdjar2ua3f0hynkjzpx552mu5snj3qmx5pzjscpknpr/wss%3A%2F%2Fgit.napplet.soy%2F/n-8804151df7f
Archive hash verified: 591bab4addcbe5fc…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
blastProximity,
PARTICLE_DEBRIS,
PARTICLE_DUST,
PARTICLE_SMOKE,
PARTICLE_SPARK,
type Game,
type PlayerSlot,
} from './game.ts';
import { MOLE_HALF_H } from './moles.ts';
import {
BG_COLORS,
BOOK_COLORS,
CAP_SPOT,
CAVE_DARK,
CAVE_WALL,
css,
FUNGUS_GLOW,
FG_COLORS,
HILLS_FAR,
HILLS_NEAR,
INK,
INK_RIM,
METEOR_CORE,
METEOR_GLOW,
mixColor,
MOLE_BELLY,
MOLE_CLAW,
MOLE_EYE,
MOLE_FLASH,
MOLE_FUR,
MOLE_NOSE,
PLAYER_COLORS,
SCORCH,
SKY_CALM,
SKY_STORM,
WARNING,
} from './palette.ts';
import { BG_GLOW, BG_SHELF, BG_WINDOW, CAP, CELL, COLLIDE_SOLID, type Terrain } from './terrain.ts';
import type { Rect } from './roster.ts';
import { BONES, DIM, HEAD } from './stickman.ts';
/** How far above the terrain grid the camera may look (world units). */
const SKY_MARGIN = 260;
const VIEW_HEIGHT = 340;
const VIEW_WIDTH = 480;
const VIEW_WIDTH_PORTRAIT = 300;
export interface RenderOptions {
/** 0 disables screen shake. */
shake: number;
flashes: boolean;
}
/** One player's pane of the canvas and its camera. */
export interface Pane {
slot: number;
rect: Rect;
}
class View {
x = 0;
y = 0;
width = 1;
height = 1;
zoom = 1;
camX = 0;
camY = 0;
/** 0 on the surface … 1 deep underground: drives the dark and the camera framing. */
under = 0;
skyKey = '';
sky: CanvasGradient | null = null;
snapped = false;
place(rect: Rect): void {
if (rect.x === this.x && rect.y === this.y && rect.width === this.width && rect.height === this.height) return;
this.x = rect.x;
this.y = rect.y;
this.width = Math.max(1, rect.width);
this.height = Math.max(1, rect.height);
// Portrait panes trade side visibility for a closer view of the stickman.
const viewWidth = this.height > this.width ? VIEW_WIDTH_PORTRAIT : VIEW_WIDTH;
this.zoom = clamp(Math.min(this.height / VIEW_HEIGHT, this.width / viewWidth), 0.35, 4);
this.skyKey = '';
}
}
export class Renderer {
private readonly ctx: CanvasRenderingContext2D;
private readonly terrainCanvas = document.createElement('canvas');
private terrainCtx: CanvasRenderingContext2D | null = null;
private image: ImageData | null = null;
private terrain: Terrain | null = null;
private width = 1;
private height = 1;
private dpr = 1;
private readonly views = new Map<number, View>();
/** The pane being drawn. */
private v = new View();
private split = false;
private readonly stars: number[] = [];
private readonly colorCache = new Map<number, string>();
private readonly fungusSprite = glowSprite(FUNGUS_GLOW);
private readonly emberSprite = glowSprite(MOLE_EYE);
options: RenderOptions = { shake: 1, flashes: true };
constructor(private readonly canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('Canvas 2D is unavailable');
this.ctx = ctx;
for (let i = 0; i < 70; i++) this.stars.push(Math.random(), Math.random() * 0.45, Math.random());
}
resize(width: number, height: number, dpr: number): void {
this.width = Math.max(1, width);
this.height = Math.max(1, height);
this.dpr = dpr;
this.canvas.width = Math.round(this.width * dpr);
this.canvas.height = Math.round(this.height * dpr);
for (const view of this.views.values()) view.skyKey = '';
}
/** Attach a new world; every camera snaps to its player on the next frame. */
bind(game: Game): void {
const t = game.terrain;
this.terrain = t;
this.terrainCanvas.width = t.cols;
this.terrainCanvas.height = t.rows;
this.terrainCtx = this.terrainCanvas.getContext('2d');
this.image = this.terrainCtx?.createImageData(t.cols, t.rows) ?? null;
t.dirty = { x0: 0, y0: 0, x1: t.cols - 1, y1: t.rows - 1 };
this.views.clear();
}
/** Draw one pane per player; `spare` is an unused grid cell (three players in a 2×2 grid). */
render(game: Game, dt: number, panes: readonly Pane[], spare: Rect | null = null): void {
const { ctx, dpr } = this;
const t = game.terrain;
if (t !== this.terrain) this.bind(game);
this.paintTerrain(t);
this.split = panes.length > 1;
for (const slot of [...this.views.keys()]) {
if (!panes.some((pane) => pane.slot === slot)) this.views.delete(slot);
}
for (const pane of panes) {
let view = this.views.get(pane.slot);
if (!view) {
view = new View();
this.views.set(pane.slot, view);
}
view.place(pane.rect);
this.v = view;
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.beginPath();
ctx.rect(view.x, view.y, view.width, view.height);
ctx.clip();
this.renderPane(game, dt, pane.slot);
ctx.restore();
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (spare) {
ctx.fillStyle = `rgb(${CAVE_DARK})`;
ctx.fillRect(spare.x, spare.y, spare.width, spare.height);
}
if (this.split) {
// Hairline gutters between panes.
ctx.fillStyle = INK;
for (const pane of panes) {
const r = pane.rect;
if (r.x > 0) ctx.fillRect(r.x - 1, r.y, 2, r.height);
if (r.y > 0) ctx.fillRect(r.x, r.y - 1, r.width, 2);
}
}
}
private renderPane(game: Game, dt: number, slot: number): void {
const { ctx, dpr, v } = this;
const t = game.terrain;
const target = game.viewTarget(slot);
if (!v.snapped) {
v.under = this.undergroundTarget(game, slot);
v.camX = game.focusX(slot);
v.camY = this.targetY(game, slot);
v.snapped = true;
}
const lead = target?.alive ? target.body.vx * 0.3 : 0;
const ease = 1 - Math.exp(-dt * 5);
v.under += (this.undergroundTarget(game, slot) - v.under) * (1 - Math.exp(-dt * 2.5));
v.camX += (game.focusX(slot) + lead - v.camX) * ease;
v.camY += (this.targetY(game, slot) - v.camY) * ease;
this.clampCamera(t);
const zoom = v.zoom;
const storm = Math.min(1, game.stormTime() / 150);
this.drawSky(storm);
this.drawHills(HILLS_FAR, 0.2, 70, 0.0035, 1.3, storm);
this.drawHills(HILLS_NEAR, 0.45, 50, 0.006, 4.1, storm);
const shake = (target?.shake ?? 0) * this.options.shake;
const sx = (Math.random() - 0.5) * shake * zoom;
const sy = (Math.random() - 0.5) * shake * zoom;
const ox = v.width / 2 - v.camX * zoom + sx;
const oy = v.height / 2 - v.camY * zoom + sy;
const world = (): void => ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, dpr * (v.x + ox), dpr * (v.y + oy));
world();
this.drawTerrain(t, ox, oy);
this.drawParticles(game, false);
this.drawMoles(game);
// Each pane draws its own player last, so they are never hidden behind a teammate.
for (const p of game.players) if (p.slot !== slot) this.drawStickman(p, game.clock);
const own = game.player(slot);
if (own) this.drawStickman(own, game.clock);
this.drawMeteors(game);
this.drawBlasts(game);
this.drawParticles(game, true);
this.screen();
this.drawDarkness(game, slot, ox, oy);
world();
this.drawLights(game, slot, ox, oy);
this.screen();
if (this.split) this.drawTags(game, slot, ox, oy);
this.drawWarnings(game, ox, oy);
this.drawFlash(game, slot);
if (this.split && !game.player(slot)?.alive && game.alive) {
ctx.fillStyle = `rgba(${CAVE_DARK}, 0.3)`;
ctx.fillRect(0, 0, v.width, v.height);
}
}
/** Pane-local screen space, in CSS pixels. */
private screen(): void {
const { dpr, v } = this;
this.ctx.setTransform(dpr, 0, 0, dpr, dpr * v.x, dpr * v.y);
}
/** Keep more sky than ground in view (meteors come from above), but centre the stickman in caves. */
private targetY(game: Game, slot: number): number {
return game.focusY(slot) - (20 + (this.v.height / this.v.zoom) * 0.1) * (1 - this.v.under);
}
private undergroundTarget(game: Game, slot: number): number {
return clamp((game.terrain.depthAt(game.focusX(slot), game.focusY(slot)) - 4) / 22, 0, 1);
}
private clampCamera(t: Terrain): void {
const v = this.v;
const viewW = v.width / v.zoom;
const viewH = v.height / v.zoom;
v.camX = viewW >= t.width ? t.width / 2 : clamp(v.camX, viewW / 2, t.width - viewW / 2);
const top = -SKY_MARGIN + viewH / 2;
const bottom = t.height - viewH / 2;
v.camY = top >= bottom ? bottom : clamp(v.camY, top, bottom);
}
// Background ─────────────────────────────────────────────────────────────
private drawSky(storm: number): void {
const { ctx, v } = this;
const { width, height } = v;
const key = `${width}x${height}:${Math.round(storm * 40)}`;
if (key !== v.skyKey || !v.sky) {
v.skyKey = key;
const g = ctx.createLinearGradient(0, 0, 0, height);
const stops = [0, 0.45, 0.8, 1];
stops.forEach((stop, i) => g.addColorStop(stop, css(mixColor(SKY_CALM[i], SKY_STORM[i], storm))));
v.sky = g;
}
this.screen();
ctx.fillStyle = v.sky;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = 'rgba(255, 240, 220, 1)';
for (let i = 0; i < this.stars.length; i += 3) {
ctx.globalAlpha = (0.25 + this.stars[i + 2] * 0.5) * (1 - storm * 0.7);
ctx.fillRect(this.stars[i] * width, this.stars[i + 1] * height, 1.2, 1.2);
}
ctx.globalAlpha = 1;
const r = Math.min(width, height) * 0.13;
const sunX = width * 0.72 - (v.camX * v.zoom * 0.02) % width;
const sunY = height * 0.58;
const glow = ctx.createRadialGradient(sunX, sunY, r * 0.6, sunX, sunY, r * 2.6);
glow.addColorStop(0, 'rgba(255, 214, 150, 0.45)');
glow.addColorStop(1, 'rgba(255, 214, 150, 0)');
ctx.fillStyle = glow;
ctx.fillRect(sunX - r * 3, sunY - r * 3, r * 6, r * 6);
ctx.fillStyle = 'rgba(255, 232, 186, 0.92)';
ctx.beginPath();
ctx.arc(sunX, sunY, r, 0, Math.PI * 2);
ctx.fill();
}
private drawHills(color: number, factor: number, amplitude: number, freq: number, seed: number, storm: number): void {
const { ctx } = this;
const { width, height, zoom, camX, camY } = this.v;
const base = height / 2 + (360 - camY) * zoom * factor - amplitude * zoom * 0.2;
const shift = camX * zoom * factor;
ctx.fillStyle = css(mixColor(color, 0x2a0f22, storm * 0.5));
ctx.beginPath();
ctx.moveTo(0, height);
for (let x = 0; x <= width + 8; x += 8) {
const u = (x + shift) / zoom;
const y = Math.sin(u * freq + seed) * 0.6 + Math.sin(u * freq * 2.3 + seed * 2) * 0.3 + Math.sin(u * freq * 5.1) * 0.1;
ctx.lineTo(x, base - y * amplitude * zoom);
}
ctx.lineTo(width, height);
ctx.closePath();
ctx.fill();
}
// Terrain ────────────────────────────────────────────────────────────────
private paintTerrain(t: Terrain): void {
const dirty = t.dirty;
if (!dirty || !this.image || !this.terrainCtx) return;
t.dirty = null;
const data = this.image.data;
const { cols, rows, fg, bg, noise, scorch, surface } = t;
for (let cy = dirty.y0; cy <= dirty.y1; cy++) {
for (let cx = dirty.x0; cx <= dirty.x1; cx++) {
const i = cy * cols + cx;
const o = i * 4;
const m = fg[i];
const b = bg[i];
let color: number;
let shade = 1;
if (m) {
color = m === CAP && noise[i] > 214 ? CAP_SPOT : FG_COLORS[m];
const openAbove = cy === 0 || fg[i - cols] === 0;
const openSide =
(cx > 0 && fg[i - 1] === 0) || (cx < cols - 1 && fg[i + 1] === 0) || (cy < rows - 1 && fg[i + cols] === 0);
shade += ((noise[i] - 128) / 128) * 0.07;
if (openAbove) shade += 0.24;
else if (openSide) shade -= 0.3;
shade -= Math.min(0.4, Math.max(0, cy - surface[cx] - 6) * 0.004);
} else if (b) {
color = b === BG_SHELF ? BOOK_COLORS[noise[i] & 3] : BG_COLORS[b];
shade += b === BG_WINDOW || b === BG_GLOW ? 0 : ((noise[i] - 128) / 128) * 0.04;
} else if (cy > surface[cx]) {
// Open earth below the old ground line: caves, tunnels and deep craters.
color = CAVE_WALL;
shade = 0.9 + ((noise[i] - 128) / 128) * 0.12 - Math.min(0.3, (cy - surface[cx]) * 0.003);
} else {
data[o + 3] = 0;
continue;
}
if (scorch[i]) color = mixColor(color, SCORCH, (scorch[i] / 255) * 0.82);
data[o] = Math.min(255, ((color >> 16) & 255) * shade);
data[o + 1] = Math.min(255, ((color >> 8) & 255) * shade);
data[o + 2] = Math.min(255, (color & 255) * shade);
data[o + 3] = 255;
}
}
this.terrainCtx.putImageData(
this.image, 0, 0, dirty.x0, dirty.y0, dirty.x1 - dirty.x0 + 1, dirty.y1 - dirty.y0 + 1,
);
}
private drawTerrain(t: Terrain, ox: number, oy: number): void {
const { ctx } = this;
const { zoom, width, height } = this.v;
const c0 = Math.max(0, Math.floor(-ox / zoom / CELL) - 1);
const c1 = Math.min(t.cols, Math.ceil((width - ox) / zoom / CELL) + 1);
const r0 = Math.max(0, Math.floor(-oy / zoom / CELL) - 1);
const r1 = Math.min(t.rows, Math.ceil((height - oy) / zoom / CELL) + 1);
if (c1 <= c0 || r1 <= r0) return;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(this.terrainCanvas, c0, r0, c1 - c0, r1 - r0, c0 * CELL, r0 * CELL, (c1 - c0) * CELL, (r1 - r0) * CELL);
}
// Actors ─────────────────────────────────────────────────────────────────
/** Solo, the stickman is ink with a pale rim; in split screen each player wears their colour. */
private drawStickman(p: PlayerSlot, clock: number): void {
const pts = p.ragdoll ? p.ragdoll.pos : p.pose;
const ctx = this.ctx;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const layers = this.split
? [[INK, 4.6, 1.2], [PLAYER_COLORS[p.slot % PLAYER_COLORS.length], 2.5, 0]] as const
: [[INK_RIM, 4.6, 1.2], [INK, 2.5, 0]] as const;
// A fresh joiner blinks while their spawn shield lasts.
if (p.shield > 0 && Math.sin(clock * 30) > 0) ctx.globalAlpha = 0.45;
for (const [style, width, headExtra] of layers) {
ctx.strokeStyle = style;
ctx.lineWidth = width;
ctx.beginPath();
for (const [a, b] of BONES) {
ctx.moveTo(pts[a * 2], pts[a * 2 + 1]);
ctx.lineTo(pts[b * 2], pts[b * 2 + 1]);
}
ctx.stroke();
ctx.fillStyle = style;
ctx.beginPath();
ctx.arc(pts[HEAD * 2], pts[HEAD * 2 + 1], DIM.headRadius + headExtra, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
/** "P1"–"P4" over every stickman, so players can find each other in any pane. */
private drawTags(game: Game, slot: number, ox: number, oy: number): void {
const { ctx, v } = this;
ctx.font = '700 10px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.lineJoin = 'round';
const order = [...game.players.filter((p) => p.slot !== slot), ...game.players.filter((p) => p.slot === slot)];
for (const p of order) {
const pts = p.ragdoll ? p.ragdoll.pos : p.pose;
const x = clamp(ox + pts[HEAD * 2] * v.zoom, 10, v.width - 10);
const y = clamp(oy + pts[HEAD * 2 + 1] * v.zoom - (DIM.headRadius + 4) * v.zoom, 12, v.height - 2);
const label = `P${p.slot + 1}`;
ctx.globalAlpha = p.alive ? 0.95 : 0.5;
ctx.lineWidth = 3;
ctx.strokeStyle = INK;
ctx.strokeText(label, x, y);
ctx.fillStyle = PLAYER_COLORS[p.slot % PLAYER_COLORS.length];
ctx.fillText(label, x, y);
}
ctx.globalAlpha = 1;
}
private drawMeteors(game: Game): void {
const ctx = this.ctx;
ctx.globalCompositeOperation = 'lighter';
for (const m of game.meteors) {
const r = m.size * 3.2;
const g = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, r);
g.addColorStop(0, `rgba(${METEOR_GLOW}, 0.85)`);
g.addColorStop(1, `rgba(${METEOR_GLOW}, 0)`);
ctx.fillStyle = g;
ctx.fillRect(m.x - r, m.y - r, r * 2, r * 2);
ctx.strokeStyle = `rgba(${METEOR_GLOW}, 0.5)`;
ctx.lineWidth = m.size * 1.3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(m.x, m.y);
ctx.lineTo(m.x - m.vx * 0.07, m.y - m.vy * 0.07);
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
for (const m of game.meteors) {
ctx.fillStyle = '#3a2430';
ctx.beginPath();
ctx.arc(m.x, m.y, m.size, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = METEOR_CORE;
ctx.beginPath();
ctx.arc(m.x + m.vx * 0.004, m.y + m.vy * 0.004, m.size * 0.62, 0, Math.PI * 2);
ctx.fill();
}
}
private drawBlasts(game: Game): void {
const ctx = this.ctx;
for (const b of game.blasts) {
const k = b.age / 0.6;
if (b.age < 0.22) {
const r = b.radius * (1.25 - b.age * 2);
const g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, r);
g.addColorStop(0, 'rgba(255, 250, 220, 0.95)');
g.addColorStop(0.5, 'rgba(255, 160, 60, 0.8)');
g.addColorStop(1, 'rgba(220, 60, 30, 0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(b.x, b.y, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = `rgba(255, 230, 190, ${0.7 * (1 - k)})`;
ctx.lineWidth = 2.5 * (1 - k) + 0.5;
ctx.beginPath();
ctx.arc(b.x, b.y, b.radius * (0.8 + k * 2.2), 0, Math.PI * 2);
ctx.stroke();
}
}
private drawParticles(game: Game, glowing: boolean): void {
const ctx = this.ctx;
if (glowing) ctx.globalCompositeOperation = 'lighter';
for (const q of game.particles) {
const isSpark = q.kind === PARTICLE_SPARK;
if (isSpark !== glowing) continue;
const life = q.life / q.max;
ctx.fillStyle = this.color(q.color);
if (q.kind === PARTICLE_SMOKE) {
ctx.globalAlpha = 0.4 * life;
ctx.beginPath();
ctx.arc(q.x, q.y, q.size, 0, Math.PI * 2);
ctx.fill();
} else if (q.kind === PARTICLE_DEBRIS || q.kind === PARTICLE_DUST) {
ctx.globalAlpha = q.kind === PARTICLE_DUST ? 0.7 * life : Math.min(1, life * 3);
ctx.fillRect(q.x - q.size / 2, q.y - q.size / 2, q.size, q.size);
} else {
ctx.globalAlpha = life;
ctx.fillRect(q.x - q.size / 2, q.y - q.size / 2, q.size, q.size);
}
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
private drawMoles(game: Game): void {
const ctx = this.ctx;
const t = game.terrain;
for (const m of game.moles) {
if (m.state === 'burrow' || (m.state === 'armed' && t.collisionAtPoint(m.x, m.y) === COLLIDE_SOLID)) continue;
const pop = Math.min(1, m.pop / 0.2);
const fuse = m.state === 'armed' ? 1 - m.fuse / m.fuseLength : 0;
const scale = (0.7 + 0.45 * easeOutBack(pop)) * (1 + fuse * 0.5);
const walking = m.state === 'walk' && m.grounded;
const bob = walking ? Math.abs(Math.sin(m.phase)) * 0.9 : 0;
const jitter = fuse > 0 ? (Math.random() - 0.5) * fuse * 2.4 : 0;
const flash = fuse > 0 && Math.sin(game.clock * (12 + fuse * 40)) > 0;
ctx.save();
ctx.translate(m.x + jitter, m.y + MOLE_HALF_H - bob);
ctx.scale(m.facing * scale, scale);
// Fuse tail.
ctx.strokeStyle = INK;
ctx.lineWidth = 1.2;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-5.5, -4);
ctx.quadraticCurveTo(-9, -4.5, -9.5, -7.5);
ctx.stroke();
// Body, belly, snout.
ctx.fillStyle = INK;
ellipse(ctx, 0, -4.3, 7.3, 5);
ctx.fillStyle = flash ? MOLE_FLASH : MOLE_FUR;
ellipse(ctx, 0, -4.3, 6.5, 4.3);
ctx.fillStyle = flash ? '#ffb38a' : MOLE_BELLY;
ellipse(ctx, 1.2, -2.4, 4, 1.9);
ctx.fillStyle = MOLE_NOSE;
ellipse(ctx, 6.6, -3.8, 1.9, 1.4);
// Big digging claws paddle as it walks.
const paddle = walking ? Math.sin(m.phase * 2) * 1.3 : 0;
ctx.fillStyle = MOLE_CLAW;
ellipse(ctx, 3.6 + paddle, -0.6, 1.9, 1);
ellipse(ctx, -2.6 - paddle, -0.6, 1.7, 0.9);
ctx.restore();
}
}
/** Underground, the world falls into darkness beyond a pool of light around the stickman. */
private drawDarkness(game: Game, slot: number, ox: number, oy: number): void {
const { ctx } = this;
const { zoom, width, height, under } = this.v;
if (under < 0.02) return;
const px = ox + game.focusX(slot) * zoom;
const py = oy + game.focusY(slot) * zoom;
const g = ctx.createRadialGradient(px, py, 34 * zoom, px, py, 210 * zoom);
g.addColorStop(0, `rgba(${CAVE_DARK}, 0)`);
g.addColorStop(0.55, `rgba(${CAVE_DARK}, ${0.6 * under})`);
g.addColorStop(1, `rgba(${CAVE_DARK}, ${0.94 * under})`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, width, height);
}
/** Light sources that shine through the dark: fungus, mole eyes and lit fuses. */
private drawLights(game: Game, slot: number, ox: number, oy: number): void {
const { ctx } = this;
const { zoom, width, height, under } = this.v;
const t = game.terrain;
const left = -ox / zoom - 30;
const right = (width - ox) / zoom + 30;
const topEdge = -oy / zoom - 30;
const bottomEdge = (height - oy) / zoom + 30;
const visible = (x: number, y: number): boolean => x > left && x < right && y > topEdge && y < bottomEdge;
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = 0.25 + 0.45 * under;
for (const i of t.glows) {
if (t.bg[i] !== BG_GLOW) continue;
const x = ((i % t.cols) + 0.5) * CELL;
const y = (Math.floor(i / t.cols) + 0.5) * CELL;
if (!visible(x, y)) continue;
const r = 13 + Math.sin(game.clock * 1.7 + i) * 1.5;
ctx.drawImage(this.fungusSprite, x - r, y - r, r * 2, r * 2);
}
const fx = game.focusX(slot);
const fy = game.focusY(slot);
for (const m of game.moles) {
if (!visible(m.x, m.y)) continue;
const buried = m.state === 'burrow' || t.collisionAtPoint(m.x, m.y) === COLLIDE_SOLID;
if (m.state === 'armed') {
const fuse = 1 - m.fuse / m.fuseLength;
const r = 10 + fuse * 22 + Math.sin(game.clock * 30) * 2;
ctx.globalAlpha = 0.35 + fuse * 0.55;
ctx.drawImage(this.emberSprite, m.x - r, m.y - r, r * 2, r * 2);
}
// Buried moles show only as a faint pair of eyes when the stickman is near.
const eyeAlpha = buried ? Math.max(0, 1 - Math.hypot(m.x - fx, m.y - fy) / 200) * 0.45 : 1;
if (eyeAlpha <= 0.01) continue;
const ex = m.x + m.facing * 3.4;
const ey = m.y - 1.2;
ctx.globalAlpha = eyeAlpha * 0.5;
ctx.drawImage(this.emberSprite, ex - 5, ey - 5, 10, 10);
ctx.globalAlpha = eyeAlpha;
ctx.fillStyle = `rgb(${MOLE_EYE})`;
ctx.fillRect(ex - 0.8, ey - 0.8, 1.6, 1.6);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
// Screen-space overlays ──────────────────────────────────────────────────
/** Chevrons at the top edge mark meteors that are still above the view. */
private drawWarnings(game: Game, ox: number, oy: number): void {
const { ctx } = this;
const { zoom, width, height } = this.v;
for (const m of game.meteors) {
const sy = oy + m.y * zoom;
if (sy > 0) continue;
const sx = clamp(ox + m.x * zoom, 10, width - 10);
const urgency = clamp(1 + sy / (height * 1.6), 0.15, 1);
const size = 4 + m.blast * 0.12 * urgency;
ctx.fillStyle = WARNING;
ctx.globalAlpha = 0.35 + urgency * 0.6;
ctx.beginPath();
ctx.moveTo(sx, 4 + size * 1.4);
ctx.lineTo(sx - size, 4);
ctx.lineTo(sx + size, 4);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
private drawFlash(game: Game, slot: number): void {
if (!this.options.flashes) return;
const fx = game.focusX(slot);
const fy = game.focusY(slot);
let alpha = 0;
for (const b of game.blasts) {
if (b.age < 0.15) alpha = Math.max(alpha, blastProximity(b, fx, fy) * 0.45 * (1 - b.age / 0.15));
}
if (alpha <= 0.01) return;
this.ctx.fillStyle = `rgba(255, 226, 180, ${alpha})`;
this.ctx.fillRect(0, 0, this.v.width, this.v.height);
}
private color(value: number): string {
let cached = this.colorCache.get(value);
if (!cached) {
cached = css(value);
this.colorCache.set(value, cached);
}
return cached;
}
}
function ellipse(ctx: CanvasRenderingContext2D, x: number, y: number, rx: number, ry: number): void {
ctx.beginPath();
ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2);
ctx.fill();
}
function easeOutBack(k: number): number {
const c = 1.9;
return 1 + (c + 1) * (k - 1) ** 3 + c * (k - 1) ** 2;
}
/** A soft radial light, pre-rendered once and stamped with additive blending. */
function glowSprite(rgb: string): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 64;
const ctx = canvas.getContext('2d');
if (ctx) {
const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
g.addColorStop(0, `rgba(${rgb}, 0.9)`);
g.addColorStop(0.35, `rgba(${rgb}, 0.35)`);
g.addColorStop(1, `rgba(${rgb}, 0)`);
ctx.fillStyle = g;
ctx.fillRect(0, 0, 64, 64);
}
return canvas;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
