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:
parent
0813b18f5a
commit
f2527055a5
66
THREADS.md
66
THREADS.md
@ -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
BIN
docs/yard_day.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/yard_night.jpg
Normal file
BIN
docs/yard_night.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
@ -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.30–0.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 = []
|
||||
|
||||
110
tools/yardshot/shot_server.py
Normal file
110
tools/yardshot/shot_server.py
Normal 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()
|
||||
@ -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) {
|
||||
|
||||
BIN
web/world/models/hail_stone_01_v1.glb
Normal file
BIN
web/world/models/hail_stone_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/hail_pips.png
Normal file
BIN
web/world/models/textures/hail_pips.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
web/world/models/textures/plant_shred.png
Normal file
BIN
web/world/models/textures/plant_shred.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Loading…
Reference in New Issue
Block a user