fuzzyworld

explodorama / files / tools / screenshots.mjs

screenshots.mjs

Blame: the commit that last changed each line · file · history

5551eb37 2026-10-20 Pavel Safronov1/**
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 Safronov7import { mkdirSync, readFileSync } from 'node:fs'
5551eb37 2026-10-20 Pavel Safronov8import { chromium } from 'playwright'
9import { preview } from 'vite'
10
11const OUT = 'docs/screenshots'
05e79f5f 2026-10-27 Pavel Safronov12/**
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 */
16const APP_PATH = `/${JSON.parse(readFileSync('package.json', 'utf8')).fuzzyworld.app}/`
5551eb37 2026-10-20 Pavel Safronov17const PHONE = { width: 390, height: 844 }
18const DESKTOP = { width: 1280, height: 720 }
99869630 2026-10-23 Pavel Safronov19/** Phone-wide and tall enough for a whole almanac list. */
20const TALL = { width: 390, height: 2100 }
5551eb37 2026-10-20 Pavel Safronov21
22const 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 Safronov29/**
e6bb7c42 2026-10-25 Pavel Safronov30 * 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 Safronov33 */
82adee18 2026-10-20 Pavel Safronov34async function tileCentre(page, x, y) {
e6bb7c42 2026-10-25 Pavel Safronov35 return page.evaluate(([x, y]) => window.tilePoint(x, y), [x, y])
82adee18 2026-10-20 Pavel Safronov36}
37
38const 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 Safronov43const 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 Safronov49 // 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 Safronov51 // 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 Safronov53 // 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 Safronov57 { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') },
c37a2b0b 2026-10-20 Pavel Safronov58 // Level 13 has all three enemy types so far, each with its intent arrow.
565a6873 2026-10-25 Pavel Safronov59 { name: 'enemies', query: 'level=19', act: press('Space', 'Space') },
52d68b59 2026-10-24 Pavel Safronov60 // Tapped a far tile: the way there in blue, and Move turned into Go; then one Go.
82adee18 2026-10-20 Pavel Safronov61 { 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 Safronov72 // 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 Safronov75 // 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 Safronov77 // 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 Safronov83 // 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 Safronov86 // Developer mode: the rainbow gem uncovered right of the player, and the mode's tag in the top bar...
beb9f2b7 2026-10-21 Pavel Safronov87 { name: 'dev', query: 'dev', act: async () => {} },
6e94f995 2026-10-24 Pavel Safronov88 // ...and taking it: every power-up on offer.
89 { name: 'dev-offer', query: 'dev', act: press('KeyD') },
99869630 2026-10-23 Pavel Safronov90 // "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 Safronov91 // explain themselves with a plain tap.
99869630 2026-10-23 Pavel Safronov92 { name: 'info-mode', act: async (page) => { await page.click('#info-button') } },
32dd53cd 2026-10-25 Pavel Safronov93 { name: 'info-enemy', act: async (page) => { await page.click('#info-button'); await tap(1, 7)(page) } },
a4fab0fb 2026-10-21 Pavel Safronov94 { 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 Safronov104 // 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 Safronov115 // 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 Safronov117 // 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 Safronov128 // 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 Safronov130]
131
132mkdirSync(OUT, { recursive: true })
05e79f5f 2026-10-27 Pavel Safronov133// The same base as the build, or the preview serves its assets somewhere else.
134const server = await preview({ base: APP_PATH, preview: { port: 4179, strictPort: true } })
6e94f995 2026-10-24 Pavel Safronov135// 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.
137const browser = await chromium.launch({ channel: 'chromium' })
5551eb37 2026-10-20 Pavel Safronov138try {
36c5d6a2 2026-10-24 Pavel Safronov139 for (const { name, viewport = PHONE, query = '', act, settle = 800 } of SHOTS) {
5551eb37 2026-10-20 Pavel Safronov140 const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE })
05e79f5f 2026-10-27 Pavel Safronov141 await page.goto(`http://localhost:4179${APP_PATH}?${query.includes('seed=') ? '' : 'seed=1&'}${query}`)
32dd53cd 2026-10-25 Pavel Safronov142 await page.waitForSelector('body > canvas') // the game's, not a clip's
5551eb37 2026-10-20 Pavel Safronov143 await page.waitForTimeout(300)
144 await act(page)
ea380ee8 2026-10-22 Pavel Safronov145 // Long enough for the turn's animations to finish and the banner to come in (style.css).
36c5d6a2 2026-10-24 Pavel Safronov146 await page.waitForTimeout(settle)
5551eb37 2026-10-20 Pavel Safronov147 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}