Files
deepgrain-foundry/tools/shot.py
T

124 lines
7.0 KiB
Python

#!/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)
--renderer webgpu|legacy pixel renderer (default: webgpu)
--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('--renderer', choices=['webgpu', 'legacy'], default='webgpu')
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.evaluate('CAPS_CHECK')
pg.evaluate('(r) => { SET.renderer = r; if (r === \'webgpu\' && !GPU.ok) throw Error(GPU.error || \'WebGPU unavailable\'); }', a.renderer)
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); }")
if a.look == 'pixel' and a.renderer == 'webgpu' and not pg.evaluate('R.gpuOn'):
raise RuntimeError('WebGPU fell back: ' + pg.evaluate('GPU.error'))
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}, {a.renderer if a.look == 'pixel' else 'legacy'})")
for e in errors:
print('ERROR', e)
sys.exit(1 if errors else 0)
if __name__ == '__main__':
main()