nuts / commit
added snapshots
.gitignore | 3 + package.json | 9 ++- pnpm-lock.yaml | 71 +++++++++++++++++ screenshots/golden/composite.png | Bin 0 -> 817415 bytes screenshots/golden/gallery.png | Bin 0 -> 445950 bytes screenshots/golden/settings.png | Bin 0 -> 38270 bytes screenshots/golden/ui.png | Bin 0 -> 23930 bytes scripts/composite.ts | 114 +++++++++++++++++++++++++++ scripts/diff.ts | 111 +++++++++++++++++++++++++++ scripts/screenshot.ts | 133 ++++++++++++++++++++++++++++++++ src/gallery.ts | 26 +++++++ src/main.ts | 10 ++- src/renderer.ts | 162 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 637 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 3d79ca0..970eb37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules dist kenney_impact-sounds +screenshots/* +!screenshots/golden/ +!screenshots/golden/** diff --git a/package.json b/package.json index 4748406..541c631 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,25 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "generate-icons": "tsx scripts/generate-icons.ts" + "generate-icons": "tsx scripts/generate-icons.ts", + "screenshot": "tsx scripts/screenshot.ts", + "diff-screenshots": "tsx scripts/diff.ts" }, "dependencies": { "matter-js": "^0.19.0", "qrcode": "^1.5.4" }, "devDependencies": { + "@playwright/test": "^1.59.1", "@types/matter-js": "^0.19.6", "@types/node": "^25.6.0", + "@types/pngjs": "^6.0.5", "@types/qrcode": "^1.5.6", "@vitejs/plugin-basic-ssl": "^2.3.0", "ffmpeg-static": "^5.3.0", + "pixelmatch": "^7.1.0", + "playwright": "^1.59.1", + "pngjs": "^7.0.0", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdca09a..36f88cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,12 +15,18 @@ importers: specifier: ^1.5.4 version: 1.5.4 devDependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@types/matter-js': specifier: ^0.19.6 version: 0.19.8 '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -30,6 +36,15 @@ importers: ffmpeg-static: specifier: ^5.3.0 version: 5.3.0 + pixelmatch: + specifier: ^7.1.0 + version: 7.1.0 + playwright: + specifier: ^1.59.1 + version: 1.59.1 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 sharp: specifier: ^0.34.5 version: 0.34.5 @@ -486,6 +501,11 @@ packages: cpu: [x64] os: [win32] + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@rollup/rollup-android-arm-eabi@4.60.1': resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] @@ -623,6 +643,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -713,6 +736,11 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -776,10 +804,28 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -1178,6 +1224,10 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@rollup/rollup-android-arm-eabi@4.60.1': optional: true @@ -1263,6 +1313,10 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 25.6.0 + '@types/qrcode@1.5.6': dependencies: '@types/node': 25.6.0 @@ -1391,6 +1445,9 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -1441,8 +1498,22 @@ snapshots: picocolors@1.1.1: {} + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} + pngjs@7.0.0: {} + postcss@8.5.9: dependencies: nanoid: 3.3.11 diff --git a/screenshots/golden/composite.png b/screenshots/golden/composite.png new file mode 100644 index 0000000..82fc269 Binary files /dev/null and b/screenshots/golden/composite.png differ diff --git a/screenshots/golden/gallery.png b/screenshots/golden/gallery.png new file mode 100644 index 0000000..c0f9a6b Binary files /dev/null and b/screenshots/golden/gallery.png differ diff --git a/screenshots/golden/settings.png b/screenshots/golden/settings.png new file mode 100644 index 0000000..ea09240 Binary files /dev/null and b/screenshots/golden/settings.png differ diff --git a/screenshots/golden/ui.png b/screenshots/golden/ui.png new file mode 100644 index 0000000..a822623 Binary files /dev/null and b/screenshots/golden/ui.png differ diff --git a/scripts/composite.ts b/scripts/composite.ts new file mode 100644 index 0000000..bafb5ee --- /dev/null +++ b/scripts/composite.ts @@ -0,0 +1,114 @@ +/** + * scripts/composite.ts + * + * Stitches gallery.png + ui.png + settings.png into a single annotated + * composite sheet saved as screenshots/composite.png. + * + * Layout: + * ┌─────────── gallery ──────────────┐ + * │ │ + * ├──── ui ─────┬──── settings ──────┤ + * │ │ │ + * └─────────────┴────────────────────┘ + * + * Also optionally copies to screenshots/golden/composite.png. + */ + +import sharp from 'sharp' +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const OUT_DIR = join(ROOT, 'screenshots') +const GOLDEN_DIR = join(OUT_DIR, 'golden') + +const BG = { r: 13, g: 13, b: 13 } // #0d0d0d +const PAD = 28 // outer padding +const GAP = 16 // gap between sections / columns +const LABEL_H = 32 // height of each section label bar + +function labelSvg(w: number, text: string): Buffer { + const svg = `<svg width="${w}" height="${LABEL_H}" xmlns="http://www.w3.org/2000/svg"> + <rect width="${w}" height="${LABEL_H}" fill="#1e1e1e" rx="4"/> + <text x="${w / 2}" y="${Math.round(LABEL_H * 0.72)}" + text-anchor="middle" + font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace" + font-size="14" fill="#888888">${text}</text> +</svg>` + return Buffer.from(svg) +} + +async function meta(file: string): Promise<{ w: number; h: number }> { + const m = await sharp(file).metadata() + return { w: m.width!, h: m.height! } +} + +export async function makeComposite(update = false): Promise<string> { + const galleryFile = join(OUT_DIR, 'gallery.png') + const uiFile = join(OUT_DIR, 'ui.png') + const settingsFile = join(OUT_DIR, 'settings.png') + + for (const f of [galleryFile, uiFile, settingsFile]) { + if (!existsSync(f)) throw new Error(`Missing screenshot: ${f}`) + } + + const [g, u, s] = await Promise.all([ + meta(galleryFile), + meta(uiFile), + meta(settingsFile), + ]) + + // ── Compute layout ──────────────────────────────────────────────────── + // Both sections (gallery row, pair row) may have different widths. + // The composite width is the wider of the two, plus outer padding. + const pairInnerW = u.w + GAP + s.w + const innerW = Math.max(g.w, pairInnerW) + const totalW = innerW + PAD * 2 + + // Horizontal offsets (center each section within innerW) + const gLeft = PAD + Math.floor((innerW - g.w) / 2) + const pairLeft = PAD + Math.floor((innerW - pairInnerW) / 2) + const uLeft = pairLeft + const sLeft = pairLeft + u.w + GAP + + // Vertical positions + let y = PAD + const gLabelTop = y; y += LABEL_H + const gTop = y; y += g.h + GAP + const pairLabelTopU = y + const pairLabelTopS = y; y += LABEL_H + const pairTop = y; y += Math.max(u.h, s.h) + const totalH = y + PAD + + // ── Build composite layers ──────────────────────────────────────────── + const layers: sharp.OverlayOptions[] = [ + // Gallery label + { input: labelSvg(g.w, 'gallery'), left: gLeft, top: gLabelTop }, + // Gallery image + { input: galleryFile, left: gLeft, top: gTop }, + // UI label + { input: labelSvg(u.w, 'ui'), left: uLeft, top: pairLabelTopU }, + // Settings label + { input: labelSvg(s.w, 'settings'), left: sLeft, top: pairLabelTopS }, + // UI image + { input: uiFile, left: uLeft, top: pairTop }, + // Settings image + { input: settingsFile, left: sLeft, top: pairTop }, + ] + + const outFile = join(OUT_DIR, 'composite.png') + await sharp({ + create: { width: totalW, height: totalH, channels: 3, background: BG }, + }) + .composite(layers) + .png({ compressionLevel: 6 }) + .toFile(outFile) + + if (update) { + mkdirSync(GOLDEN_DIR, { recursive: true }) + writeFileSync(join(GOLDEN_DIR, 'composite.png'), readFileSync(outFile)) + } + + return outFile +} diff --git a/scripts/diff.ts b/scripts/diff.ts new file mode 100644 index 0000000..8244f4c --- /dev/null +++ b/scripts/diff.ts @@ -0,0 +1,111 @@ +/** + * scripts/diff.ts + * + * Compares screenshots/composite.png against screenshots/golden/composite.png + * using pixelmatch. + * + * Exit codes: + * 0 — images match (within threshold) + * 1 — images differ (above threshold) — diff saved to screenshots/diff.png + * 2 — golden file missing (run `pnpm screenshot --update` first) + * + * Usage (standalone): + * pnpm diff-screenshots + * + * The screenshot.ts script calls compareWithGolden() directly. + */ + +import pixelmatch from 'pixelmatch' +import { PNG } from 'pngjs' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const OUT_DIR = join(ROOT, 'screenshots') +const DIFF_FILE = join(OUT_DIR, 'diff.png') + +/** Fraction of pixels that must change before we consider the images different. */ +const THRESHOLD = 0.005 // 0.5 % + +/** + * Compare `composite.png` against the golden copy. + * Returns `null` when there is no golden to compare against. + * Otherwise returns an object with the diff result. + */ +export async function compareWithGolden(): Promise<{ + changed: number + total: number + pct: number + pass: boolean + diffFile: string | null +} | null> { + const current = join(OUT_DIR, 'composite.png') + const golden = join(OUT_DIR, 'golden', 'composite.png') + + if (!existsSync(golden)) return null + if (!existsSync(current)) throw new Error(`Missing composite: ${current}`) + + const imgA = PNG.sync.read(readFileSync(golden)) + const imgB = PNG.sync.read(readFileSync(current)) + + // If dimensions differ, treat every pixel as changed + if (imgA.width !== imgB.width || imgA.height !== imgB.height) { + const total = Math.max(imgA.width * imgA.height, imgB.width * imgB.height) + return { changed: total, total, pct: 1, pass: false, diffFile: null } + } + + const { width, height } = imgA + const diff = new PNG({ width, height }) + + const changed = pixelmatch( + imgA.data, imgB.data, diff.data, + width, height, + { threshold: 0.1, alpha: 0.3 }, + ) + + const total = width * height + const pct = changed / total + const pass = pct <= THRESHOLD + + let diffFile: string | null = null + if (!pass) { + writeFileSync(DIFF_FILE, PNG.sync.write(diff)) + diffFile = DIFF_FILE + } + + return { changed, total, pct, pass, diffFile } +} + +// ── Run standalone ──────────────────────────────────────────────────────── +// Only execute when this file is the entry point, not when imported as a module. +const isMain = process.argv[1] && + (process.argv[1].endsWith('diff.ts') || process.argv[1].endsWith('diff.js')) + +if (!isMain) { /* imported — skip standalone logic */ } +else { + + const result = await compareWithGolden() + + if (result === null) { + console.log('No golden composite found.') + console.log('Run `pnpm screenshot --update` to create one.') + process.exit(2) + } + + const pctStr = (result.pct * 100).toFixed(2) + const thStr = (THRESHOLD * 100).toFixed(1) + + if (result.pass) { + console.log(`✓ Images match (${pctStr}% changed, threshold ${thStr}%)`) + process.exit(0) + } else { + console.error(`✗ Images differ: ${pctStr}% of pixels changed (threshold ${thStr}%)`) + if (result.diffFile) { + console.error(` Diff saved to ${result.diffFile}`) + } + console.error(' Run `pnpm screenshot --update` to accept the new output as golden.') + process.exit(1) + } + +} // end isMain diff --git a/scripts/screenshot.ts b/scripts/screenshot.ts new file mode 100644 index 0000000..310c6cf --- /dev/null +++ b/scripts/screenshot.ts @@ -0,0 +1,133 @@ +/** + * scripts/screenshot.ts + * + * Takes screenshots of the running Vite dev server and saves them to + * screenshots/ (gitignored by default — golden files are checked in separately). + * + * Usage: + * pnpm screenshot # takes screenshots, composites, diffs against golden + * pnpm screenshot --update # promote existing screenshots/composite to golden (no re-run) + * + * Shots taken: + * gallery.png — full gallery canvas (?gallery mode) + * ui.png — default app UI, no items + * settings.png — settings panel open + */ + +import { chromium } from 'playwright' +import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { createServer } from 'vite' +import { fileURLToPath } from 'node:url' +import { makeComposite } from './composite.ts' +import { compareWithGolden } from './diff.ts' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const OUT_DIR = join(ROOT, 'screenshots') +const GOLDEN_DIR = join(ROOT, 'screenshots', 'golden') +const UPDATE_GOLDEN = process.argv.includes('--update') + +mkdirSync(OUT_DIR, { recursive: true }) +mkdirSync(GOLDEN_DIR, { recursive: true }) + +// ── --update: promote existing screenshots to golden without re-running ─── +if (UPDATE_GOLDEN) { + const names = ['gallery', 'ui', 'settings'] + for (const name of names) { + const src = join(OUT_DIR, `${name}.png`) + if (!existsSync(src)) { + console.error(`Missing ${name}.png — run \`pnpm screenshot\` first.`) + process.exit(1) + } + writeFileSync(join(GOLDEN_DIR, `${name}.png`), readFileSync(src)) + console.log(` ✓ ${name}.png → golden`) + } + console.log('\nBuilding composite…') + await makeComposite(true) + console.log(' ✓ composite.png → golden') + process.exit(0) +} + +// ── Start Vite in-process ───────────────────────────────────────────────── +console.log('Starting Vite dev server…') +const server = await createServer({ root: ROOT, server: { port: 5174, strictPort: true } }) +await server.listen() +// Detect https vs http (basicSsl plugin makes it https) +const protocol = (server.config.server.https) ? 'https' : 'http' +const BASE = `${protocol}://localhost:5174` +console.log(` ${BASE}`) + +// Give the dev server a moment to fully warm up (transforms, etc.) +await new Promise(r => setTimeout(r, 1500)) + +// ── Launch Chromium ─────────────────────────────────────────────────────── +const browser = await chromium.launch() +const context = await browser.newContext({ ignoreHTTPSErrors: true }) + +async function shot(name: string, url: string, setup?: (page: import('playwright').Page) => Promise<void>): Promise<void> { + const page = await context.newPage() + await page.setViewportSize({ width: 390, height: 844 }) // iPhone 14 size + await page.goto(url) + if (setup) await setup(page) + const dest = join(OUT_DIR, `${name}.png`) + await page.screenshot({ path: dest, fullPage: true }) + console.log(` ✓ ${name}.png`) + await page.close() +} + +// ── Gallery ─────────────────────────────────────────────────────────────── +await shot('gallery', `${BASE}/?gallery`, async (page) => { + // Wait for the gallery to finish rendering + await page.waitForSelector('[data-gallery-ready="true"]', { timeout: 10_000 }) + // Fit the viewport to the canvas size + const size = await page.evaluate(() => { + const c = document.getElementById('canvas') as HTMLCanvasElement + return { width: c.offsetWidth, height: c.offsetHeight } + }) + await page.setViewportSize({ width: size.width, height: size.height }) +}) + +// ── Default UI (no items) ───────────────────────────────────────────────── +await shot('ui', `${BASE}/?screenshot`, async (page) => { + // On desktop/headless, the app auto-starts after 400 ms (no touch) + await page.waitForSelector('#toolbar:not([hidden])', { timeout: 10_000 }) +}) + +// ── Settings panel ──────────────────────────────────────────────────────── +await shot('settings', `${BASE}/?screenshot`, async (page) => { + await page.waitForSelector('#toolbar:not([hidden])', { timeout: 10_000 }) + await page.locator('#settings-btn').click() + await page.waitForSelector('#settings-panel:not([hidden])', { timeout: 5_000 }) +}) + +// ── Composite ──────────────────────────────────────────────────────────── +console.log('\nBuilding composite…') +await makeComposite(false) +console.log(' ✓ composite.png') + +// ── Diff against golden ─────────────────────────────────────────────────── +{ + const diff = await compareWithGolden() + if (diff === null) { + console.log('\n No golden yet — run with --update to create one.') + } else { + const pct = (diff.pct * 100).toFixed(2) + if (diff.pass) { + console.log(`\n ✓ Matches golden (${pct}% changed)`) + } else { + console.error(`\n ✗ Differs from golden: ${pct}% changed`) + if (diff.diffFile) console.error(` Diff image: ${diff.diffFile}`) + console.error(' Run with --update to accept as new golden.') + // Don't exit(1) here — screenshots are still saved. + // CI can run `pnpm diff-screenshots` separately for a hard fail. + } + } +} + +// ── Cleanup ─────────────────────────────────────────────────────────────── +await context.close() +await browser.close() +await server.close() + +console.log(`\nScreenshots saved to ${OUT_DIR}`) +console.log('Run with --update to promote to golden.') diff --git a/src/gallery.ts b/src/gallery.ts new file mode 100644 index 0000000..0e44234 --- /dev/null +++ b/src/gallery.ts @@ -0,0 +1,26 @@ +// Gallery mode — rendered when ?gallery is present in the URL. +// Mounts a single <canvas> with all item variants drawn statically. +// No physics, no UI. Used by the screenshot script in scripts/screenshot.ts. + +import { renderGallery } from './renderer' + +export function runGallery(): void { + document.body.style.cssText = 'margin:0;padding:0;background:#1a1a1a;overflow:auto' + + // Hide all UI overlays so only the canvas shows + for (const id of ['start-overlay', 'fullscreen-overlay', 'toolbar', 'settings-panel']) { + const el = document.getElementById(id) + if (el) { el.hidden = true; (el as HTMLElement).style.display = 'none' } + } + + const canvas = document.getElementById('canvas') as HTMLCanvasElement + renderGallery(canvas) + + // Stretch the <canvas> element to its natural pixel size so the screenshot + // tool captures it without scaling artifacts + canvas.style.width = `${canvas.width}px` + canvas.style.height = `${canvas.height}px` + + // Signal to Playwright that the gallery is ready + document.body.setAttribute('data-gallery-ready', 'true') +} diff --git a/src/main.ts b/src/main.ts index 1542fff..44a683e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,11 +13,19 @@ import { } from './persistence' import { buildShareURL, parseShareFromURL, placeSharedNuts } from './share' +// ── Gallery shortcut ─────────────────────────────────────────────────────── +if (new URLSearchParams(window.location.search).has('gallery')) { + const { runGallery } = await import('./gallery') + runGallery() + throw new Error('gallery mode — halting normal init') +} + // ── Bootstrap ────────────────────────────────────────────────────────────── async function main(): Promise<void> { + const params = new URLSearchParams(window.location.search) const compat = checkCompat() - if (!compat.ok) { + if (!compat.ok && !params.has('screenshot')) { showUnsupportedWall(compat.reason) return } diff --git a/src/renderer.ts b/src/renderer.ts index cb1c34f..3cc0d21 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1165,3 +1165,165 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { return { render, setShareURL, resize } } + +// ── Gallery renderer ─────────────────────────────────────────────────────── +// Draws a static grid of every item/variant onto a <canvas> with labels. +// Called by gallery.ts when ?gallery is in the URL. + +import { + GEM_VARIANT_COUNT, BALLOON_COLOR_COUNT, STRESSBALL_COLOR_COUNT, + COIN_MATERIAL_COUNT, +} from './physics' + +const GALLERY_RADIUS = 52 +const GALLERY_CELL = GALLERY_RADIUS * 2 + 32 // padding around each cell +const GALLERY_LABEL_H = 18 +const GALLERY_ROW_H = GALLERY_CELL + GALLERY_LABEL_H + 8 +const GALLERY_BG = '#1a1a1a' +const GALLERY_FG = '#e0e0e0' + +interface GalleryRow { + kind: string + label: string + count: number + /** Draw variant i at centre (cx,cy) on ctx. */ + draw: (ctx: CanvasRenderingContext2D, cx: number, cy: number, variant: number) => void +} + +function galleryRows(): GalleryRow[] { + const r = GALLERY_RADIUS + return [ + { + kind: 'nut', label: 'Nut', count: PALETTE.length, + draw: (ctx, cx, cy, i) => { + const sp = getSprite(r, i, 'nut', 0) + ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2) + }, + }, + { + kind: 'gem', label: 'Gem', count: GEM_VARIANT_COUNT, + draw: (ctx, cx, cy, i) => { + const sp = getSprite(r, 0, 'gem', i) + ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2) + }, + }, + { + kind: 'metalnut', label: 'Metal Nut', count: METAL_PALETTE.length, + draw: (ctx, cx, cy, i) => { + const sp = getSprite(r, i, 'metalnut', 0) + ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2) + }, + }, + { + kind: 'balloon', label: 'Balloon', count: BALLOON_COLOR_COUNT, + draw: (ctx, cx, cy, i) => { + const sp = getBalloonBodySprite(r, i) + ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2) + // knot + string + const bc = BALLOON_COLORS[i % BALLOON_COLORS.length] + const bknotH = Math.max(4, r * 0.13) + const bstringLen = r * 0.9 + ctx.save() + ctx.translate(cx, cy) + ctx.beginPath() + ctx.moveTo(0, r + bknotH * 1.6) + ctx.lineTo(-bknotH * 0.55, r) + ctx.lineTo(bknotH * 0.55, r) + ctx.closePath() + ctx.fillStyle = bc.rim; ctx.fill() + ctx.beginPath() + ctx.moveTo(0, r + bknotH * 1.6) + ctx.quadraticCurveTo(r * 0.18, r + bstringLen * 0.5, r * 0.06, r + bstringLen + bknotH * 1.6) + ctx.strokeStyle = bc.string; ctx.lineWidth = Math.max(1, r * 0.025) + ctx.globalAlpha *= 0.75; ctx.stroke() + ctx.restore() + }, + }, + { + kind: 'stressball', label: 'Stress Ball', count: STRESSBALL_COLOR_COUNT, + draw: (ctx, cx, cy, i) => { + const sp = getStressBallSprite(r, i) + ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2) + }, + }, + { + kind: 'rock', label: 'Rock', count: ROCK_TEMPLATES.length, + draw: (ctx, cx, cy, i) => { + const tmpl = ROCK_TEMPLATES[i % ROCK_TEMPLATES.length] + // Build a fake Matter.Body from normVerts scaled to radius + const verts = tmpl.normVerts.map(v => ({ x: cx + v.x * r, y: cy + v.y * r })) + const fakeBody = { + position: { x: cx, y: cy }, + angle: 0, + vertices: verts, + speed: 0, + } as unknown as Matter.Body + drawRock(ctx, fakeBody, r, i % 6, 0, i % ROCK_TEMPLATES.length) + }, + }, + { + kind: 'coin', label: 'Coin', count: COIN_MATERIAL_COUNT, + draw: (ctx, cx, cy, i) => { + const sp = getCoinHeadsSprite(r, i) + ctx.drawImage(sp, cx - sp.width / 2, cy - sp.height / 2) + }, + }, + ] +} + +export function renderGallery(canvas: HTMLCanvasElement): void { + const rows = galleryRows() + const maxCount = Math.max(...rows.map(r => r.count)) + const cols = maxCount + + const SECTION_LABEL_H = 28 + const PAD_LEFT = 110 // space for row labels on the left + + const totalW = PAD_LEFT + cols * GALLERY_CELL + 16 + const totalH = rows.length * (GALLERY_ROW_H + SECTION_LABEL_H) + 32 + + canvas.width = totalW + canvas.height = totalH + const ctx = canvas.getContext('2d')! + + ctx.fillStyle = GALLERY_BG + ctx.fillRect(0, 0, totalW, totalH) + + let y = 16 + + for (const row of rows) { + // Section divider + ctx.fillStyle = '#ffffff18' + ctx.fillRect(0, y - 4, totalW, 1) + + // Row label (left column) + ctx.fillStyle = GALLERY_FG + ctx.font = 'bold 14px system-ui, sans-serif' + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.fillText(row.label, 8, y + GALLERY_ROW_H / 2) + + // Variants + for (let i = 0; i < row.count; i++) { + const cx = PAD_LEFT + i * GALLERY_CELL + GALLERY_CELL / 2 + const cy = y + GALLERY_ROW_H / 2 - GALLERY_LABEL_H / 2 + + // Cell background + ctx.fillStyle = '#ffffff08' + ctx.beginPath() + ctx.roundRect(PAD_LEFT + i * GALLERY_CELL + 4, y + 4, GALLERY_CELL - 8, GALLERY_ROW_H - 8, 6) + ctx.fill() + + row.draw(ctx, cx, cy, i) + + // Variant index label below item + ctx.fillStyle = '#888' + ctx.font = '11px system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'alphabetic' + ctx.fillText(String(i), cx, y + GALLERY_ROW_H - 6) + } + + y += GALLERY_ROW_H + SECTION_LABEL_H + } +}