SOURCE / PINNED RELEASE
Made of little things.
Truffle Rush
- Release
- bf9d57a12f82…
- Author-recorded commit
- bf11c370d93c…
- License
- LICENSE
- Author’s source reference
- nostr://npub1dmmh0futyszl4445mv4xrygkgsgj8vpu79yx3qyy9qxmz4pemtzqlqmc87/wss%3A%2F%2Fgit.napplet.soy%2F/n-dad0644224b
Archive hash verified: 261cbd0dce1d32f5…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { onSettingsChanged, type GameSettings } from './napplet-settings.js';
import './styles.css';
/* ─────────────────────────────────────────────────────────────────────
Truffle Rush — a moonlit truffle-orchard arcade.
Catch snacks, dodge crows, fill the belly for a golden frenzy.
Everything is drawn locally; no network, no remote assets.
───────────────────────────────────────────────────────────────────── */
type Phase = 'ready' | 'playing' | 'paused' | 'over';
type ItemKind = 'acorn' | 'truffle' | 'golden' | 'crow';
type OverlayMode = 'start' | 'pause' | 'over';
interface Item {
kind: ItemKind;
x: number;
y: number;
vy: number;
drift: number;
phase: number;
spin: number;
r: number;
}
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
size: number;
color: string;
gravity: number;
}
interface Floater {
x: number;
y: number;
text: string;
life: number;
maxLife: number;
color: string;
}
interface Firefly {
baseX: number;
baseY: number;
phase: number;
speed: number;
amp: number;
}
const FRENZY_TIME = 5;
const BEST_KEY = 'truffle-rush:best';
const MUTE_KEY = 'truffle-rush:muted';
const elements = {
app: requireElement<HTMLElement>('#app'),
canvas: requireElement<HTMLCanvasElement>('#game'),
score: requireElement<HTMLElement>('#score'),
best: requireElement<HTMLElement>('#best'),
hearts: requireElement<HTMLElement>('#hearts'),
combo: requireElement<HTMLElement>('#combo'),
toast: requireElement<HTMLElement>('#toast'),
bellyFill: requireElement<HTMLElement>('#bellyFill'),
overlay: requireElement<HTMLElement>('#overlay'),
overlayKicker: requireElement<HTMLElement>('#overlayKicker'),
overlayTitle: requireElement<HTMLElement>('#overlayTitle'),
overlayText: requireElement<HTMLElement>('#overlayText'),
overlayStats: requireElement<HTMLElement>('#overlayStats'),
playButton: requireElement<HTMLButtonElement>('#playButton'),
hint: requireElement<HTMLElement>('#hint'),
soundButton: requireElement<HTMLButtonElement>('#soundButton'),
pauseButton: requireElement<HTMLButtonElement>('#pauseButton'),
};
const canvas = elements.canvas;
const context2d = canvas.getContext('2d');
if (!context2d) throw new Error('Canvas 2D context is unavailable');
const ctx: CanvasRenderingContext2D = context2d;
const game = {
phase: 'ready' as Phase,
w: 0,
h: 0,
groundY: 0,
unit: 1,
pigW: 64,
pigH: 46,
pigX: 0,
pigTarget: 0,
pigVX: 0,
mouth: 0,
blink: 0,
invuln: 0,
score: 0,
best: 0,
bestBefore: 0,
hearts: 3,
belly: 0,
combo: 0,
maxCombo: 0,
level: 1,
foodCaught: 0,
frenzy: 0,
elapsed: 0,
time: 0,
spawnTimer: 0.6,
shake: 0,
flash: 0,
items: [] as Item[],
particles: [] as Particle[],
floaters: [] as Floater[],
fireflies: [] as Firefly[],
keys: { left: false, right: false },
pointerDown: false,
};
let settings: GameSettings = { sound: true, screenShake: true };
let muted = false;
let configSoundEnabled = true;
let audio: AudioContext | null = null;
let background: HTMLCanvasElement | null = null;
let toastTimer = 0;
let bestDirty = false;
let lastFrame = performance.now();
let resizeObserver: ResizeObserver | null = null;
const reduceMotion =
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const hud = {
score: -1,
best: -1,
hearts: -1,
mult: -1,
frenzy: -1,
belly: -1,
phase: '' as Phase | '',
};
/* ── Utilities ───────────────────────────────────────────────────────── */
function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function rand(min: number, max: number): number {
return min + Math.random() * (max - min);
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function multiplier(): number {
return Math.min(5, 1 + Math.floor(game.combo / 5));
}
/* ── Sound ──────────────────────────────────────────────────────────── */
function soundEnabled(): boolean {
return !muted && configSoundEnabled;
}
function ensureAudio(): boolean {
if (!soundEnabled()) return false;
try {
audio ??= new AudioContext();
if (audio.state === 'suspended') void audio.resume();
return true;
} catch {
return false;
}
}
function playTone(
freq: number,
duration = 0.1,
type: OscillatorType = 'triangle',
volume = 0.05,
slideTo?: number,
delay = 0,
): void {
if (!ensureAudio() || !audio) return;
const ac = audio;
const start = ac.currentTime + delay;
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.type = type;
osc.frequency.setValueAtTime(Math.max(30, freq), start);
if (slideTo !== undefined) {
osc.frequency.exponentialRampToValueAtTime(Math.max(30, slideTo), start + duration);
}
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(volume, start + 0.012);
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
osc.connect(gain);
gain.connect(ac.destination);
osc.start(start);
osc.stop(start + duration + 0.03);
}
function soundCatch(kind: ItemKind): void {
if (kind === 'acorn') {
playTone(520 + Math.min(game.combo, 14) * 22, 0.08, 'triangle', 0.05, 780);
} else if (kind === 'truffle') {
playTone(560, 0.08, 'triangle', 0.055);
playTone(840, 0.1, 'triangle', 0.045, 1080, 0.05);
} else if (kind === 'golden') {
playTone(660, 0.1, 'sine', 0.06);
playTone(990, 0.1, 'sine', 0.05, undefined, 0.06);
playTone(1320, 0.16, 'sine', 0.045, undefined, 0.12);
}
}
function soundMiss(): void {
playTone(230, 0.1, 'sine', 0.025, 170);
}
function soundHurt(): void {
playTone(200, 0.18, 'sawtooth', 0.05, 70);
playTone(120, 0.22, 'triangle', 0.04, 55, 0.03);
}
function soundFrenzy(): void {
playTone(520, 0.12, 'square', 0.035);
playTone(660, 0.12, 'square', 0.035, undefined, 0.1);
playTone(880, 0.12, 'square', 0.035, undefined, 0.2);
playTone(1170, 0.22, 'triangle', 0.05, undefined, 0.3);
}
function soundGameOver(): void {
playTone(440, 0.16, 'triangle', 0.05, undefined, 0);
playTone(330, 0.18, 'triangle', 0.05, undefined, 0.16);
playTone(220, 0.34, 'triangle', 0.05, 140, 0.34);
}
function soundStart(): void {
playTone(392, 0.1, 'triangle', 0.04);
playTone(587, 0.14, 'triangle', 0.045, undefined, 0.09);
}
/* ── Persistence (optional storage domain) ──────────────────────────── */
function persist(key: string, value: string): void {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
void storage.setItem(key, value).catch(() => undefined);
} catch {
// A present domain may still deny an operation; the game stays playable.
}
}
async function loadProgress(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
const [bestRaw, muteRaw] = await Promise.all([
storage.getItem(BEST_KEY),
storage.getItem(MUTE_KEY),
]);
const storedBest = Number(bestRaw);
if (Number.isFinite(storedBest) && storedBest > 0) {
game.best = Math.floor(storedBest);
}
if (muteRaw === '1') muted = true;
updateSoundButton();
updateHud(true);
if (game.phase === 'ready') showOverlay('start');
} catch {
// Optional persistence is unavailable; defaults remain.
}
}
function saveBest(): void {
if (!bestDirty) return;
bestDirty = false;
persist(BEST_KEY, String(game.best));
}
/* ── Sizing and background ──────────────────────────────────────────── */
function resize(): void {
const rect = canvas.getBoundingClientRect();
const w = Math.max(1, rect.width);
const h = Math.max(1, rect.height);
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const pixelW = Math.round(w * dpr);
const pixelH = Math.round(h * dpr);
if (pixelW === canvas.width && pixelH === canvas.height && background) {
return;
}
game.w = w;
game.h = h;
canvas.width = pixelW;
canvas.height = pixelH;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
game.groundY = h - clamp(h * 0.12, 14, 58);
game.unit = clamp(Math.min(w / 500, h / 700), 0.7, 1.7);
game.pigW = clamp(w * 0.2, 44, 120);
game.pigH = game.pigW * 0.72;
if (game.pigX === 0 && game.pigTarget === 0) {
game.pigX = w / 2;
game.pigTarget = w / 2;
} else {
game.pigX = clamp(game.pigX, game.pigW / 2, w - game.pigW / 2);
game.pigTarget = clamp(game.pigTarget, game.pigW / 2, w - game.pigW / 2);
}
for (const item of game.items) {
item.x = clamp(item.x, item.r, w - item.r);
item.y = Math.min(item.y, h + item.r);
}
game.fireflies = Array.from({ length: 9 }, (_, index) => {
const seed = mulberry32(9001 + index * 131);
return {
baseX: 0.08 + seed() * 0.84,
baseY: 0.2 + seed() * 0.58,
phase: seed() * Math.PI * 2,
speed: 0.35 + seed() * 0.6,
amp: 6 + seed() * 14,
};
});
background = buildBackground(w, h, dpr);
}
function hillPath(
b: CanvasRenderingContext2D,
w: number,
h: number,
baseY: number,
amp: number,
color: string,
phase: number,
): void {
b.fillStyle = color;
b.beginPath();
b.moveTo(0, baseY);
const step = Math.max(8, w / 48);
for (let x = 0; x <= w + step; x += step) {
const y =
baseY -
Math.sin((x / w) * Math.PI * 2 + phase) * amp -
Math.sin((x / w) * Math.PI * 4 + phase * 1.7) * amp * 0.32;
b.lineTo(x, y);
}
b.lineTo(w, h);
b.lineTo(0, h);
b.closePath();
b.fill();
}
function pine(
b: CanvasRenderingContext2D,
x: number,
baseY: number,
size: number,
color: string,
): void {
b.fillStyle = color;
b.fillRect(x - size * 0.06, baseY - size * 0.3, size * 0.12, size * 0.32);
for (let layer = 0; layer < 3; layer += 1) {
const layerY = baseY - size * (0.18 + layer * 0.23);
const half = size * (0.34 - layer * 0.07);
b.beginPath();
b.moveTo(x, layerY - size * 0.34);
b.lineTo(x + half, layerY);
b.lineTo(x - half, layerY);
b.closePath();
b.fill();
}
}
function buildBackground(w: number, h: number, dpr: number): HTMLCanvasElement {
const bg = document.createElement('canvas');
bg.width = Math.round(w * dpr);
bg.height = Math.round(h * dpr);
const b = bg.getContext('2d');
if (!b) return bg;
b.setTransform(dpr, 0, 0, dpr, 0, 0);
const sky = b.createLinearGradient(0, 0, 0, h);
sky.addColorStop(0, '#0e0622');
sky.addColorStop(0.45, '#231138');
sky.addColorStop(0.78, '#3a1b4c');
sky.addColorStop(1, '#472357');
b.fillStyle = sky;
b.fillRect(0, 0, w, h);
const rng = mulberry32(1337 + Math.round(w) * 7 + Math.round(h) * 13);
// Stars
b.fillStyle = '#ffeccb';
for (let i = 0; i < 64; i += 1) {
const x = rng() * w;
const y = rng() * Math.max(16, game.groundY * 0.72);
const r = 0.4 + rng() * 1.15;
b.globalAlpha = 0.2 + rng() * 0.65;
b.beginPath();
b.arc(x, y, r, 0, Math.PI * 2);
b.fill();
}
b.globalAlpha = 1;
// Moon and its halo
const mx = w * 0.77;
const my = Math.max(26, h * 0.2);
const mr = Math.max(13, Math.min(w, h) * 0.085);
const halo = b.createRadialGradient(mx, my, mr * 0.35, mx, my, mr * 3.4);
halo.addColorStop(0, 'rgba(255, 226, 176, 0.3)');
halo.addColorStop(0.5, 'rgba(255, 208, 150, 0.1)');
halo.addColorStop(1, 'rgba(255, 208, 150, 0)');
b.fillStyle = halo;
b.beginPath();
b.arc(mx, my, mr * 3.4, 0, Math.PI * 2);
b.fill();
b.fillStyle = '#ffe9c2';
b.beginPath();
b.arc(mx, my, mr, 0, Math.PI * 2);
b.fill();
b.fillStyle = 'rgba(207, 168, 116, 0.34)';
const craters: Array<[number, number, number]> = [
[-0.35, -0.2, 0.24],
[0.22, 0.3, 0.18],
[0.34, -0.38, 0.12],
[-0.1, 0.5, 0.1],
];
for (const [cx, cy, cr] of craters) {
b.beginPath();
b.arc(mx + cx * mr, my + cy * mr, cr * mr, 0, Math.PI * 2);
b.fill();
}
// Distant hills and pines
hillPath(b, w, h, game.groundY + h * 0.02, h * 0.075, '#2b1643', 0.8);
hillPath(b, w, h, game.groundY + h * 0.05, h * 0.055, '#241139', 2.4);
for (let i = 0; i < 5; i += 1) {
const tx = w * (0.08 + rng() * 0.84);
pine(b, tx, game.groundY + 2, Math.max(12, h * (0.035 + rng() * 0.035)), '#1b0d2e');
}
// Ground
const soil = b.createLinearGradient(0, game.groundY, 0, h);
soil.addColorStop(0, '#341b45');
soil.addColorStop(0.45, '#261234');
soil.addColorStop(1, '#1a0c26');
b.fillStyle = soil;
b.fillRect(0, game.groundY, w, h - game.groundY);
b.strokeStyle = '#7a4792';
b.lineWidth = Math.max(1, h * 0.005);
b.beginPath();
b.moveTo(0, game.groundY + 0.5);
b.lineTo(w, game.groundY + 0.5);
b.stroke();
// Grass tufts
const tuftCount = Math.max(8, Math.floor(w / 26));
for (let i = 0; i < tuftCount; i += 1) {
const x = (i + 0.5) * (w / tuftCount) + (rng() - 0.5) * 8;
const height = 2 + rng() * 4;
b.strokeStyle = i % 3 === 0 ? '#9a63b5' : '#5c3275';
b.lineWidth = 1.2;
b.beginPath();
b.moveTo(x, game.groundY + 1);
b.lineTo(x + (rng() - 0.5) * 3, game.groundY + 1 - height);
b.stroke();
}
// A few glowing orchard flowers
for (let i = 0; i < 4; i += 1) {
const x = (0.12 + rng() * 0.76) * w;
const y = game.groundY + 6 + rng() * Math.max(4, (h - game.groundY) * 0.45);
b.fillStyle = 'rgba(255, 207, 107, 0.55)';
b.beginPath();
b.arc(x, y, 1.4, 0, Math.PI * 2);
b.fill();
}
const vignette = b.createRadialGradient(
w / 2,
h * 0.45,
Math.min(w, h) * 0.18,
w / 2,
h * 0.45,
Math.max(w, h) * 0.78,
);
vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
vignette.addColorStop(1, 'rgba(6, 2, 14, 0.55)');
b.fillStyle = vignette;
b.fillRect(0, 0, w, h);
return bg;
}
/* ── HUD and overlays ───────────────────────────────────────────────── */
function bump(element: HTMLElement): void {
if (reduceMotion) return;
element.classList.remove('bump');
void element.offsetWidth;
element.classList.add('bump');
}
function updateHearts(): void {
if (hud.hearts === game.hearts) return;
hud.hearts = game.hearts;
const lives: HTMLElement[] = [];
for (let i = 0; i < 3; i += 1) {
const span = document.createElement('span');
span.className = i < game.hearts ? 'life' : 'life off';
span.textContent = '\u2665\uFE0E';
span.setAttribute('aria-hidden', 'true');
lives.push(span);
}
elements.hearts.replaceChildren(...lives);
elements.hearts.setAttribute(
'aria-label',
`${game.hearts} ${game.hearts === 1 ? 'life' : 'lives'} remaining`,
);
}
function updateHud(force = false): void {
const score = game.score;
const best = game.best;
const mult = multiplier();
if (force || score !== hud.score) {
if (!force && score > hud.score && hud.score >= 0) bump(elements.score);
elements.score.textContent = String(score);
hud.score = score;
}
if (force || best !== hud.best) {
elements.best.textContent = String(best);
hud.best = best;
}
updateHearts();
const frenzyOn = game.frenzy > 0;
if (force || mult !== hud.mult || Number(frenzyOn) !== hud.frenzy) {
elements.combo.hidden = mult <= 1 && !frenzyOn;
elements.combo.textContent = frenzyOn ? `Frenzy ×2` : `×${mult}`;
elements.combo.dataset.hot = String(frenzyOn);
hud.mult = mult;
hud.frenzy = Number(frenzyOn);
elements.app.classList.toggle('is-frenzy', frenzyOn);
}
const belly = frenzyOn ? clamp(game.frenzy / FRENZY_TIME, 0, 1) : game.belly;
const percent = Math.round(belly * 100);
if (force || percent !== hud.belly) {
elements.bellyFill.style.width = `${percent}%`;
hud.belly = percent;
}
if (force || hud.phase !== game.phase) {
elements.pauseButton.disabled = game.phase !== 'playing' && game.phase !== 'paused';
elements.pauseButton.setAttribute(
'aria-label',
game.phase === 'paused' ? 'Resume' : 'Pause',
);
elements.pauseButton.querySelector('span')!.textContent =
game.phase === 'paused' ? '\u25B6\uFE0E' : '\u275A\u275A';
hud.phase = game.phase;
canvas.style.cursor = game.phase === 'playing' ? 'crosshair' : 'default';
}
}
function updateSoundButton(): void {
const enabled = soundEnabled();
elements.soundButton.classList.toggle('muted', !enabled);
elements.soundButton.disabled = !configSoundEnabled;
if (!configSoundEnabled) {
elements.soundButton.setAttribute('aria-label', 'Sound disabled in settings');
elements.soundButton.title = 'Sound disabled in settings';
return;
}
elements.soundButton.setAttribute('aria-label', enabled ? 'Mute sound' : 'Enable sound');
elements.soundButton.title = enabled ? 'Sound on (M)' : 'Sound off (M)';
}
function miniStat(label: string, value: number): HTMLElement {
const box = document.createElement('div');
box.className = 'mini-stat';
const labelEl = document.createElement('span');
labelEl.className = 'mini-label';
labelEl.textContent = label;
const valueEl = document.createElement('span');
valueEl.className = 'mini-value';
valueEl.textContent = String(value);
box.append(labelEl, valueEl);
return box;
}
function showOverlay(mode: OverlayMode): void {
elements.overlay.hidden = false;
elements.overlay.dataset.mode = mode;
elements.overlayStats.hidden = true;
elements.overlayStats.replaceChildren();
if (mode === 'start') {
elements.overlayKicker.textContent = 'Moonlit piggy arcade';
elements.overlayTitle.textContent = 'Truffle Rush';
elements.overlayText.textContent =
'Catch the falling snacks. Dodge the crows. Fill your belly for a golden frenzy.';
elements.playButton.textContent = 'Play';
elements.hint.hidden = false;
if (game.best > 0) {
elements.overlayStats.hidden = false;
elements.overlayStats.replaceChildren(miniStat('Best', game.best));
}
} else if (mode === 'pause') {
elements.overlayKicker.textContent = 'Take a breath';
elements.overlayTitle.textContent = 'Paused';
elements.overlayText.textContent = 'The truffles can wait. Your pig is safe here.';
elements.playButton.textContent = 'Resume';
elements.hint.hidden = true;
} else {
const newBest = game.score > game.bestBefore;
elements.overlayKicker.textContent = newBest ? 'A new orchard record' : 'Snout out';
elements.overlayTitle.textContent = newBest ? 'New best!' : 'Snout out!';
elements.overlayText.textContent = newBest
? `You snuffled up ${game.score} points. The orchard will remember that snout.`
: `The orchard got the better of you at ${game.score} points. One more run?`;
elements.playButton.textContent = 'Play again';
elements.hint.hidden = true;
elements.overlayStats.hidden = false;
elements.overlayStats.replaceChildren(
miniStat('Score', game.score),
miniStat('Best', game.best),
miniStat('Max combo', game.maxCombo),
);
}
}
function showToast(message: string): void {
window.clearTimeout(toastTimer);
elements.toast.textContent = message;
elements.toast.classList.remove('show');
void elements.toast.offsetWidth;
elements.toast.classList.add('show');
toastTimer = window.setTimeout(() => {
elements.toast.classList.remove('show');
}, 1400);
}
/* ── Game state ─────────────────────────────────────────────────────── */
function startGame(): void {
ensureAudio();
game.phase = 'playing';
game.score = 0;
game.bestBefore = game.best;
game.hearts = 3;
game.belly = 0;
game.combo = 0;
game.maxCombo = 0;
game.level = 1;
game.foodCaught = 0;
game.frenzy = 0;
game.elapsed = 0;
game.spawnTimer = 0.55;
game.invuln = 1;
game.mouth = 0;
game.shake = 0;
game.flash = 0;
game.items = [];
game.particles = [];
game.floaters = [];
game.keys.left = false;
game.keys.right = false;
game.pigX = game.w / 2;
game.pigTarget = game.pigX;
elements.overlay.hidden = true;
elements.playButton.blur();
elements.toast.classList.remove('show');
updateHud(true);
soundStart();
}
function gameOver(): void {
game.phase = 'over';
game.frenzy = 0;
game.shake = reduceMotion ? 0 : 10;
elements.app.classList.remove('is-frenzy');
if (game.score > game.best) {
game.best = game.score;
bestDirty = true;
saveBest();
}
updateHud(true);
showOverlay('over');
soundGameOver();
}
function togglePause(): void {
if (game.phase === 'playing') {
game.phase = 'paused';
showOverlay('pause');
} else if (game.phase === 'paused') {
game.phase = 'playing';
elements.overlay.hidden = true;
elements.playButton.blur();
lastFrame = performance.now();
}
updateHud(true);
}
function toggleSound(): void {
if (!configSoundEnabled) return;
muted = !muted;
persist(MUTE_KEY, muted ? '1' : '0');
updateSoundButton();
if (!muted) {
ensureAudio();
playTone(660, 0.08, 'triangle', 0.04);
}
}
/* ── Spawning ───────────────────────────────────────────────────────── */
function spawnItem(): void {
const roll = Math.random();
let kind: ItemKind;
if (game.frenzy > 0) {
if (roll < 0.14) kind = 'golden';
else if (roll < 0.72) kind = 'truffle';
else kind = 'acorn';
} else {
const crowChance = Math.min(0.29, 0.07 + game.level * 0.027);
if (roll < crowChance) kind = 'crow';
else if (roll < crowChance + 0.03) kind = 'golden';
else if (roll < crowChance + 0.03 + 0.24) kind = 'truffle';
else kind = 'acorn';
}
const unit = game.unit;
const radius =
kind === 'acorn'
? 8 * unit
: kind === 'truffle'
? 10 * unit
: kind === 'golden'
? 11 * unit
: 14 * unit;
const margin = radius + 7;
const x = game.w <= margin * 2 ? game.w / 2 : rand(margin, game.w - margin);
const base = game.h * (0.3 + game.level * 0.012) * (game.frenzy > 0 ? 1.18 : 1);
const vy =
kind === 'crow'
? base * rand(1.02, 1.18)
: base * rand(0.84, 1.14);
game.items.push({
kind,
x,
y: -radius - 6,
vy,
drift: kind === 'crow' ? rand(-26, 26) : rand(-8, 8),
phase: rand(0, Math.PI * 2),
spin: rand(-1.6, 1.6),
r: radius,
});
}
function spawnInterval(): number {
if (game.frenzy > 0) return rand(0.24, 0.36);
const base = Math.max(0.34, 0.95 - game.level * 0.05);
return base * rand(0.74, 1.24);
}
/* ── Effects ────────────────────────────────────────────────────────── */
function burst(x: number, y: number, colors: string[], count: number, power: number): void {
const amount = reduceMotion ? Math.ceil(count / 2) : count;
for (let i = 0; i < amount; i += 1) {
const angle = rand(0, Math.PI * 2);
const speed = rand(power * 0.3, power);
game.particles.push({
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - power * 0.25,
life: rand(0.35, 0.8),
maxLife: 0.8,
size: rand(1.5, 4) * game.unit,
color: colors[Math.floor(Math.random() * colors.length)],
gravity: 260,
});
}
}
function addFloater(x: number, y: number, text: string, color: string): void {
game.floaters.push({
x: clamp(x, 28, game.w - 28),
y,
text,
life: 0.85,
maxLife: 0.85,
color,
});
}
function triggerFrenzy(): void {
game.frenzy = FRENZY_TIME;
game.belly = 0;
game.score += 100;
game.shake = settings.screenShake && !reduceMotion ? 9 : 0;
game.flash = 0.35;
addFloater(game.pigX, game.groundY - game.pigH * 1.5, '+100', '#ffd97a');
showToast('Truffle frenzy!');
soundFrenzy();
burst(game.pigX, game.groundY - game.pigH * 0.6, ['#ffe9a8', '#ffcf6b', '#fff6d8'], 26, 260);
for (let i = game.items.length - 1; i >= 0; i -= 1) {
const item = game.items[i];
if (item.kind === 'crow') {
burst(item.x, item.y, ['#3a2148', '#241231', '#7a4d8f'], 6, 140);
game.items.splice(i, 1);
}
}
}
function catchFood(item: Item): void {
const previousMult = multiplier();
game.combo += 1;
game.maxCombo = Math.max(game.maxCombo, game.combo);
game.foodCaught += 1;
game.mouth = 0.22;
const mult = multiplier();
const base = item.kind === 'acorn' ? 5 : item.kind === 'truffle' ? 25 : 75;
const points = Math.round(base * mult * (game.frenzy > 0 ? 2 : 1));
game.score += points;
const bellyGain = item.kind === 'acorn' ? 0.085 : item.kind === 'truffle' ? 0.2 : 0.45;
if (game.frenzy <= 0) {
game.belly = Math.min(1, game.belly + bellyGain);
}
const colors =
item.kind === 'golden'
? ['#fff3c4', '#ffcf6b', '#ffb03e']
: item.kind === 'truffle'
? ['#e7b07f', '#9a6244', '#ffcf6b']
: ['#e0a968', '#9c6a3d', '#ffd98f'];
burst(item.x, item.y, colors, item.kind === 'acorn' ? 7 : 12, item.kind === 'golden' ? 240 : 170);
addFloater(
item.x,
item.y - 8,
`+${points}`,
item.kind === 'golden' ? '#ffe9a8' : item.kind === 'truffle' ? '#ffd98a' : '#ffe9cf',
);
if (item.kind === 'golden' && game.frenzy > 0) {
game.frenzy = Math.min(FRENZY_TIME + 2, game.frenzy + 1.2);
}
soundCatch(item.kind);
if (mult > previousMult) {
addFloater(game.pigX, game.groundY - game.pigH * 1.35, `×${mult}`, '#ffcf6b');
if (mult >= 3) {
showToast(`×${mult} snout streak`);
}
}
if (game.belly >= 1 && game.frenzy <= 0) triggerFrenzy();
game.level = Math.min(9, 1 + Math.floor(game.foodCaught / 12));
}
function hitByCrow(item: Item): void {
game.hearts -= 1;
game.combo = 0;
game.invuln = 1.5;
game.shake = settings.screenShake && !reduceMotion ? 13 : 0;
game.flash = 0.55;
burst(item.x, item.y, ['#2b1739', '#472a5e', '#ff7d86'], 14, 210);
soundHurt();
if (game.hearts <= 0) gameOver();
}
/* ── Simulation ─────────────────────────────────────────────────────── */
function updateGame(dt: number): void {
game.elapsed += dt;
game.mouth = Math.max(0, game.mouth - dt);
game.invuln = Math.max(0, game.invuln - dt);
game.flash = Math.max(0, game.flash - dt);
game.shake = Math.max(0, game.shake - dt * 34);
if (game.frenzy > 0) {
game.frenzy = Math.max(0, game.frenzy - dt);
if (game.frenzy === 0) showToast('Frenzy over');
}
// Movement: keyboard nudges the target, pointer/touch sets it directly.
const maxTargetSpeed = game.w * 1.5 + 180;
if (game.keys.left) game.pigTarget -= maxTargetSpeed * dt;
if (game.keys.right) game.pigTarget += maxTargetSpeed * dt;
game.pigTarget = clamp(game.pigTarget, game.pigW / 2, game.w - game.pigW / 2);
const maxStep = (game.w * 1.8 + 260) * dt;
const step = clamp(game.pigTarget - game.pigX, -maxStep, maxStep);
game.pigX += step;
game.pigVX = game.pigVX * 0.8 + (step / Math.max(dt, 0.001)) * 0.2;
// Spawning
game.spawnTimer -= dt;
if (game.spawnTimer <= 0 && game.items.length < 46) {
spawnItem();
game.spawnTimer = spawnInterval();
}
const pigCenterY = game.groundY - game.pigH * 0.38;
const halfW = game.pigW * 0.56;
const halfH = game.pigH * 0.62;
for (let i = game.items.length - 1; i >= 0; i -= 1) {
const item = game.items[i];
item.y += item.vy * dt;
item.x += item.drift * dt + Math.sin(game.elapsed * 2.2 + item.phase) * 10 * dt;
item.x = clamp(item.x, item.r, game.w - item.r);
item.spin += (item.kind === 'crow' ? 2.4 : 1.2) * dt;
const hit =
Math.abs(item.x - game.pigX) < halfW + item.r * 0.6 &&
Math.abs(item.y - pigCenterY) < halfH + item.r * 0.6;
if (hit) {
if (item.kind === 'crow') {
if (game.invuln <= 0) {
game.items.splice(i, 1);
hitByCrow(item);
if (game.phase !== 'playing') return;
}
} else {
game.items.splice(i, 1);
catchFood(item);
}
continue;
}
if (item.y > game.groundY + item.r * 0.4) {
game.items.splice(i, 1);
if (item.kind === 'crow') {
burst(item.x, game.groundY, ['#33204a', '#4c2d63'], 5, 110);
} else {
game.combo = 0;
burst(item.x, game.groundY, ['#4a2a5e', '#2c153f'], 4, 90);
soundMiss();
}
}
}
}
function updateEffects(dt: number): void {
for (let i = game.particles.length - 1; i >= 0; i -= 1) {
const p = game.particles[i];
p.life -= dt;
if (p.life <= 0) {
game.particles.splice(i, 1);
continue;
}
p.vy += p.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
}
for (let i = game.floaters.length - 1; i >= 0; i -= 1) {
const f = game.floaters[i];
f.life -= dt;
if (f.life <= 0) {
game.floaters.splice(i, 1);
continue;
}
f.y -= 34 * dt;
}
if (game.particles.length > 240) {
game.particles.splice(0, game.particles.length - 240);
}
}
/* ── Rendering ──────────────────────────────────────────────────────── */
function drawAcorn(x: number, y: number, r: number, rotation: number): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation * 0.25);
ctx.fillStyle = '#d99a58';
ctx.beginPath();
ctx.ellipse(0, r * 0.18, r * 0.72, r * 0.82, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(55, 26, 30, 0.7)';
ctx.lineWidth = Math.max(1, r * 0.12);
ctx.stroke();
ctx.fillStyle = '#7a4426';
ctx.beginPath();
ctx.ellipse(0, -r * 0.38, r * 0.82, r * 0.44, 0, Math.PI, Math.PI * 2);
ctx.fill();
ctx.strokeRect(-r * 0.06, -r * 1.12, r * 0.12, r * 0.3);
ctx.fillStyle = 'rgba(255, 240, 210, 0.5)';
ctx.beginPath();
ctx.arc(-r * 0.26, r * 0.05, r * 0.16, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function lumpyTruffle(x: number, y: number, r: number, fill: string, speckle: string): void {
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(x - r * 0.3, y + r * 0.1, r * 0.68, 0, Math.PI * 2);
ctx.arc(x + r * 0.34, y + r * 0.05, r * 0.6, 0, Math.PI * 2);
ctx.arc(x, y - r * 0.32, r * 0.72, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = speckle;
const dots: Array<[number, number, number]> = [
[-0.36, 0.24, 0.1],
[0.28, 0.34, 0.08],
[0.05, -0.28, 0.09],
[-0.08, 0.42, 0.07],
[0.42, -0.12, 0.07],
];
for (const [dx, dy, dr] of dots) {
ctx.beginPath();
ctx.arc(x + dx * r, y + dy * r, dr * r, 0, Math.PI * 2);
ctx.fill();
}
}
function drawTruffle(x: number, y: number, r: number, golden: boolean): void {
ctx.save();
if (golden) {
ctx.shadowColor = 'rgba(255, 196, 90, 0.9)';
ctx.shadowBlur = r * 1.7;
ctx.fillStyle = 'rgba(255, 220, 130, 0.22)';
ctx.beginPath();
ctx.arc(x, y, r * 1.5, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
lumpyTruffle(x, y, r, '#ffce62', '#fff1b8');
ctx.strokeStyle = 'rgba(255, 248, 214, 0.9)';
} else {
ctx.shadowColor = 'rgba(255, 193, 110, 0.35)';
ctx.shadowBlur = r * 0.9;
lumpyTruffle(x, y, r, '#8a573d', '#d8a274');
ctx.strokeStyle = 'rgba(255, 219, 176, 0.35)';
}
ctx.lineWidth = Math.max(1, r * 0.1);
ctx.beginPath();
ctx.arc(x - r * 0.15, y - r * 0.1, r * 0.85, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
if (golden) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(game.time * 1.4);
ctx.strokeStyle = 'rgba(255, 249, 214, 0.9)';
ctx.lineWidth = 1.4;
for (let i = 0; i < 4; i += 1) {
const angle = (i / 4) * Math.PI * 2;
const inner = r * 1.15;
const outer = r * 1.55;
ctx.beginPath();
ctx.moveTo(Math.cos(angle) * inner, Math.sin(angle) * inner);
ctx.lineTo(Math.cos(angle) * outer, Math.sin(angle) * outer);
ctx.stroke();
}
ctx.restore();
}
}
function drawCrow(item: Item): void {
const { x, y, r } = item;
const flap = Math.sin(game.elapsed * 15 + item.phase);
ctx.save();
ctx.translate(x, y);
ctx.rotate(clamp(item.drift * 0.004, -0.18, 0.18));
ctx.fillStyle = '#1d102c';
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.72, r * 0.54, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(-r * 0.62, -r * 0.1 + flap * r * 0.34, r * 0.55, r * 0.26, -0.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.ellipse(r * 0.62, -r * 0.1 - flap * r * 0.34, r * 0.55, r * 0.26, 0.5, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(r * 0.42, -r * 0.3, r * 0.34, 0, Math.PI * 2);
ctx.fillStyle = '#241436';
ctx.fill();
ctx.fillStyle = '#ffb457';
ctx.beginPath();
ctx.moveTo(r * 0.72, -r * 0.3);
ctx.lineTo(r * 1.16, -r * 0.18);
ctx.lineTo(r * 0.72, -r * 0.08);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#ffe9cf';
ctx.beginPath();
ctx.arc(r * 0.5, -r * 0.38, r * 0.08, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
function drawPig(): void {
const w = game.pigW;
const h = game.pigH;
const bob =
game.phase === 'playing' && !reduceMotion ? Math.sin(game.elapsed * 9) * 1.8 : 0;
const catching = game.mouth > 0;
const frenzy = game.frenzy > 0;
const centerY = game.groundY - h * 0.38 + bob;
const blink = game.invuln > 0 && Math.floor(game.time * 16) % 2 === 0;
if (frenzy) {
const glow = ctx.createRadialGradient(
game.pigX,
centerY,
w * 0.2,
game.pigX,
centerY,
w * 1.15,
);
glow.addColorStop(0, 'rgba(255, 219, 130, 0.4)');
glow.addColorStop(1, 'rgba(255, 190, 80, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(game.pigX, centerY, w * 1.15, 0, Math.PI * 2);
ctx.fill();
}
ctx.save();
ctx.globalAlpha = blink ? 0.4 : 1;
ctx.translate(game.pigX, centerY);
const tilt = clamp(game.pigVX * 0.00022, -0.14, 0.14);
ctx.rotate(tilt);
// Tail
ctx.strokeStyle = '#d96385';
ctx.lineWidth = Math.max(1.5, w * 0.035);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(-w * 0.5, -h * 0.02, w * 0.12, 0.4, Math.PI * 1.7);
ctx.stroke();
// Legs
ctx.fillStyle = '#e8799a';
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.roundRect(side * w * 0.26 - w * 0.09, h * 0.28, w * 0.18, h * 0.3, w * 0.06);
ctx.fill();
}
// Ears
ctx.fillStyle = '#ffb0c4';
ctx.strokeStyle = '#7c2c49';
ctx.lineWidth = Math.max(1.4, w * 0.025);
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(side * w * 0.17, -h * 0.36);
ctx.lineTo(side * w * 0.42, -h * 0.64);
ctx.lineTo(side * w * 0.46, -h * 0.24);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
// Body
const body = ctx.createLinearGradient(0, -h * 0.5, 0, h * 0.5);
body.addColorStop(0, '#ffc0cf');
body.addColorStop(0.55, '#ffabc0');
body.addColorStop(1, '#f28aa7');
ctx.fillStyle = body;
ctx.strokeStyle = '#7c2c49';
ctx.lineWidth = Math.max(1.6, w * 0.03);
ctx.beginPath();
ctx.ellipse(0, 0, w * 0.5, h * 0.48, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Snout
ctx.fillStyle = '#ffd4dd';
ctx.beginPath();
ctx.ellipse(0, h * 0.12, w * 0.24, h * 0.17, 0, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(124, 44, 73, 0.55)';
ctx.lineWidth = Math.max(1, w * 0.018);
ctx.stroke();
ctx.fillStyle = '#a64667';
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.ellipse(side * w * 0.075, h * 0.12, w * 0.028, h * 0.05, 0, 0, Math.PI * 2);
ctx.fill();
}
// Cheeks
ctx.fillStyle = 'rgba(233, 105, 140, 0.4)';
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.ellipse(side * w * 0.32, h * 0.1, w * 0.075, h * 0.05, 0, 0, Math.PI * 2);
ctx.fill();
}
// Eyes
const eyeY = -h * 0.12;
ctx.strokeStyle = '#3c1428';
ctx.fillStyle = '#3c1428';
ctx.lineWidth = Math.max(1.4, w * 0.022);
if (frenzy) {
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.arc(side * w * 0.19, eyeY, w * 0.06, Math.PI * 0.15, Math.PI * 0.85);
ctx.stroke();
}
} else {
for (const side of [-1, 1]) {
ctx.beginPath();
ctx.ellipse(side * w * 0.19, eyeY, w * 0.045, h * 0.055, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.beginPath();
ctx.arc(side * w * 0.19 + w * 0.016, eyeY - h * 0.018, w * 0.014, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#3c1428';
}
}
// Mouth
if (catching || frenzy) {
ctx.fillStyle = '#7a2544';
ctx.beginPath();
ctx.ellipse(0, h * 0.3, w * 0.1, h * 0.075, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#ff9fb6';
ctx.beginPath();
ctx.ellipse(0, h * 0.325, w * 0.05, h * 0.035, 0, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.strokeStyle = 'rgba(124, 44, 73, 0.7)';
ctx.beginPath();
ctx.arc(0, h * 0.2, w * 0.13, Math.PI * 0.18, Math.PI * 0.82);
ctx.stroke();
}
ctx.restore();
}
function drawItems(): void {
for (const item of game.items) {
ctx.save();
if (item.kind === 'crow') {
// Ground shadow telegraphs the dive.
const depth = clamp(item.y / Math.max(1, game.groundY), 0, 1);
ctx.globalAlpha = 0.12 + depth * 0.26;
ctx.fillStyle = '#05020c';
ctx.beginPath();
ctx.ellipse(item.x, game.groundY + 3, item.r * 1.5 * depth, item.r * 0.35 * depth, 0, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
drawCrow(item);
} else if (item.kind === 'acorn') {
drawAcorn(item.x, item.y, item.r, item.spin);
} else {
drawTruffle(item.x, item.y, item.r, item.kind === 'golden');
}
ctx.restore();
}
}
function drawFireflies(t: number): void {
ctx.save();
for (const fly of game.fireflies) {
const x = fly.baseX * game.w + Math.sin(t * fly.speed + fly.phase) * fly.amp;
const y =
fly.baseY * game.h +
Math.cos(t * fly.speed * 0.8 + fly.phase * 1.3) * fly.amp * 0.6;
const pulse = 0.55 + 0.45 * Math.sin(t * 2.2 + fly.phase);
ctx.globalAlpha = 0.35 + pulse * 0.5;
ctx.fillStyle = '#ffe3a0';
ctx.shadowColor = 'rgba(255, 214, 120, 0.9)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.arc(x, y, 1.5 + pulse, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawParticles(): void {
for (const p of game.particles) {
const alpha = clamp(p.life / p.maxLife, 0, 1);
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * (0.4 + alpha * 0.6), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawFloaters(): void {
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `900 ${Math.max(11, 14 * game.unit)}px "Trebuchet MS", "Avenir Next", sans-serif`;
for (const f of game.floaters) {
const alpha = clamp(f.life / f.maxLife, 0, 1);
ctx.globalAlpha = alpha;
ctx.fillStyle = f.color;
ctx.shadowColor = 'rgba(10, 4, 18, 0.85)';
ctx.shadowBlur = 4;
ctx.fillText(f.text, f.x, f.y);
}
ctx.restore();
}
function render(t: number): void {
const { w, h } = game;
if (w <= 0 || h <= 0) return;
ctx.clearRect(0, 0, w, h);
ctx.save();
if (game.shake > 0.2) {
const strength = game.shake;
ctx.translate(rand(-strength, strength) * 0.5, rand(-strength, strength) * 0.5);
}
if (background) ctx.drawImage(background, 0, 0, w, h);
drawFireflies(t);
drawItems();
drawPig();
drawParticles();
drawFloaters();
ctx.restore();
if (game.flash > 0) {
ctx.fillStyle = `rgba(255, 120, 120, ${clamp(game.flash * 0.35, 0, 0.3)})`;
ctx.fillRect(0, 0, w, h);
}
}
/* ── Main loop ──────────────────────────────────────────────────────── */
function frame(now: number): void {
const dt = clamp((now - lastFrame) / 1000, 0, 0.05);
lastFrame = now;
game.time += dt;
if (game.phase === 'playing') updateGame(dt);
updateEffects(dt);
updateHud();
render(game.time);
window.requestAnimationFrame(frame);
}
/* ── Input ──────────────────────────────────────────────────────────── */
function pointToPig(clientX: number): void {
const rect = canvas.getBoundingClientRect();
game.pigTarget = clamp(
clientX - rect.left,
game.pigW / 2,
game.w - game.pigW / 2,
);
}
function isMoveKey(key: string): 'left' | 'right' | null {
if (key === 'ArrowLeft' || key === 'a' || key === 'A') return 'left';
if (key === 'ArrowRight' || key === 'd' || key === 'D') return 'right';
return null;
}
window.addEventListener('keydown', (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const move = isMoveKey(event.key);
if (move) {
game.keys[move] = true;
ensureAudio();
event.preventDefault();
return;
}
if (event.key === 'm' || event.key === 'M') {
event.preventDefault();
toggleSound();
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
ensureAudio();
if (game.phase === 'ready' || game.phase === 'over') startGame();
else togglePause();
return;
}
if (event.key === 'p' || event.key === 'P' || event.key === 'Escape') {
event.preventDefault();
if (game.phase === 'playing' || game.phase === 'paused') togglePause();
}
});
window.addEventListener('keyup', (event) => {
const move = isMoveKey(event.key);
if (move) game.keys[move] = false;
});
window.addEventListener('blur', () => {
game.keys.left = false;
game.keys.right = false;
game.pointerDown = false;
if (game.phase === 'playing') togglePause();
});
canvas.addEventListener('pointerdown', (event) => {
ensureAudio();
game.pointerDown = true;
pointToPig(event.clientX);
if (canvas.setPointerCapture) {
try {
canvas.setPointerCapture(event.pointerId);
} catch {
// Pointer capture is best-effort.
}
}
event.preventDefault();
});
canvas.addEventListener('pointermove', (event) => {
if (event.pointerType === 'mouse' || game.pointerDown) {
pointToPig(event.clientX);
}
});
function endPointer(): void {
game.pointerDown = false;
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
canvas.addEventListener('pointerleave', (event) => {
if (event.pointerType === 'mouse') endPointer();
});
canvas.addEventListener('contextmenu', (event) => event.preventDefault());
elements.playButton.addEventListener('click', () => {
if (game.phase === 'paused') {
togglePause();
} else {
startGame();
}
});
elements.pauseButton.addEventListener('click', () => {
if (game.phase === 'playing' || game.phase === 'paused') togglePause();
});
elements.soundButton.addEventListener('click', () => {
toggleSound();
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && game.phase === 'playing') togglePause();
});
window.addEventListener('pagehide', () => {
saveBest();
resizeObserver?.disconnect();
});
onSettingsChanged((next) => {
settings = { ...next };
configSoundEnabled = next.sound;
updateSoundButton();
});
/* ── Boot ───────────────────────────────────────────────────────────── */
elements.hearts.setAttribute('aria-label', '3 lives remaining');
updateSoundButton();
updateHud(true);
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(canvas);
resize();
void loadProgress();
window.requestAnimationFrame(frame);
