#!/usr/bin/env python3 """Screenshot the game in a headless browser, after setting up a scene with JavaScript. python3 tools/shot.py OUT.png [options] Starts a new world in dist/index.html (run python3 build.py first), runs your setup code in the page (every game global is available: setCell, placeBlock, placeMachineAt, bot, SET, GAME ...), runs the simulation for --ticks steps (it is paused otherwise, so the same options give the same picture), then saves a PNG. Open the PNG to look at it. The camera follows the bot. To frame something else, set CAM = [x, y] (in grains) in your setup code and the bot is moved there before the shot. SX, SY are the bot's starting position. Options: --look pixel|smooth which look to draw (default: pixel; use smooth only to check graphics) --seed N world seed (default 424242) --size WxH world size in grains (default 2048x1024) --setup FILE.js code to run once the world is ready (see tools/scenes/ for examples) --js CODE code to run after --setup (both may be given) --ticks N simulation ticks to run after the setup (default 0) --zoom Z screen pixels per grain (default 6) --hud keep the HUD and build menu (hidden by default) --previews keep the cursor previews (drill aim, build ghost); hidden by default --viewport WxH browser window size (default 1280x760) --clip X,Y,W,H save only this part of the window --no-light switch off lighting (shows true colours, as in daylight) --freeze draw the last frame at a fixed time and frame count, so animation (belts, saws, the bot's bob) is caught at the same moment every time: two shots of the same scene then match pixel for pixel, and a before / after pair shows only what really changed --page PATH another built page to load (default dist/index.html) Exit status 1 if the page threw an error (they are printed). Example: a belt carrying a heap of sand, in the smooth look: python3 tools/shot.py /tmp/belt.png --look smooth --setup tools/scenes/clear-sky.js \\ --js "placeBlocks(BP_BELT, 1, blockLine(SX/4+2, SY/4+6, SX/4+12, SY/4+6), undefined, false)" --ticks 30 """ import argparse, pathlib, sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from where import translate from use_venv import ensure_playwright; ensure_playwright() from browser import launch, gpu_option ROOT = pathlib.Path(__file__).resolve().parent.parent FREEZE = """(() => { const real = performance.now.bind(performance); window.__frozenNow = null; performance.now = () => window.__frozenNow === null ? real() : window.__frozenNow; })();""" def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('out') ap.add_argument('--look', choices=['pixel', 'smooth'], default='pixel') ap.add_argument('--seed', type=int, default=424242) ap.add_argument('--size', default='2048x1024') ap.add_argument('--setup') ap.add_argument('--js') ap.add_argument('--ticks', type=int, default=0) ap.add_argument('--zoom', type=float, default=6) ap.add_argument('--hud', action='store_true') ap.add_argument('--previews', action='store_true') ap.add_argument('--viewport', default='1280x760') ap.add_argument('--clip') ap.add_argument('--freeze', action='store_true') ap.add_argument('--no-light', action='store_true') ap.add_argument('--page', default=str(ROOT / 'dist' / 'index.html')) gpu_option(ap) a = ap.parse_args() ww, wh = (int(v) for v in a.size.split('x')) vw, vh = (int(v) for v in a.viewport.split('x')) errors = [] with launch(gpu=a.gpu, viewport={'width': vw, 'height': vh}, page_path=a.page) as (b, pg, url): pg.on('pageerror', lambda e: errors.append(translate(e.stack or str(e)))) pg.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' else None) pg.add_init_script(FREEZE if a.freeze else '') pg.goto(url) pg.evaluate("SET.fog = false") pg.wait_for_timeout(300) # pause the moment the world exists, so the shot depends only on the seed, the setup # code and --ticks (simTick is deterministic), not on how fast the browser ran # (wrapped in a function: Playwright calls a string that evaluates to a function) pg.evaluate("() => { const enter = enterGame; enterGame = function () { enter(); GAME.paused = true; }; }") pg.evaluate(f"SET.look = '{a.look}'; {"SET.lighting = 'off';" if a.no_light else ''} startNewWorld({a.seed}, {ww}, {wh})") pg.wait_for_function('GAME.running === true', timeout=60000) pg.evaluate("document.querySelectorAll('.card, .toast').forEach(e => e.remove())") # SX, SY: the bot's position when the world starts, handy for placing things near it pg.evaluate(f"window.SX = Math.floor(bot.x); window.SY = Math.floor(bot.y); SET.zoom = {a.zoom}; resizeRender();") for code in [pathlib.Path(a.setup).read_text() if a.setup else None, a.js]: if code: pg.evaluate(f'() => {{ {code}\n}}') if a.ticks: pg.evaluate(f'for (let k = 0; k < {a.ticks}; k++) simTick();') pg.evaluate(f"""GAME.paused = true; $('hud').hidden = {'false' if a.hud else 'true'}; GAME.hidePreviews = {'false' if a.previews else 'true'}; if (window.CAM) {{ bot.x = CAM[0] - bot.w / 2; bot.y = CAM[1] - bot.h / 2; }} snapCamera(); R.snapNext = true;""") pg.wait_for_timeout(600) if a.freeze: # stop the frame loop, then draw one last frame at a fixed time and frame count pg.evaluate("() => { window.__frozenNow = 5000; window.requestAnimationFrame = () => 0; }") pg.wait_for_timeout(100) pg.evaluate("() => { R.frame = 999; if (typeof SM !== 'undefined') SM.t0 = 0; frame(lastRender + 1000); }") kw = {} if a.clip: x, y, w, h = (float(v) for v in a.clip.split(',')) kw['clip'] = {'x': x, 'y': y, 'width': w, 'height': h} pathlib.Path(a.out).parent.mkdir(parents=True, exist_ok=True) pg.screenshot(path=a.out, **kw) note = pg.evaluate("SET.look === 'smooth' ? (SM.ok ? 'smooth look' : 'smooth look FAILED: ' + SM.why) : 'pixel look'") b.close() print(f'saved {a.out} ({note})') for e in errors: print('ERROR', e) sys.exit(1 if errors else 0) if __name__ == '__main__': main()