Add aftermath wreckage props and verify glTF extras survive
The aftermath screen needs something to point at (SPRINT3 §Lane E-2). garden_gnome_01_broken snaps at the ankles with the base left where the player last saw him, head rolled clear with the beard still on, hat off — recognisably him rather than a shattered pile, because wreckage nobody can identify is just gravel. fence_panel_broken loses a few palings and hangs one off a nail; most of it stays standing, which is what makes the hole read as damage rather than as a design choice. Both keep their intact twin's origin and ground plane so Lane A swaps mesh-for-mesh with no offsets, asserted both ways. Also asserts something claimed since Sprint 1 and never checked: that glTF extras reach three's userData. They do — the gnome's collateral_value, the canopy's sway_amp, branch_anchor rating_hint and the bin's mass_hint all arrive as numbers. Had that silently dropped, Lane A's gnome would score $0 and every anchor would rate identical, which reads as a gameplay decision rather than a missing field. Contact-sheet framing now keys the capsule off height, not max(dims): the broken gnome is 0.39 m across but stands 0.11, so spread-based framing buried it the same way it once buried the shackle. Selftest 175/0/0, 30 output files byte-identical across two runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
823dbb942b
commit
fccbeb9319
72
THREADS.md
72
THREADS.md
@ -866,3 +866,75 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
Selftest on merged main: **169 pass / 0 fail**. Hand-driven check via SHADES.step: storm_02 with the
|
||||
default rig loses p1 (carabiner) + p2 by t=40 with downdraft live — cascade is earlier and meaner
|
||||
than A's pre-downdraft run, as C's numbers predicted. Screenshot of the merged storm going to DESIGN.md.
|
||||
|
||||
[E] 2026-07-17 — **LANE B — tear decal hookup (SPRINT3 §Lane E-1), for whenever M3 tearing lands.**
|
||||
`models/textures/sail_tears.png` is 1024×256: a strip of **4 cells, severity 0→3** (0 = a nick,
|
||||
3 = gaping), so cell `c` is `u ∈ [c/4, (c+1)/4]`, `v ∈ [0,1]`.
|
||||
The one thing that matters: **a decal has to ride the sim nodes.** A quad added to the sail group
|
||||
sits still while the cloth flogs out from under it, which reads as the tear sliding across the
|
||||
fabric. Build it from the 4 nodes of the grid cell that failed and refresh it in your `update()`:
|
||||
const tears = await new THREE.TextureLoader().loadAsync('/world/models/textures/sail_tears.png');
|
||||
tears.colorSpace = THREE.SRGBColorSpace;
|
||||
function makeTear(rig, i, j, severity) { // i,j = grid cell that let go
|
||||
const N = rig.N, c = Math.min(3, severity);
|
||||
const nodes = [j*N+i, j*N+i+1, (j+1)*N+i+1, (j+1)*N+i];
|
||||
const g = new THREE.BufferGeometry();
|
||||
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(12), 3));
|
||||
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(
|
||||
[c/4,0, (c+1)/4,0, (c+1)/4,1, c/4,1]), 2));
|
||||
g.setIndex([0,1,2, 0,2,3]);
|
||||
const mesh = new THREE.Mesh(g, new THREE.MeshStandardMaterial({
|
||||
map: tears, transparent: true, side: THREE.DoubleSide,
|
||||
depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, // no z-fight vs cloth
|
||||
}));
|
||||
mesh.frustumCulled = false; // same reason your sail isn't
|
||||
mesh.update = () => { // call from group.update()
|
||||
const p = g.attributes.position.array;
|
||||
nodes.forEach((n, k) => { p[k*3] = rig.pos[n*3]; p[k*3+1] = rig.pos[n*3+1];
|
||||
p[k*3+2] = rig.pos[n*3+2]; });
|
||||
g.attributes.position.needsUpdate = true;
|
||||
};
|
||||
return mesh;
|
||||
}
|
||||
One cell ≈ 0.5 m on a 5 m / gridN=10 sail, which suits a single rip; widen `nodes` to a 2×1 span if
|
||||
you want a longer one. Severity is yours to map — corner load at failure is the obvious source. No
|
||||
rush on any of this; it's parked until tearing is actually scoped.
|
||||
|
||||
[E] 2026-07-17 — aftermath wreckage landed (SPRINT3 §Lane E-2). Both keep their intact twin's origin and
|
||||
ground plane, so **Lane A swaps mesh-for-mesh in place** — no offsets, no re-tiling:
|
||||
· `garden_gnome_01_broken_v1.glb` — 0.39 × 0.35 × 0.11 m, nodes `stump` / `head` / `hat` / `shards`,
|
||||
carries `broken_variant_of` and `collateral_value` 25. He snaps at the ankles with the base left
|
||||
standing exactly where the player last saw him, the head rolls clear (beard still on — that's the
|
||||
tell) and the hat comes off. Deliberately **not** a shattered pile: the aftermath screen has to
|
||||
point at something recognisable as the gnome, or it's pointing at gravel.
|
||||
· `fence_panel_broken_v1.glb` — same 2.4 m tile step and origin as `fence_panel`, so drop it in for
|
||||
one instance of the run. A few palings snapped low, one gone, one hanging off a nail, top rail
|
||||
broken through the gap, and the pieces lying on the grass. Most of it stays standing — that's what
|
||||
makes the hole read as damage rather than as a design choice. It IS deeper than the intact panel
|
||||
(0.77 m vs 0.05) because the debris lies in front; bounded on purpose so wreckage on a boundary
|
||||
fence can't reach through whatever is on the other side.
|
||||
|
||||
[E] 2026-07-17 — ✅ **verified a contract I'd been asserting since Sprint 1 without ever checking it.**
|
||||
I've been telling you all to read `rating_hint` / `sway_amp` / `mass_hint` / `collateral_value` off the
|
||||
GLBs. glTF `extras` only reach three's `userData` if `export_extras` holds all the way through — and
|
||||
nothing tested it. It does hold: e.test.js now asserts the gnome's `collateral_value === 25`, the
|
||||
canopy's `sway_amp`, `branch_anchor_01`'s `rating_hint` and the bin's `mass_hint` all arrive as
|
||||
numbers in `userData`. Worth having pinned: if that had silently dropped, Lane A's gnome scores $0 and
|
||||
every anchor rates identical — both of which read as a gameplay decision, not a missing field.
|
||||
Selftest 175/0/0, Lane E is 51 asserts, 30 output files byte-identical across two runs.
|
||||
|
||||
[E] 2026-07-17 — contact-sheet framing now keys the 1.7 m capsule off an asset's **height**, not
|
||||
`max(dims)`. The broken gnome is 0.39 m across but stands 0.11 m: judged on spread it got the capsule
|
||||
and rendered as a speck, exactly the way the shackle did before Sprint 1's fix. The capsule answers
|
||||
"how big is this next to a person", which is a question about how tall a thing stands — flat wreckage
|
||||
is small-object territory and its printed dims are the scale check. Only asset affected is the broken
|
||||
gnome.
|
||||
|
||||
[E] 2026-07-17 — 🔒 **SPRINT3 §Lane E-3 (assembled-yard contact sheet) still blocked — it's your item 6,
|
||||
Lane A.** Checked main at 624a72e: `world.js` has zero `_v1.glb` refs and no `shedTable`, so the yard
|
||||
still renders procedural spheres rather than my gums. Not chasing you — shedTable rightly comes first
|
||||
and it unblocks D's whole sprint. **Ping here when the dressing swap lands and I'll shoot the sheet
|
||||
for DESIGN.md the same session.** Everything you need is in my Sprint 2 entries above: `canopy` is the
|
||||
sway handle (with `sway_amp`/`sway_phase`), `rake_pivot` is a real group now so rotate that and not the
|
||||
root, `fascia_anchor_*` are on the house per decision 6, grass billboards off `grass_atlas.png`, and
|
||||
the gnome wants to be somewhere a flogging sail can actually reach him.
|
||||
|
||||
@ -337,6 +337,41 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01_broken",
|
||||
"dims": [
|
||||
0.3947,
|
||||
0.3519,
|
||||
0.106
|
||||
],
|
||||
"tris": 344,
|
||||
"nodes": [
|
||||
"garden_gnome_01_broken",
|
||||
"hat",
|
||||
"head",
|
||||
"shards",
|
||||
"stump"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel_broken",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.7749,
|
||||
1.8197
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"debris_palings",
|
||||
"fence_panel_broken",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
|
||||
@ -1123,6 +1123,131 @@ def build_garden_gnome_01(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_garden_gnome_01_broken(name):
|
||||
"""The gnome after the sail found him. Same origin and ground plane as the
|
||||
intact one, so Lane A swaps meshes in place without moving anything: hide
|
||||
`garden_gnome_01`, show this, bill $25 on the aftermath screen.
|
||||
|
||||
Deliberately NOT a shattered pile — the wreckage has to be *recognisable* as
|
||||
the gnome from across the yard, or the aftermath screen is pointing at
|
||||
gravel. So: he snaps at the ankles, the head rolls, the hat comes off, and
|
||||
the base stays exactly where the player last saw it standing.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
|
||||
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
|
||||
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
|
||||
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
|
||||
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
# The stump: base plus the bottom of the coat, snapped off at a ragged line.
|
||||
join_group([
|
||||
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
|
||||
add_cone(f"{name}_stump", 0.072, 0.060, 0.055, (0, 0, 0.048), coat,
|
||||
verts=10),
|
||||
add_cyl(f"{name}_break_face", 0.060, 0.006, (0, 0, 0.078), base_m,
|
||||
verts=10), # raw concrete at the fracture
|
||||
], "stump", root)
|
||||
|
||||
# The head, rolled clear and face-down. Beard still on, which is the tell.
|
||||
hx, hy = 0.16, -0.09
|
||||
join_group([
|
||||
add_ico(f"{name}_head", 0.042, (hx, hy, 0.040), skin, subdiv=2),
|
||||
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (hx + 0.02, hy - 0.03, 0.030),
|
||||
beard, verts=8, rot=(math.radians(96), 0, math.radians(20))),
|
||||
add_ico(f"{name}_nose", 0.011, (hx + 0.01, hy - 0.035, 0.046), skin, subdiv=1),
|
||||
], "head", root)
|
||||
|
||||
# The hat, off and on its side — the single most legible piece of him.
|
||||
join_group([add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (-0.15, 0.07, 0.026),
|
||||
hat, verts=10, rot=(math.radians(90), 0,
|
||||
math.radians(-35)))], "hat", root)
|
||||
|
||||
shards = []
|
||||
for i in range(6):
|
||||
a = math.tau * rng.random()
|
||||
d = rng.uniform(0.10, 0.26)
|
||||
s = rng.uniform(0.010, 0.022)
|
||||
shards.append(add_box(f"{name}_shard_{i}", (s, s * 1.4, s * 0.7),
|
||||
(math.cos(a) * d, math.sin(a) * d, s * 0.35),
|
||||
coat if i % 2 else base_m,
|
||||
rot=(0, 0, rng.uniform(0, math.tau))))
|
||||
join_group(shards, "shards", root)
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["broken_variant_of"] = "garden_gnome_01"
|
||||
root["collateral_value"] = 25
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_panel_broken(name):
|
||||
"""A panel the storm went through. Same 2.4 m tile footprint and origin as
|
||||
fence_panel, so Lane A drops it into the run in place of one instance rather
|
||||
than re-tiling the fence.
|
||||
|
||||
A panel does not disintegrate — it loses a few palings and hangs off one
|
||||
rail. Keeping most of it standing is what makes the gap read as damage
|
||||
instead of as a design choice.
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
||||
rail_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.85)
|
||||
width, h = 2.4, 1.8
|
||||
pw = 0.09
|
||||
n = 24
|
||||
step = width / n
|
||||
|
||||
standing, ground = [], []
|
||||
for i in range(n):
|
||||
x = -width / 2 + step * (i + 0.5)
|
||||
roll = rng.random()
|
||||
if 9 <= i <= 13 and roll < 0.75:
|
||||
# The hole: snapped low, or gone entirely onto the grass.
|
||||
if roll < 0.42:
|
||||
continue
|
||||
ph = rng.uniform(0.35, 0.72) # jagged stump
|
||||
standing.append(add_box(f"{name}_snapped_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
elif roll < 0.10:
|
||||
# One paling hanging by a single nail, swung off vertical.
|
||||
standing.append(add_box(f"{name}_hanging_{i:02d}", (pw, 0.019, h * 0.8),
|
||||
(x + 0.06, 0.01, h * 0.42), timber,
|
||||
rot=(0, rng.uniform(0.25, 0.5), 0)))
|
||||
else:
|
||||
ph = h + rng.uniform(-0.02, 0.02)
|
||||
standing.append(add_box(f"{name}_paling_{i:02d}", (pw, 0.019, ph),
|
||||
(x, 0, ph / 2), timber))
|
||||
join_group(standing, "palings", root)
|
||||
|
||||
# Top rail snapped through the gap; bottom rail survives.
|
||||
rails = [add_box(f"{name}_rail_bot", (width, 0.035, 0.07), (0, 0.027, 0.35),
|
||||
rail_m),
|
||||
add_box(f"{name}_rail_top_l", (width * 0.42, 0.035, 0.07),
|
||||
(-width * 0.29, 0.027, 1.45), rail_m),
|
||||
add_box(f"{name}_rail_top_r", (width * 0.30, 0.035, 0.07),
|
||||
(width * 0.35, 0.027, 1.45), rail_m,
|
||||
rot=(rng.uniform(0.05, 0.14), 0, 0))]
|
||||
join_group(rails, "rails", root)
|
||||
|
||||
# The pieces that left, lying on the grass in front of the hole. Kept to
|
||||
# snapped lengths and tucked close: the fence sits on the yard boundary, so
|
||||
# a full-length paling flung a metre out pokes through whatever is on the
|
||||
# other side of it. Wreckage should read as wreckage, not reach.
|
||||
for i in range(3):
|
||||
ground.append(add_box(f"{name}_down_{i}", (pw, 0.019, rng.uniform(0.5, 0.95)),
|
||||
(rng.uniform(-0.2, 0.6), rng.uniform(-0.40, -0.15),
|
||||
0.012),
|
||||
timber, rot=(math.pi / 2, 0, rng.uniform(-0.5, 0.5))))
|
||||
join_group(ground, "debris_palings", root)
|
||||
|
||||
stamp(root, name, "fence")
|
||||
root["broken_variant_of"] = "fence_panel"
|
||||
root["tile_step"] = width
|
||||
return root
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
||||
# ============================================================================
|
||||
@ -1354,6 +1479,17 @@ ASSETS = [
|
||||
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
|
||||
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
|
||||
nodes=["gnome"]),
|
||||
# Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin
|
||||
# and footprint so Lane A swaps in place.
|
||||
dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken,
|
||||
dims=((0.30, 0.70), (0.25, 0.65), (0.08, 0.20)),
|
||||
nodes=["stump", "head", "hat", "shards"]),
|
||||
# Deeper than fence_panel on purpose: the snapped palings lie on the grass in
|
||||
# front of it. Bounded so wreckage on a boundary fence can't reach through
|
||||
# whatever is behind it.
|
||||
dict(name="fence_panel_broken", fn=build_fence_panel_broken,
|
||||
dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)),
|
||||
nodes=["palings", "rails", "debris_palings"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1572,10 +1708,15 @@ def verify_all(only=None):
|
||||
problems.append(f"{tris} tris > {TRI_BUDGET} budget")
|
||||
|
||||
# The capsule beside it — the actual acceptance criterion. Skipped for
|
||||
# hardware: a 1.7 m human next to a 60 mm shackle tells you nothing and
|
||||
# zooms the shackle down to one pixel. Below 0.30 m the printed dims are
|
||||
# the scale check, and the tile's job is proving the thing READS.
|
||||
show_capsule = name != "ref_capsule" and max(dims) >= 0.30
|
||||
# small things: a 1.7 m human next to a 60 mm shackle tells you nothing
|
||||
# and zooms the shackle down to one pixel. Below the cut the printed dims
|
||||
# are the scale check, and the tile's job is proving the thing READS.
|
||||
#
|
||||
# Keyed on HEIGHT, not max(dims): the capsule answers "how big is this
|
||||
# next to a person", which is a question about how tall it stands. Flat
|
||||
# wreckage spread 0.39 m across the grass but standing 0.11 m is small-
|
||||
# object territory — measuring its scatter against a human just buries it.
|
||||
show_capsule = name != "ref_capsule" and dims[2] >= 0.30
|
||||
if show_capsule:
|
||||
build_ref_capsule("ref_capsule")
|
||||
for o in bpy.data.objects:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.7 MiB |
@ -73,6 +73,10 @@ const ASSETS = [
|
||||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
{ name: 'garden_gnome_01_broken', h: [0.08, 0.20],
|
||||
nodes: ['stump', 'head', 'hat', 'shards'] },
|
||||
{ name: 'fence_panel_broken', h: [1.70, 1.90],
|
||||
nodes: ['palings', 'rails', 'debris_palings'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -234,6 +238,45 @@ export default async function run(t) {
|
||||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||||
});
|
||||
|
||||
// Custom props are a contract, not decoration — and I have been telling other
|
||||
// lanes to read these since Sprint 1 without ever checking they survive the
|
||||
// export. glTF `extras` arrive as three's userData, but only if export_extras
|
||||
// held all the way through; if it silently dropped, Lane A's gnome scores $0
|
||||
// and Lane B's anchors all rate the same, both of which would look like a
|
||||
// gameplay decision rather than a missing field.
|
||||
t.test('glTF extras survive as userData — the props other lanes read', () => {
|
||||
const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
|
||||
assert(gnome, 'gnome root node missing');
|
||||
assert(gnome.userData?.collateral_value === 25,
|
||||
`collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);
|
||||
|
||||
const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
|
||||
assert(typeof canopy?.userData?.sway_amp === 'number',
|
||||
'canopy.sway_amp lost — world.js per-tree sway tuning reads it');
|
||||
|
||||
const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
|
||||
assert(typeof branch?.userData?.rating_hint === 'number',
|
||||
'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');
|
||||
|
||||
const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
|
||||
assert(typeof bin?.userData?.mass_hint === 'number',
|
||||
'wheelie_bin mass_hint lost — Lane C throws it with this');
|
||||
});
|
||||
|
||||
// The wreckage has to drop into the intact asset's place, so both variants
|
||||
// stand on the same ground plane. If the broken one floats or sinks, Lane A's
|
||||
// swap needs a fudge offset per prop and will grow one.
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_broken']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
assert(Math.abs(box.min.y) < 0.03,
|
||||
`${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
|
||||
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
BIN
web/world/models/fence_panel_broken_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_broken_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user