diff --git a/THREADS.md b/THREADS.md index 1a4104f..4ac80d4 100644 --- a/THREADS.md +++ b/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=`, writing the body to `docs/.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. diff --git a/docs/yard_day.jpg b/docs/yard_day.jpg new file mode 100644 index 0000000..46a6b41 Binary files /dev/null and b/docs/yard_day.jpg differ diff --git a/docs/yard_night.jpg b/docs/yard_night.jpg new file mode 100644 index 0000000..b2e47ff Binary files /dev/null and b/docs/yard_night.jpg differ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 60bbf21..5e5933e 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -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 = [] diff --git a/tools/yardshot/shot_server.py b/tools/yardshot/shot_server.py new file mode 100644 index 0000000..ad389df --- /dev/null +++ b/tools/yardshot/shot_server.py @@ -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=`, writing the raw body to docs/.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() diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 24aa058..6884688 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -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) { diff --git a/web/world/models/hail_stone_01_v1.glb b/web/world/models/hail_stone_01_v1.glb new file mode 100644 index 0000000..1d8b811 Binary files /dev/null and b/web/world/models/hail_stone_01_v1.glb differ diff --git a/web/world/models/textures/hail_pips.png b/web/world/models/textures/hail_pips.png new file mode 100644 index 0000000..c9794d3 Binary files /dev/null and b/web/world/models/textures/hail_pips.png differ diff --git a/web/world/models/textures/plant_shred.png b/web/world/models/textures/plant_shred.png new file mode 100644 index 0000000..54d33c4 Binary files /dev/null and b/web/world/models/textures/plant_shred.png differ