explodorama / files / tools / screenshots.mjs
screenshots.mjs
5551eb37 2026-10-20 Pavel Safronov | 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 | */ | |
05e79f5f 2026-10-27 Pavel Safronov | 7 | import { mkdirSync, readFileSync } from 'node:fs' |
5551eb37 2026-10-20 Pavel Safronov | 8 | import { chromium } from 'playwright' |
| 9 | import { preview } from 'vite' | |
| 10 | ||
| 11 | const OUT = 'docs/screenshots' | |
05e79f5f 2026-10-27 Pavel Safronov | 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}/` | |
5551eb37 2026-10-20 Pavel Safronov | 17 | const PHONE = { width: 390, height: 844 } |
| 18 | const DESKTOP = { width: 1280, height: 720 } | |
99869630 2026-10-23 Pavel Safronov | 19 | /** Phone-wide and tall enough for a whole almanac list. */ |
| 20 | const TALL = { width: 390, height: 2100 } | |
5551eb37 2026-10-20 Pavel Safronov | 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 | ||
32dd53cd 2026-10-25 Pavel Safronov | 29 | /** |
e6bb7c42 2026-10-25 Pavel Safronov | 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.) | |
32dd53cd 2026-10-25 Pavel Safronov | 33 | */ |
82adee18 2026-10-20 Pavel Safronov | 34 | async function tileCentre(page, x, y) { |
e6bb7c42 2026-10-25 Pavel Safronov | 35 | return page.evaluate(([x, y]) => window.tilePoint(x, y), [x, y]) |
82adee18 2026-10-20 Pavel Safronov | 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 | ||
5551eb37 2026-10-20 Pavel Safronov | 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') }, | |
82adee18 2026-10-20 Pavel Safronov | 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') }, | |
5551eb37 2026-10-20 Pavel Safronov | 51 | // Standing next to our own bomb as it goes off. |
| 52 | { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') }, | |
e3d8dc49 2026-10-27 Pavel Safronov | 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') }, | |
5551eb37 2026-10-20 Pavel Safronov | 57 | { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, |
c37a2b0b 2026-10-20 Pavel Safronov | 58 | // Level 13 has all three enemy types so far, each with its intent arrow. |
565a6873 2026-10-25 Pavel Safronov | 59 | { name: 'enemies', query: 'level=19', act: press('Space', 'Space') }, |
52d68b59 2026-10-24 Pavel Safronov | 60 | // Tapped a far tile: the way there in blue, and Move turned into Go; then one Go. |
82adee18 2026-10-20 Pavel Safronov | 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 | }, | |
d3e4ed26 2026-10-25 Pavel Safronov | 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') }, | |
e3d8dc49 2026-10-27 Pavel Safronov | 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') }, | |
7f242d36 2026-10-25 Pavel Safronov | 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') }, | |
e6bb7c42 2026-10-25 Pavel Safronov | 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) }, | |
6e94f995 2026-10-24 Pavel Safronov | 86 | // Developer mode: the rainbow gem uncovered right of the player, and the mode's tag in the top bar... |
beb9f2b7 2026-10-21 Pavel Safronov | 87 | { name: 'dev', query: 'dev', act: async () => {} }, |
6e94f995 2026-10-24 Pavel Safronov | 88 | // ...and taking it: every power-up on offer. |
| 89 | { name: 'dev-offer', query: 'dev', act: press('KeyD') }, | |
99869630 2026-10-23 Pavel Safronov | 90 | // "What is that?" (the ? button beside the menu) on, then tapping an enemy explains it; the top bar's items |
ea380ee8 2026-10-22 Pavel Safronov | 91 | // explain themselves with a plain tap. |
99869630 2026-10-23 Pavel Safronov | 92 | { name: 'info-mode', act: async (page) => { await page.click('#info-button') } }, |
32dd53cd 2026-10-25 Pavel Safronov | 93 | { name: 'info-enemy', act: async (page) => { await page.click('#info-button'); await tap(1, 7)(page) } }, |
a4fab0fb 2026-10-21 Pavel Safronov | 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 | }, | |
99869630 2026-10-23 Pavel Safronov | 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 | })), | |
d3e4ed26 2026-10-25 Pavel Safronov | 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 () => {} }, | |
36c5d6a2 2026-10-24 Pavel Safronov | 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 | }, | |
52d68b59 2026-10-24 Pavel Safronov | 128 | // Move picked: the tiles it can step to. |
| 129 | { name: 'move-picked', act: async (page) => { await page.click('[data-action=move]') } }, | |
5551eb37 2026-10-20 Pavel Safronov | 130 | ] |
| 131 | ||
| 132 | mkdirSync(OUT, { recursive: true }) | |
05e79f5f 2026-10-27 Pavel Safronov | 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 } }) | |
6e94f995 2026-10-24 Pavel Safronov | 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' }) | |
5551eb37 2026-10-20 Pavel Safronov | 138 | try { |
36c5d6a2 2026-10-24 Pavel Safronov | 139 | for (const { name, viewport = PHONE, query = '', act, settle = 800 } of SHOTS) { |
5551eb37 2026-10-20 Pavel Safronov | 140 | const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE }) |
05e79f5f 2026-10-27 Pavel Safronov | 141 | await page.goto(`http://localhost:4179${APP_PATH}?${query.includes('seed=') ? '' : 'seed=1&'}${query}`) |
32dd53cd 2026-10-25 Pavel Safronov | 142 | await page.waitForSelector('body > canvas') // the game's, not a clip's |
5551eb37 2026-10-20 Pavel Safronov | 143 | await page.waitForTimeout(300) |
| 144 | await act(page) | |
ea380ee8 2026-10-22 Pavel Safronov | 145 | // Long enough for the turn's animations to finish and the banner to come in (style.css). |
36c5d6a2 2026-10-24 Pavel Safronov | 146 | await page.waitForTimeout(settle) |
5551eb37 2026-10-20 Pavel Safronov | 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 | } |