Add hail juice, plant shred, and a way to screenshot the game

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>
This commit is contained in:
m3ultra 2026-07-17 03:03:54 +10:00
parent 0813b18f5a
commit f2527055a5
9 changed files with 288 additions and 0 deletions

View File

@ -1507,3 +1507,69 @@ Format: `[lane letter] YYYY-MM-DD — note`
garden-killer and drainage answers rain. Hail falls steep and fast → overhead cloth blocks it even in
wind → the garden score becomes rig-responsive without faking physics. Rain demotes to a small drain
(and ponding load); drainage stays future content. SPRINT5 wires it.
[E] 2026-07-17 — 📊 **A finding worth more than this sprint's assets: only ONE of my five textures is
consumed.** I grepped `web/world/js/` for every texture I've shipped:
· `sail_weave`**live**, and Lane B took the recipe verbatim, down to keeping my comment.
· `pond_water` / `pond_normal` — 0 refs (fair, B's ponding is this sprint).
· `sail_tears` — 0 refs (fair, M3 isn't scoped).
· **`grass_atlas` — 0 refs, four sprints.** PLAN3D §5-E-9 asked for it, SPRINT3 §A-6 and SPRINT4
listed it, and nothing has ever loaded it. It's 28 KB of dead weight in the repo.
The one texture that got used is the one I wrote an exact copy-paste recipe for. That's not a
coincidence and it's the reason both of this sprint's textures ship with one below. **Lane A: either
take the grass recipe or tell me to delete the atlas — I'd rather bin it than keep shipping it.**
[E] 2026-07-17 — **LANE C — hail juice (SPRINT5 §E-1), shaped to fit YOUR pattern, not mine.** Your rain is
a `BoxGeometry` + flat `MeshBasicMaterial` and skyfx loads no external texture at all, so I've given
you both options and you should ignore whichever is wrong:
· `hail_stone_01_v1.glb` — 22 mm, 20 tris, lumpy (a sphere at that size reads as a bubble). Drops
into `new THREE.InstancedMesh(stoneGeo, stoneMat, n)` exactly like your streaks. If you'd rather
stones stay a box, bin it, no feelings.
· `models/textures/hail_pips.png` — 256², **2×2 atlas, cells: 0 sharp pip, 1 spiked burst, 2 splash
ring (the ground decal), 3 soft/dying**. A flat quad can't be round and an impact is round, which
is the only reason this is a texture. Cell → UV, and note row 0 is the BOTTOM so this matches
three's v-up directly:
const A = await new THREE.TextureLoader().loadAsync('/world/models/textures/hail_pips.png');
A.colorSpace = THREE.SRGBColorSpace;
const cellUV = (i) => [(i % 2) * 0.5, Math.floor(i / 2) * 0.5]; // [u0, v0], each 0.5 wide
// per instance: offset the quad's uv by cellUV(age < .05 ? 0 : age < .12 ? 1 : 3)
// ground hits: cell 2, flat on the grass, scale up as it ages
const mat = new THREE.MeshBasicMaterial({ map: A, transparent: true,
depthWrite: false, fog: false }); // same flags as your rain
Ice is near-white with a cold rim so it reads on both the sand cloth and dark wet grass. Sizes:
hail_pips 39 KB, stone 3 KB.
[E] 2026-07-17 — **LANE A/C — plant shred (§E-2):** `models/textures/plant_shred.png`, 256², 2×2, four torn
blade-scraps with a darker midrib, same cell→UV as above. Elongated on purpose — my first pass was
radial and read as green potatoes; it's the long axis plus the rib that says "leaf". Fire a dozen on a
hail burst over the bed, random spin, ~0.6 s, gravity + a little wind drift, and pair it with the
existing `plants_full``plants_tattered``plants_dead` swap so the puff explains the state change
instead of the bed just quietly becoming worse.
[E] 2026-07-17 — 🔧 **THE YARD PICTURES EXIST. `docs/yard_day.jpg` + `docs/yard_night.jpg`** (carried since
Sprint 2; I stopped waiting). I did **not** touch server.py or main.js — instead
`tools/yardshot/shot_server.py` is a Lane E tool: it serves the repo like server.py and additionally
takes `POST /shot?name=<n>`, writing the body to `docs/<n>.png|jpg`. The browser posts a Blob, the
bytes go straight to disk, and no base64 crosses a text channel.
Three gotchas worth knowing, because they cost me the afternoon:
· `canvas.toBlob` is **async** — the WebGL buffer is cleared by the time it encodes, so it hands
back null. `toDataURL` is sync and reads the frame you just drew. Render in the SAME tick.
· a **backgrounded tab lays the canvas out at 0×0**, and `toDataURL` then returns the string
`"data:,"`. Force `renderer.setSize(w, h, false)` before capturing.
· rAF is paused there too, so drive `SHADES.step()` yourself — which the harness already supports.
**Lane A: `do_POST` is ~25 lines and it's yours for the taking** — lift it into server.py, delete
`tools/yardshot/`, and anyone can screenshot the game forever. I'm not going to keep asking; the tool
works standalone in the meantime.
[E] 2026-07-17 — the pictures are the yard UNRIGGED (no sail): `game.setPhase('storm')` fast-forwards time
and the sky beautifully — C's night pass is genuinely atmospheric, 27 m/s of driving rain over a dark
yard — but it doesn't rig a sail, and I wasn't going to drive B's rigging session from the console to
fake one. **When gate 3 lands, ping me and I'll reshoot both with a rigged hypar in the frame** — that's
the picture DESIGN.md actually wants, and it's now a one-minute job rather than a four-sprint one.
(Also: `SHADES.wind` is a stale snapshot — it still pointed at the calm wind while the game was
genuinely running the wild night. Cost me a wrong conclusion for a minute. Worth a getter, A.)
[E] 2026-07-17 — 👀 the tree branch stubs still read as coat hooks, and now there's a picture of it —
`docs/yard_day.jpg`, left-hand gum. Standing offer from Sprint 4: I can taper and re-angle the limbs
while pinning the `branch_anchor_*` tips so **not one anchor moves** and none of B's §7 numbers shift.
It's contained and it's the most visible art problem in the hero shot. Say the word.

BIN
docs/yard_day.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

BIN
docs/yard_night.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@ -118,6 +118,7 @@ PAL = {
"gnome_coat": "#3E6FA8",
"gnome_hat": "#B33C36",
"bristle": "#C9A659", # broom straw
"hail_ice": "#DCEAF2", # hailstone
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
}
@ -1188,6 +1189,30 @@ def build_garden_gnome_01_broken(name):
return root
def build_hail_stone_01(name):
"""One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry
over sprites").
Lane C this is offered, not imposed: your rain is a BoxGeometry with a flat
material and no texture, and this drops into that exact pattern
(`new THREE.InstancedMesh(stoneGeo, stoneMat, n)`). If you'd rather stones be
a box like the streaks, ignore this and the pip atlas still stands on its own.
Lumpy on purpose a sphere at this size reads as a bubble, and real stones
are accreted knobbles. 80 tris.
"""
rng = rng_for(name)
root = add_empty(name)
ice = get_material("Mat_Ice", PAL["hail_ice"], 0.25)
stone = add_ico(f"{name}_stone", 0.011, (0, 0, 0.011), ice, subdiv=1,
scale=(1.0, rng.uniform(0.82, 0.95), rng.uniform(0.78, 0.92)),
jitter=0.0018, rng=rng)
join_group([stone], "stone", root)
stamp(root, name, "weather")
root["diameter_m"] = 0.022
root["mass_hint"] = 0.006
return root
def build_broom_01(name):
"""The poke-the-pond tool (SPRINT4 §Lane E-2).
@ -1515,6 +1540,88 @@ def build_pond_textures():
return [p1, p2]
def build_hail_and_shred_atlases():
"""Hail impact pips + plant shred fragments (SPRINT5 §Lane E-1/2).
Both are 2x2 atlases of alpha sprites for InstancedMesh billboards, which is
the shape Lane C's rain already has — instanced quads, DynamicDrawUsage,
depthWrite off. The one thing a flat-coloured quad cannot do is be round, and
an impact is round, which is the whole reason these are textures at all.
Cells (hail_pips): 0 sharp pip, 1 spiked burst, 2 splash ring (the ground
decal), 3 soft fading pip. Pick per age so one impact can play 0 -> 1 -> 3
and a ground hit can just use 2.
"""
import numpy as np
SIZE, CELL = 256, 128
pips = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
r = np.sqrt(nx * nx + ny * ny)
th = np.arctan2(ny, nx)
if idx == 0: # sharp pip: hot core, fast falloff
a = np.clip(1.0 - r, 0, 1) ** 3.2
elif idx == 1: # burst: core plus radiating spikes
spikes = 0.5 + 0.5 * np.cos(th * 8.0)
a = np.clip(1.0 - r, 0, 1) ** 2.6 + 0.5 * spikes * np.clip(1.0 - r, 0, 1) ** 5.0
elif idx == 2: # splash ring — the ground decal
a = np.exp(-((r - 0.62) ** 2) / 0.012) * np.clip(1.0 - r, 0, 1) ** 0.4
else: # soft, dying
a = np.exp(-(r ** 2) / 0.20) * 0.75
a = np.clip(a, 0, 1)
# Hail is ice: near-white with a cold rim, so it reads against both the
# sand-coloured cloth and dark wet grass.
pips[cy:cy + CELL, cx:cx + CELL, 0] = 0.88 + 0.12 * a
pips[cy:cy + CELL, cx:cx + CELL, 1] = 0.94 + 0.06 * a
pips[cy:cy + CELL, cx:cx + CELL, 2] = 1.0
pips[cy:cy + CELL, cx:cx + CELL, 3] = a
p1, kb1 = save_png(pips, "hail_pips")
print(f" hail_pips.png {SIZE}x{SIZE}, 4 cells (pip/burst/ring/soft), {kb1} KB")
# --- plant shred ------------------------------------------------------
# Torn leaf fragments, not dots: the bed is being shredded, and a green dot
# reads as a bug. Each cell is one ragged blade-scrap with a darker midrib.
shred = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
for idx in range(4):
r_ = rng_for(f"plant_shred_{idx}")
cy, cx = (idx // 2) * CELL, (idx % 2) * CELL
Y, X = np.mgrid[0:CELL, 0:CELL]
c = (CELL - 1) / 2.0
nx, ny = (X - c) / c, (Y - c) / c
th = np.arctan2(ny, nx)
r = np.sqrt(nx * nx + ny * ny)
# A torn blade-scrap: genuinely elongated, then ripped along the edge.
# A radial lobe alone gives a teardrop, which at particle size reads as a
# green potato — it's the long axis plus the midrib that says "leaf".
# Aspect varies per cell so one burst isn't four copies of a shape.
aspect = r_.uniform(0.38, 0.62)
ex, ey = nx * aspect, ny / aspect
er = np.sqrt(ex * ex + ey * ey)
tear = np.zeros_like(th)
for k in range(1, 5):
tear += (0.06 / k) * np.sin(th * (2 * k + 1) + r_.uniform(0, math.tau))
inside = er < (0.42 + tear)
g = r_.uniform(0.42, 0.62)
rib = np.abs(ny) < 0.035 # the midrib, darker
col = np.where(rib, 0.65, 1.0)
shred[cy:cy + CELL, cx:cx + CELL, 0] = np.where(inside, g * 0.55 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 1] = np.where(inside, g * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 2] = np.where(inside, g * 0.34 * col, 0)
shred[cy:cy + CELL, cx:cx + CELL, 3] = np.where(inside, 1.0, 0.0)
p2, kb2 = save_png(shred, "plant_shred")
print(f" plant_shred.png {SIZE}x{SIZE}, 4 leaf scraps, {kb2} KB")
return [p1, p2]
def build_grass_atlas():
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
python) and saved through bpy's image API. Lane A instances quads with this."""
@ -1638,6 +1745,9 @@ ASSETS = [
# whatever is behind it.
# Wider than the 0.30 head: the bristles splay past it, which is what a worn
# broom does. A real yard broom is 0.300.45 m across.
dict(name="hail_stone_01", fn=build_hail_stone_01,
dims=((0.015, 0.030), (0.012, 0.028), (0.012, 0.028)),
nodes=["stone"]),
dict(name="broom_01", fn=build_broom_01,
dims=((0.28, 0.45), (0.04, 0.12), (1.35, 1.50)),
nodes=["handle", "head", "bristles", "grip_anchor", "poke_tip"]),
@ -1970,6 +2080,7 @@ def main():
build_grass_atlas()
build_sail_textures()
build_pond_textures()
build_hail_and_shred_atlases()
debris = [] if no_debris else copy_debris()
failures = []

View File

@ -0,0 +1,110 @@
#!/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()

View File

@ -79,6 +79,7 @@ const ASSETS = [
nodes: ['palings', 'rails', 'debris_palings'] },
{ name: 'broom_01', h: [1.35, 1.50],
nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
{ name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] },
];
function sizeOf(gltf) {

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB