explodorama / files / tools / screenshots.mjs
screenshots.mjs
| 1 | /** |
| 2 | * Photograph the built game into docs/screenshots/, for the README and for looking a change |
| 3 | * over before it is committed. Always seed 1, so the pictures are of the same board each run. |
| 4 | * |
| 5 | * npm run screenshots |
| 6 | */ |
| 7 | import { mkdirSync, readFileSync } from 'node:fs' |
| 8 | import { chromium } from 'playwright' |
| 9 | import { preview } from 'vite' |
| 10 | |
| 11 | const OUT = 'docs/screenshots' |
| 12 | /** |
| 13 | * Where the build is served: its app path on apps.fuzzyworld.net (`fuzzyworld.app` in |
| 14 | * package.json), since builds use it as their base (fuzzyworld-tools/vite, 2026-10-26). |
| 15 | */ |
| 16 | const APP_PATH = `/${JSON.parse(readFileSync('package.json', 'utf8')).fuzzyworld.app}/` |
| 17 | const PHONE = { width: 390, height: 844 } |
| 18 | const DESKTOP = { width: 1280, height: 720 } |
| 19 | /** Phone-wide and tall enough for a whole almanac list. */ |
| 20 | const TALL = { width: 390, height: 2100 } |
| 21 | |
| 22 | const press = (...keys) => async (page) => { |
| 23 | for (const k of keys) { |
| 24 | await page.keyboard.press(k) |
| 25 | await page.waitForTimeout(60) |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | /** |
| 30 | * The screen position of a tile's centre, asked of the game (main.ts `window.tilePoint`): since |
| 31 | * 2026-10-25 the board can be bigger than the view and scroll with the player, so only the game's |
| 32 | * camera knows. (Worked out here from the board's size until then.) |
| 33 | */ |
| 34 | async function tileCentre(page, x, y) { |
| 35 | return page.evaluate(([x, y]) => window.tilePoint(x, y), [x, y]) |
| 36 | } |
| 37 | |
| 38 | const tap = (x, y) => async (page) => { |
| 39 | const p = await tileCentre(page, x, y) |
| 40 | await page.mouse.click(p.x, p.y) |
| 41 | } |
| 42 | |
| 43 | const SHOTS = [ |
| 44 | { name: 'start', act: async () => {} }, |
| 45 | // Bomb picked: the tiles it can go on are highlighted. |
| 46 | { name: 'bomb-picked', act: async (page) => { await page.click('[data-action=bomb]') } }, |
| 47 | // A bomb placed to the right, then a step down out of its way: its countdown and the danger overlay. |
| 48 | { name: 'countdown', act: press('KeyB', 'KeyD', 'KeyS') }, |
| 49 | // The bomb has gone off: its flame lingers on the board through the next turn. |
| 50 | { name: 'lingering', act: press('KeyB', 'KeyD', 'KeyS', 'Space', 'Space') }, |
| 51 | // Standing next to our own bomb as it goes off. |
| 52 | { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') }, |
| 53 | // One turn before that: waiting now would cost a life, so Wait is red... |
| 54 | { name: 'deadly-wait', act: press('KeyB', 'KeyD', 'Space', 'Space') }, |
| 55 | // Stepped down out of the blast instead: with Move picked, the step back up into it is red with a cross. |
| 56 | { name: 'deadly-move', act: press('KeyB', 'KeyD', 'KeyS', 'Space', 'KeyM') }, |
| 57 | { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, |
| 58 | // Level 13 has all three enemy types so far, each with its intent arrow. |
| 59 | { name: 'enemies', query: 'level=19', act: press('Space', 'Space') }, |
| 60 | // Tapped a far tile: the way there in blue, and Move turned into Go; then one Go. |
| 61 | { name: 'auto-move', act: async (page) => { await tap(3, 3)(page); await page.click('[data-action=move]') } }, |
| 62 | // Held a finger on the tile to the right: a bomb is armed there, with its blast, until release. |
| 63 | { |
| 64 | name: 'long-press', |
| 65 | act: async (page) => { |
| 66 | const p = await tileCentre(page, 2, 1) |
| 67 | await page.mouse.move(p.x, p.y) |
| 68 | await page.mouse.down() |
| 69 | await page.waitForTimeout(600) |
| 70 | }, |
| 71 | }, |
| 72 | // Carrying a Nuke (?charge=nuke): the charge button (V) puts a Nuke bomb to the right, and after |
| 73 | // a step down the danger shows its reach; the top bar and the button show what is left. |
| 74 | { name: 'nuke', query: 'charge=nuke', act: press('KeyV', 'KeyD', 'KeyS') }, |
| 75 | // Carrying Acid: its bomb's danger covers the Nuke's area (2026-10-26), with pillars diagonal to it. |
| 76 | { name: 'acid', query: 'charge=acid', act: press('KeyV', 'KeyS', 'KeyD') }, |
| 77 | // Seed 107: a bomb by the gem's crate, out of the way while it goes off, then back: the gem |
| 78 | // uncovered beside the player... (found by searching random play with the sim, 2026-10-24; the |
| 79 | // old seed-55 moves no longer reached a gem) |
| 80 | { name: 'gem', query: 'seed=107', act: press('KeyS', 'KeyB', 'KeyS', 'Space', 'KeyW', 'Space', 'Space', 'Space', 'KeyS', 'KeyS') }, |
| 81 | // ...then onto it: the choice of power-ups. |
| 82 | { name: 'gem-offer', query: 'seed=107', act: press('KeyS', 'KeyB', 'KeyS', 'Space', 'KeyW', 'Space', 'Space', 'Space', 'KeyS', 'KeyS', 'KeyD') }, |
| 83 | // The shop after level 3 (?shop: straight in, with 9 coins; the keys that played through level 3 |
| 84 | // stopped working when the boards changed, 2026-10-25), its first item selected and playing above. |
| 85 | { name: 'shop', query: 'seed=37&level=3&shop', act: async (page) => await page.waitForTimeout(1200) }, |
| 86 | // Developer mode: the rainbow gem uncovered right of the player, and the mode's tag in the top bar... |
| 87 | { name: 'dev', query: 'dev', act: async () => {} }, |
| 88 | // ...and taking it: every power-up on offer. |
| 89 | { name: 'dev-offer', query: 'dev', act: press('KeyD') }, |
| 90 | // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items |
| 91 | // explain themselves with a plain tap. |
| 92 | { name: 'info-mode', act: async (page) => { await page.click('#info-button') } }, |
| 93 | { name: 'info-enemy', act: async (page) => { await page.click('#info-button'); await tap(1, 7)(page) } }, |
| 94 | { name: 'info-hud', act: async (page) => { await page.click('[data-info=fuse]') } }, |
| 95 | // The menu (top right), and the confirmation before abandoning a run. |
| 96 | { name: 'menu', act: async (page) => { await page.click('#menu-button') } }, |
| 97 | { |
| 98 | name: 'new-run', |
| 99 | act: async (page) => { |
| 100 | await page.click('#menu-button') |
| 101 | await page.click('#dialog-buttons .danger') |
| 102 | }, |
| 103 | }, |
| 104 | // The almanac (menu → Almanac), one shot per tab, in a tall window so each list shows whole. |
| 105 | ...['powerUps', 'charges', 'enemies'].map((tab) => ({ |
| 106 | name: tab === 'powerUps' ? 'almanac' : `almanac-${tab}`, |
| 107 | viewport: TALL, |
| 108 | act: async (page) => { |
| 109 | await page.click('#menu-button') |
| 110 | await page.click('#dialog-buttons button:has-text("Almanac")') |
| 111 | await page.waitForTimeout(300) |
| 112 | await page.click(`[data-tab=${tab}]`) |
| 113 | }, |
| 114 | })), |
| 115 | // The one charge carried (?charge=pierce), in the top bar's own row and on the charge button. |
| 116 | { name: 'charges', query: 'charge=pierce', act: async () => {} }, |
| 117 | // The demo before the game (?demo): its finger holding on a tile for a bomb (the ring closed in, |
| 118 | // the bomb's blast previewed), over the action bar and the caption. Taken mid-press, so no settling. |
| 119 | { |
| 120 | name: 'demo', |
| 121 | query: 'demo', |
| 122 | settle: 0, |
| 123 | act: async (page) => { |
| 124 | await page.waitForSelector('.demo-ring', { state: 'attached', timeout: 20000 }) |
| 125 | await page.waitForTimeout(600) |
| 126 | }, |
| 127 | }, |
| 128 | // Move picked: the tiles it can step to. |
| 129 | { name: 'move-picked', act: async (page) => { await page.click('[data-action=move]') } }, |
| 130 | ] |
| 131 | |
| 132 | mkdirSync(OUT, { recursive: true }) |
| 133 | // The same base as the build, or the preview serves its assets somewhere else. |
| 134 | const server = await preview({ base: APP_PATH, preview: { port: 4179, strictPort: true } }) |
| 135 | // Chromium's new headless mode (the full browser), not Playwright's default headless shell: the |
| 136 | // shell's screenshots of the WebGL canvas show a black strip that isn't in the drawing. |
| 137 | const browser = await chromium.launch({ channel: 'chromium' }) |
| 138 | try { |
| 139 | for (const { name, viewport = PHONE, query = '', act, settle = 800 } of SHOTS) { |
| 140 | const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE }) |
| 141 | await page.goto(`http://localhost:4179${APP_PATH}?${query.includes('seed=') ? '' : 'seed=1&'}${query}`) |
| 142 | await page.waitForSelector('body > canvas') // the game's, not a clip's |
| 143 | await page.waitForTimeout(300) |
| 144 | await act(page) |
| 145 | // Long enough for the turn's animations to finish and the banner to come in (style.css). |
| 146 | await page.waitForTimeout(settle) |
| 147 | await page.screenshot({ path: `${OUT}/${name}.png` }) |
| 148 | await page.close() |
| 149 | console.log(` ${OUT}/${name}.png`) |
| 150 | } |
| 151 | } finally { |
| 152 | await browser.close() |
| 153 | server.httpServer.close() |
| 154 | } |