Hail (SPRINT5 §E-1) ships two ways because Lane C's rain is a BoxGeometry with a flat material and skyfx loads no external texture at all: hail_stone_01 as geometry that drops into their existing InstancedMesh pattern, and hail_pips.png as a 4-cell atlas for impacts. A flat quad can't be round and an impact is round — that's the only reason the atlas is a texture. Both come with a copy-paste recipe, because a grep showed exactly one of my five shipped textures is consumed: sail_weave, the one I wrote a recipe for. grass_atlas has sat unreferenced for four sprints. plant_shred (§E-2) is elongated after the first pass came out radial and read as green potatoes — it's the long axis plus the midrib that says "leaf". tools/yardshot/ finally lands the DESIGN.md pictures, carried since Sprint 2. It does NOT touch server.py or main.js: it's a Lane E stdlib server that serves the repo and takes POST /shot, so the browser posts a Blob and bytes go straight to disk instead of base64 through a console. do_POST is ~25 lines and Lane A is welcome to lift it and delete this. Three traps found on the way, all documented: toBlob is async so the WebGL buffer is already cleared (toDataURL is sync); a backgrounded tab lays the canvas out at 0x0 and toDataURL then returns "data:,"; rAF is paused there so step() must be driven by hand. Selftest 209/0/0, 36 output files byte-identical across two runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
SHADES — Lane E screenshot capture. Python stdlib only, same house rule as
|
|
server.py: no pip, no venv, no build step.
|
|
|
|
WHY THIS EXISTS
|
|
---------------
|
|
There is no way to get a game screenshot onto disk. The canvas is WebGL, so
|
|
`toDataURL` hands back a blank buffer unless you render and read in the same
|
|
tick, and even then the only channel out of the page is text — moving one
|
|
900x506 JPEG as base64 costs ~60 KB of round-trip to save one picture.
|
|
DESIGN.md has wanted a shot of the assembled yard since Sprint 2 and it has been
|
|
carried four sprints waiting on a five-line fix in a file Lane E doesn't own.
|
|
|
|
So this serves the repo exactly like server.py, and additionally accepts
|
|
`POST /shot?name=<n>`, writing the raw body to docs/<n>.png. The browser posts a
|
|
Blob and the bytes go straight to disk — they never touch a text channel.
|
|
|
|
python3 tools/yardshot/shot_server.py --port 8815
|
|
|
|
then, from the page:
|
|
|
|
SHADES.render(); // same tick as the read!
|
|
document.getElementById('c').toBlob(
|
|
(b) => fetch('/shot?name=yard_day', { method: 'POST', body: b }));
|
|
|
|
LANE A: this is deliberately NOT an edit to server.py — that's your file, and
|
|
§6 says post the need rather than reach into it. I posted it twice; nobody had
|
|
the spare hands, which is fair. `do_POST` below is the whole fix: lift it
|
|
verbatim into server.py, delete this directory, and anyone can screenshot the
|
|
game forever after. Until then this stays a Lane E tool and touches nothing.
|
|
|
|
Dev tool, and it writes files, so: binds loopback only, the name is restricted
|
|
to a safe charset (no traversal), the body is size-capped, and it only ever
|
|
writes .png into docs/.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import http.server
|
|
import re
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
OUT_DIR = ROOT / "docs"
|
|
SAFE_NAME = re.compile(r"^[a-z0-9_\-]{1,64}$")
|
|
MAX_BYTES = 20 * 1024 * 1024
|
|
|
|
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, directory=str(ROOT), **kwargs)
|
|
|
|
def do_POST(self): # noqa: N802 (stdlib naming)
|
|
url = urlparse(self.path)
|
|
if url.path != "/shot":
|
|
self.send_error(404, "only POST /shot")
|
|
return
|
|
|
|
name = (parse_qs(url.query).get("name") or ["shot"])[0]
|
|
if not SAFE_NAME.match(name):
|
|
self.send_error(400, "name must match [a-z0-9_-]{1,64}")
|
|
return
|
|
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
if not 0 < length <= MAX_BYTES:
|
|
self.send_error(413, "empty or too large")
|
|
return
|
|
|
|
data = self.rfile.read(length)
|
|
# A blank WebGL read is the failure mode this tool exists to dodge, so
|
|
# refuse to write one rather than quietly commit an empty picture.
|
|
if data.startswith(b"\x89PNG"):
|
|
ext = "png"
|
|
elif data.startswith(b"\xff\xd8\xff"):
|
|
ext = "jpg"
|
|
else:
|
|
self.send_error(415, "body is neither PNG nor JPEG")
|
|
return
|
|
|
|
OUT_DIR.mkdir(exist_ok=True)
|
|
out = OUT_DIR / f"{name}.{ext}"
|
|
out.write_bytes(data)
|
|
print(f" shot -> {out.relative_to(ROOT)} ({len(data) // 1024} KB)")
|
|
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain")
|
|
self.send_header("Content-Length", "2")
|
|
self.end_headers()
|
|
self.wfile.write(b"ok")
|
|
|
|
def log_message(self, fmt, *args):
|
|
pass # the shot line above is the only output worth having
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Serve the repo + accept POST /shot")
|
|
ap.add_argument("--port", type=int, default=8815)
|
|
args = ap.parse_args()
|
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
print(f"serving {ROOT} on http://127.0.0.1:{args.port}/")
|
|
print(f" game http://127.0.0.1:{args.port}/web/world/index.html")
|
|
print(f" shots -> {OUT_DIR}")
|
|
srv.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|