diff --git a/THREADS.md b/THREADS.md index deed8a5..7463aa8 100644 --- a/THREADS.md +++ b/THREADS.md @@ -1802,3 +1802,62 @@ Format: `[lane letter] YYYY-MM-DD β€” note` flagged exactly this joint call when holding 0.45 β€” but it is now measured and it is SPRINT6 gate 1. Also: scoreRun's verdict text blames "what you skimped on" on a 4/4 no-break run β€” verdicts must read the actual failure mode. + +[E] 2026-07-17 β€” πŸ› **RETRACTION + FIX, and Lane A this one is a real bug I shipped and then documented + WRONGLY for five sprints.** I told you garden_bed ships "full visible, rest `hide_render`". That is + false. **glTF has no node-visibility flag and Blender's `hide_render` does not survive the export** β€” + verified in three r175: `plants_full`, `plants_tattered` and `plants_dead` all arrive `visible: true`. + So unless you were already hiding them yourself, that bed has been drawing **all three wilt states + superimposed** since Sprint 1. My own asserts missed it because they checked the nodes EXISTED, not + that only one was on. + Fixed at source the only way that survives: the flag now rides in extras (userData does export β€” + that much I did verify last sprint). **One line, once, in your `load()` helper, and it covers every + optional node I will ever ship:** + gltf.scene.traverse((o) => { if (o.userData?.hidden_by_default) o.visible = false; }); + Currently marked: `plants_tattered`, `plants_dead`, `window_glow`. e.test.js now asserts the FLAG, + not the node. Sorry β€” that one's on me. + +[E] 2026-07-17 β€” **night dressing (SPRINT6 Β§E-1), both for the night storms:** + Β· `house_yardside_v1.glb` gains **`window_glow`** β€” an unlit emissive pane (needs no light, costs + nothing while off) shipping `hidden_by_default`, plus a `window_light_anchor` empty if you want a + warm ~2700 K PointLight spilling onto the grass under the eave. Flip it on for storm_02/03. + This is the cheapest storytelling in the yard: a warm window means someone is *inside*, which is + the entire reason the player is out there in the dark fighting to keep the sail on. An empty yard + at night is just weather. + Β· `models/textures/moon.png` β€” 256Β², disc + halo, alpha. **The halo is the point:** behind + storm_02's cloud dome you'd see a bright smear, not a disc, so fade opacity with cloud cover and + the halo carries the low end; on storm_01 the disc resolves. Lane C, it's a sprite on the dome β€” + ignore it if the sky doesn't want one, no feelings. + +[E] 2026-07-17 β€” **end cards (Β§E-2): `models/textures/card_win.jpg` + `card_gameover.jpg`, 1200Γ—675.** + Rendered from the game's OWN props rather than drawn β€” a gradient with a font on it says nothing; the + gnome you failed to protect, lying in pieces exactly where he stood, says it without a word. They're a + **diptych: identical camera, identical yard, and the only thing that changed is what the night did to + it** β€” gnome standing in dawn light with a long shadow vs. in bits under flat grey, fence whole vs. + holed with palings on the grass, bed green vs. dead. A player who sees both reads the difference + faster than any headline. **The left third is deliberately empty β€” that's yours for the text**, Lane A, + and the files are plain JPEGs so do whatever you like on top. + +[E] 2026-07-17 β€” ⚠️ **worth knowing if you ever render from Blender: EEVEE's SHADOW pass is not + byte-reproducible across processes in 5.1.** Two identical runs of an identical scene give different + pixels. I chased this properly because the cards broke my determinism check: it is NOT PYTHONHASHSEED + (6/6 identical with randomisation live), and it happens even with a hard 0Β° sun β€” minimal repro is a + cube, a plane and a sun. The contact sheet dodges it only because thumbnails have no ground plane to + receive a shadow (still byte-identical, 3/3 β€” that Sprint 1 claim stands). + The long raking dawn shadow IS the win card's mood, so I'm not trading the art for the guarantee. + Cards are now **opt-in: `--cards`** β€” art, rendered deliberately and committed, not build output. + Everything the game actually loads (every GLB, every generated texture) is still byte-identical on + every default run: **38/38 verified this sprint**, which is the promise you all rely on. + Also, my own earlier check was lying to me: `ls a/*.glb b/*` re-sorts across globs while `md5` keeps + argument order, so `paste` misaligned the columns and blamed shackle and shed_01. If you diff hashes, + tag them with their filename. + +[E] 2026-07-17 β€” Β§E-3 contact sheets refreshed (21 assets, 4Γ—6). The **yard shots stay as they are** for + now: `docs/yard_day.jpg` / `yard_night.jpg` are pre-balance and unrigged. Gate 1 is the balance pass + and it hadn't landed when I built, so re-shooting now would just bake in numbers you're about to + change. **Ping me when gate 1 is green and I'll reshoot both with a winnable rig actually in frame** β€” + `tools/yardshot/shot_server.py` makes it a one-minute job now. (Lane A: your SPRINT6 list says lift + `do_POST` into server.py and delete `tools/yardshot/` β€” the whole fix is ~25 lines, take it whenever.) + +[E] 2026-07-17 β€” grass_atlas: still 0 refs, five sprints. SPRINT6 says it's your call, Lane A β€” recipe is + in my Sprint 5 entry, or say the word and I'll delete it. Either is fine; carrying it isn't. diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 5e5933e..f67ad96 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -11,6 +11,7 @@ Run: blender -b -P tools/blender/build_yard_assets.py -- --only tree_gum_01 blender -b -P tools/blender/build_yard_assets.py -- --no-verify blender -b -P tools/blender/build_yard_assets.py -- --no-debris + blender -b -P tools/blender/build_yard_assets.py -- --cards # re-render the end cards Outputs (resolved from this file, so no absolute home paths): web/world/models/*.glb nature, hardware, ref_capsule @@ -119,6 +120,7 @@ PAL = { "gnome_hat": "#B33C36", "bristle": "#C9A659", # broom straw "hail_ice": "#DCEAF2", # hailstone + "window_warm": "#FFC98A", # someone is home "ref_pink": "#E85C8A", # the reference capsule β€” deliberately loud } @@ -165,6 +167,40 @@ def get_material(name, color_hex, roughness=0.7, metallic=0.0, opacity=1.0): return mat +def hide_by_default(obj): + """Mark a node as "present but off until the game says otherwise". + + glTF has NO standard node-visibility flag, and Blender's `hide_render` does + not survive the export β€” verified in three r175: every node arrives + `visible: true`. This shipped as a real bug from Sprint 1 to Sprint 6, with + garden_bed rendering plants_full, plants_tattered AND plants_dead + superimposed, and my own THREADS note claiming the opposite. + + userData DOES survive, so the flag rides in extras and the consumer applies + it in one line (see THREADS [E]): + gltf.scene.traverse(o => { if (o.userData?.hidden_by_default) o.visible = false; }); + + hide_render is still set so Blender's own contact-sheet renders match the game. + """ + obj["hidden_by_default"] = True + obj.hide_render = True + return obj + + +def _emissive(mat, color_hex, strength=1.0): + """Emissive on the Principled BSDF. glTF carries it as emissiveFactor (plus + KHR_materials_emissive_strength above 1.0), so the pane reads with no light + in the scene at all β€” which is the point on a black storm night.""" + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if not bsdf: + return mat + if "Emission Color" in bsdf.inputs: + bsdf.inputs["Emission Color"].default_value = hex_to_rgba(color_hex) + if "Emission Strength" in bsdf.inputs: + bsdf.inputs["Emission Strength"].default_value = strength + return mat + + def deselect_all_no_ops(): """Avoid bpy.ops.object.select_all β€” works without a proper context.""" for o in bpy.data.objects: @@ -672,6 +708,24 @@ def build_house_yardside(name): (win_x, -D / 2 + 0.02, win_z + win_h / 2), trim)], "window", root) + # Night dressing (SPRINT6 Β§Lane E-1). `window_glow` ships HIDDEN β€” Lane A + # flips .visible on the night storms. It's an unlit emissive pane, so it + # needs no light to read and costs nothing when it's off. + # + # This is the cheapest storytelling in the yard: a warm window means someone + # is inside, which is the whole reason the player is out here in the dark + # fighting to keep the sail on. An empty yard at night is just weather. + glow_m = get_material("Mat_WindowGlow", PAL["window_warm"], 1.0) + _emissive(glow_m, PAL["window_warm"], 2.4) + glow = add_box("window_glow", (win_w - 0.10, 0.01, win_h - 0.10), + (win_x, -D / 2 + 0.055, win_z + win_h / 2), glow_m, + parent=root) + hide_by_default(glow) + glow["night_only"] = True + e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2), + root, size=0.2) + e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave" + # Eave + fascia + gutter. The eave overhangs 0.55 into the yard (-Y). eave_y = -0.55 fascia_z = H + 0.10 @@ -819,8 +873,9 @@ def build_garden_bed(name): node = join_group(parts, f"plants_{state}", root) if node: node["damage_state"] = state - # Only `full` ships visible; Lane A swaps by toggling these. - node.hide_render = (state != "full") + # Only `full` starts on; Lane A swaps by toggling .visible. + if state != "full": + hide_by_default(node) stamp(root, name, "garden") root["states"] = "full,tattered,dead" root["bed_size"] = f"{W}x{D}" @@ -1622,6 +1677,167 @@ def build_hail_and_shred_atlases(): return [p1, p2] +def build_moon_texture(): + """Moon disc + halo for the night storms (SPRINT6 Β§Lane E-1). + + The halo is the point, not the disc. On storm_02 the moon sits behind a + cloud dome and what you'd actually see is a bright smear; on storm_01 the + disc resolves. One sprite does both β€” Lane C fades opacity with cloud cover + and the halo carries the low end. + """ + import numpy as np + + SIZE = 256 + Y, X = np.mgrid[0:SIZE, 0:SIZE] + c = (SIZE - 1) / 2.0 + nx, ny = (X - c) / c, (Y - c) / c + r = np.sqrt(nx * nx + ny * ny) + + R_DISC = 0.34 + disc = np.clip((R_DISC - r) / 0.02, 0, 1) # soft-edged disc + halo = np.exp(-((r - R_DISC) ** 2) / 0.045) * 0.55 # the bit that survives cloud + halo = np.where(r > R_DISC, halo, 0.0) + + # Maria: a few soft dark blotches, seeded. Craters at this size are a lie β€” + # what you see from a back yard is patches. + shade = np.ones_like(r) + mrng = rng_for("moon_maria") + for _ in range(6): + mx, my = mrng.uniform(-0.18, 0.18), mrng.uniform(-0.18, 0.18) + mr = mrng.uniform(0.06, 0.13) + d = np.sqrt((nx - mx) ** 2 + (ny - my) ** 2) + shade -= np.exp(-(d ** 2) / (mr ** 2)) * mrng.uniform(0.06, 0.13) + shade = np.clip(shade, 0.72, 1.0) + + a = np.clip(disc + halo, 0, 1) + lum = np.clip(np.where(disc > 0, shade, 1.0), 0, 1) + img = np.zeros((SIZE, SIZE, 4), dtype=np.float32) + img[:, :, 0] = lum * 0.96 + img[:, :, 1] = lum * 0.97 + img[:, :, 2] = lum + img[:, :, 3] = a + p, kb = save_png(img, "moon") + print(f" moon.png {SIZE}x{SIZE}, disc+halo, {kb} KB") + return p + + +# ============================================================================ +# END CARDS β€” rendered from the game's own props, not drawn (SPRINT6 Β§Lane E-2) +# ============================================================================ +def _card_scene(broken): + """One vignette, built from the same yard objects the player spent the week + protecting β€” that's the whole idea. A gradient with a font on it says + nothing; the gnome you failed to protect, lying in pieces, says it without a + word.""" + reset_to_empty() + grass = get_material("Mat_Grass", "#4A6B36" if broken else "#5C8A3A", 1.0) + add_box("ground", (60, 60, 0.4), (0, 0, -0.2), grass) + + if broken: + gnome = build_garden_gnome_01_broken("gnome") + gnome.location = (0.82, -0.78, 0) + gnome.rotation_mode = 'XYZ' + gnome.rotation_euler = (0, 0, math.radians(-24)) + fence = build_fence_panel_snapped("fence") + fence.location = (-0.35, 2.6, 0) + bin_ = build_wheelie_bin_01("bin") # blown over, lid flung open + bin_.location = (2.9, 1.5, 0.34) + bin_.rotation_mode = 'XYZ' + bin_.rotation_euler = (math.radians(-96), 0, math.radians(38)) + bed = build_garden_bed("bed") + bed.location = (-3.4, 1.2, 0) + for o in bpy.data.objects: # a loss shows the dead bed + if o.name == "plants_full": + o.hide_render = True + elif o.name == "plants_dead": + o.hide_render = False + else: + gnome = build_garden_gnome_01("gnome") + gnome.location = (0.82, -0.78, 0) + fence = build_fence_panel("fence") + fence.location = (-0.35, 2.6, 0) + post = build_fence_post("post") + post.location = (0.85, 2.6, 0) + bed = build_garden_bed("bed") + bed.location = (-3.4, 1.2, 0) + shed = build_shed_01("shed") + shed.location = (3.6, 2.2, 0) + + +def build_end_cards(): + """NOT part of the default build β€” pass `--cards`. + + EEVEE's SHADOW rendering is not byte-reproducible across processes in + Blender 5.1: two identical runs of the same scene give slightly different + pixels. Measured, with a minimal cube-plus-plane repro, and it happens even + with a hard (0-degree) sun. The contact sheet escapes it only because its + thumbnails have no ground plane to receive a shadow β€” verified still + byte-identical 3/3. + + The long raking dawn shadow IS the win card's mood, so dropping shadows to + win determinism would be trading the art for the guarantee. Instead these + are treated as what they are: art, rendered deliberately and committed, not + build output. Everything the game actually loads β€” every GLB and every + generated texture β€” stays byte-identical on every run, which is the promise + other lanes rely on. + """ + out = [] + for name, broken, warm in (("card_win", False, True), + ("card_gameover", True, False)): + _card_scene(broken) + scn = bpy.context.scene + scn.render.engine = 'BLENDER_EEVEE' + scn.render.resolution_x, scn.render.resolution_y = 1200, 675 + scn.render.image_settings.file_format = 'JPEG' + scn.render.image_settings.quality = 88 + scn.world = bpy.data.worlds.new(f"W_{name}") + scn.world.use_nodes = True + bg = scn.world.node_tree.nodes.get("Background") + if bg: + # Dawn after a night you survived, vs the flat grey of a morning you + # have to explain to a client. + bg.inputs[0].default_value = ((0.92, 0.55, 0.32, 1.0) if warm + else (0.34, 0.37, 0.41, 1.0)) + # Low strength on purpose. The world is a huge ambient fill, and at + # 1.15 it flooded both cards flat β€” the sun stopped casting anything + # and dawn read as fog. Keep the fill down and let the sun do it. + bg.inputs[1].default_value = 0.30 if warm else 0.42 + + bpy.ops.object.light_add(type='SUN', location=(-6, -8, 4)) + sun = _active() + sun.data.energy = 7.0 if warm else 2.6 + sun.data.angle = math.radians(2 if warm else 40) # crisp dawn vs overcast + sun.data.color = (1.0, 0.78, 0.52) if warm else (0.82, 0.86, 0.92) + sun.rotation_mode = 'XYZ' + # Low and raking on the win card: the long shadows are the mood. + sun.rotation_euler = ((math.radians(74), 0, math.radians(-52)) if warm + else (math.radians(28), 0, math.radians(30))) + + bpy.ops.object.camera_add(location=(0, 0, 0)) + cam = _active() + cam.data.lens = 50 + scn.camera = cam + # IDENTICAL camera on both cards, deliberately. They're a diptych: same + # yard, same framing, and the only thing that changed overnight is what + # happened to it. A player who sees both reads the difference instantly, + # which no amount of headline text would do as fast. Low and close, so + # the gnome is the subject; the left third stays empty for Lane A's text. + cam.location = (2.00, -2.86, 0.94) + cam.rotation_mode = 'XYZ' + target = Vector((0.66, -0.42, 0.30)) + cam.rotation_euler = (target - cam.location).to_track_quat('-Z', 'Y').to_euler() + + os.makedirs(TEXTURES_DIR, exist_ok=True) + out_path = os.path.join(TEXTURES_DIR, f"{name}.jpg") + scn.render.filepath = out_path + bpy.ops.render.render(write_still=True) + kb = os.path.getsize(out_path) // 1024 + print(f" {name}.jpg{' ' * max(1, 18 - len(name))}1200x675, from props, {kb} KB") + out.append(out_path) + reset_to_empty() + return out + + 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.""" @@ -1696,6 +1912,7 @@ ASSETS = [ dict(name="house_yardside", fn=build_house_yardside, dims=((9.0, 9.5), (0.8, 1.6), (2.8, 3.3)), nodes=["wall", "door", "window", "roof", "fascia", "gutter", + "window_glow", "window_light_anchor", "fascia_anchor_01", "fascia_anchor_02", "fascia_anchor_03"]), dict(name="shed_01", fn=build_shed_01, dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)), @@ -2058,18 +2275,20 @@ def make_contact_sheet(thumbs): def parse_args(): argv = sys.argv argv = argv[argv.index("--") + 1:] if "--" in argv else [] - only, no_verify, no_debris = None, False, False + only, no_verify, no_debris, cards = None, False, False, False if "--only" in argv: only = set(argv[argv.index("--only") + 1].split(",")) if "--no-verify" in argv: no_verify = True if "--no-debris" in argv: no_debris = True - return only, no_verify, no_debris + if "--cards" in argv: + cards = True + return only, no_verify, no_debris, cards def main(): - only, no_verify, no_debris = parse_args() + only, no_verify, no_debris, cards = parse_args() print("\n" + "=" * 72) print("SHADES yard asset factory β€” Lane E") print(f"Blender {bpy.app.version_string} repo: {REPO_ROOT}") @@ -2081,6 +2300,9 @@ def main(): build_sail_textures() build_pond_textures() build_hail_and_shred_atlases() + build_moon_texture() + if cards: + build_end_cards() debris = [] if no_debris else copy_debris() failures = [] diff --git a/web/world/js/tests/e.test.js b/web/world/js/tests/e.test.js index 6884688..1833470 100644 --- a/web/world/js/tests/e.test.js +++ b/web/world/js/tests/e.test.js @@ -280,6 +280,28 @@ export default async function run(t) { } }); + // glTF has no node-visibility flag and Blender's hide_render does NOT survive + // the export β€” every node arrives visible:true. That shipped as a real bug + // from Sprint 1 to Sprint 6: garden_bed drew full + tattered + dead + // superimposed, and the asserts missed it because they only checked the nodes + // EXISTED. So the flag rides in extras, and this pins the flag, not the node. + t.test('optional nodes carry hidden_by_default β€” hide_render does not export', () => { + const bed = loaded.get('garden_bed')?.scene; + assert(bed, 'garden_bed did not load'); + const on = ['plants_full'], off = ['plants_tattered', 'plants_dead']; + for (const n of on) { + assert(!bed.getObjectByName(n).userData?.hidden_by_default, + `${n} should start visible β€” it's the default state`); + } + for (const n of off) { + assert(bed.getObjectByName(n).userData?.hidden_by_default === true, + `${n} must carry hidden_by_default or the bed renders all three wilt states at once`); + } + const glow = loaded.get('house_yardside')?.scene.getObjectByName('window_glow'); + assert(glow?.userData?.hidden_by_default === true, + 'window_glow must carry hidden_by_default or the house is lit at noon'); + }); + // One GLB carries three wilt states as siblings; Lane A toggles .visible // rather than reloading, so all three have to be present at once. t.test('garden_bed carries all 3 damage states in one GLB', () => { diff --git a/web/world/models/garden_bed_v1.glb b/web/world/models/garden_bed_v1.glb index accfc9d..3db2fa0 100644 Binary files a/web/world/models/garden_bed_v1.glb and b/web/world/models/garden_bed_v1.glb differ diff --git a/web/world/models/house_yardside_v1.glb b/web/world/models/house_yardside_v1.glb index 547a98c..db71f21 100644 Binary files a/web/world/models/house_yardside_v1.glb and b/web/world/models/house_yardside_v1.glb differ diff --git a/web/world/models/textures/card_gameover.jpg b/web/world/models/textures/card_gameover.jpg new file mode 100644 index 0000000..d14da12 Binary files /dev/null and b/web/world/models/textures/card_gameover.jpg differ diff --git a/web/world/models/textures/card_win.jpg b/web/world/models/textures/card_win.jpg new file mode 100644 index 0000000..665e461 Binary files /dev/null and b/web/world/models/textures/card_win.jpg differ diff --git a/web/world/models/textures/moon.png b/web/world/models/textures/moon.png new file mode 100644 index 0000000..bca89c5 Binary files /dev/null and b/web/world/models/textures/moon.png differ