Give the gum trees a canopy sway handle

world.js sways a tree by rotating a `canopy` group whose origin sits at the
trunk top, so the blobs swing about the trunk. The trees shipped canopy_01..03
as siblings of `trunk` with each origin at its own blob centre — rotating one
spins a sphere in place, which is visually nothing. Lane A could not have swayed
these trees, and the canopy lean is the gust telegraph the player reads a beat
before it hits the sail, so the tell would have gone missing rather than looked
wrong.

Adds the `canopy` empty at the trunk top with the blobs parented under it, so
A's existing code works unchanged, plus sway_amp / sway_phase / sway_pivot_y
for the per-tree tuning SPRINT2 §Lane E asks for (gum_01 is big and leans less
at 0.85; gum_02 is whippy at 1.20 and shows a gust first).

sway_phase draws from its own RNG stream: taking it from the shared one would
consume a value and shift every blob draw after it, resilhouetting a tree the
other lanes have already tuned against. Exported dims are identical to Sprint 1.

e.test.js asserts the pivot by rotating the handle and measuring that a blob
actually travels — red before this change, green after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-17 00:42:30 +10:00
parent de86aa1662
commit 7cd25a5d2a
6 changed files with 73 additions and 253 deletions

View File

@ -1,22 +1,6 @@
{
"blender": "5.1.2",
"assets": [
{
"name": "ref_capsule",
"dims": [
0.4,
0.4,
1.7
],
"tris": 220,
"nodes": [
"head_height",
"ref_capsule",
"ref_capsule_mesh"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_01",
"dims": [
@ -29,6 +13,7 @@
"branch_anchor_01",
"branch_anchor_02",
"branch_anchor_03",
"canopy",
"canopy_01",
"canopy_02",
"canopy_03",
@ -49,6 +34,7 @@
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"canopy",
"canopy_01",
"canopy_02",
"tree_gum_02",
@ -56,235 +42,6 @@
],
"status": "PASS",
"problems": []
},
{
"name": "fence_post",
"dims": [
0.13,
0.13,
2.03
],
"tris": 24,
"nodes": [
"fence_post",
"post"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_panel",
"dims": [
2.4,
0.054,
1.8194
],
"tris": 324,
"nodes": [
"fence_panel",
"palings",
"rails"
],
"status": "PASS",
"problems": []
},
{
"name": "gate",
"dims": [
1.045,
0.0615,
1.75
],
"tris": 220,
"nodes": [
"gate",
"gate_frame",
"gate_palings",
"hinge_axis",
"hinges"
],
"status": "PASS",
"problems": []
},
{
"name": "house_yardside",
"dims": [
9.2,
1.0547,
2.9
],
"tris": 200,
"nodes": [
"door",
"fascia",
"fascia_anchor_01",
"fascia_anchor_02",
"fascia_anchor_03",
"gutter",
"house_yardside",
"roof",
"wall",
"window"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_01",
"dims": [
2.58,
1.9708,
2.2224
],
"tris": 96,
"nodes": [
"door_anchor",
"doors",
"roof",
"shed_01",
"shell"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_table",
"dims": [
1.6,
0.6,
0.9
],
"tris": 72,
"nodes": [
"pickup_anchor",
"shed_table",
"table_frame",
"table_top"
],
"status": "PASS",
"problems": []
},
{
"name": "garden_bed",
"dims": [
3.0,
1.2,
0.8609
],
"tris": 2580,
"nodes": [
"bed",
"garden_bed",
"plants_dead",
"plants_full",
"plants_tattered",
"soil"
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post",
"dims": [
0.507,
0.52,
4.0327
],
"tris": 528,
"nodes": [
"footing",
"pad_eye",
"post",
"rake_pivot",
"sail_post",
"top_anchor"
],
"status": "PASS",
"problems": []
},
{
"name": "ladder_01",
"dims": [
0.455,
0.075,
3.0
],
"tris": 276,
"nodes": [
"ladder",
"ladder_01",
"ladder_base",
"ladder_top"
],
"status": "PASS",
"problems": []
},
{
"name": "shackle",
"dims": [
0.0569,
0.019,
0.0744
],
"tris": 560,
"nodes": [
"bow",
"pin",
"shackle"
],
"status": "PASS",
"problems": []
},
{
"name": "carabiner",
"dims": [
0.049,
0.009,
0.1027
],
"tris": 476,
"nodes": [
"body",
"carabiner",
"gate"
],
"status": "PASS",
"problems": []
},
{
"name": "turnbuckle",
"dims": [
0.0292,
0.0341,
0.1955
],
"tris": 728,
"nodes": [
"body",
"eye_a",
"eye_b",
"turnbuckle"
],
"status": "PASS",
"problems": []
},
{
"name": "tramp_01",
"dims": [
2.9555,
2.9555,
0.78
],
"tris": 976,
"nodes": [
"legs",
"mat",
"pad",
"rim",
"tramp_01"
],
"status": "PASS",
"problems": []
}
],
"debris": [

View File

@ -252,6 +252,20 @@ def add_tube_between(name, p0, p1, radius, material, parent=None, verts=8):
return obj
def parent_keep_transform(child, parent):
"""Blender's Ctrl+P "Keep Transform": reparent without moving the child.
Everything else in this script keeps its root empty at the origin so that
`obj.parent = root` needs no parent-inverse juggling. The canopy handle is
the one exception its pivot has to sit at the trunk top so the blobs need
the inverse or they leap upward by the trunk height on parenting.
"""
bpy.context.view_layer.update()
child.parent = parent
child.matrix_parent_inverse = parent.matrix_world.inverted()
return child
def add_empty(name, location=(0, 0, 0), parent=None, size=0.15):
bpy.ops.object.empty_add(type='PLAIN_AXES', location=location)
obj = _active()
@ -403,7 +417,8 @@ def build_ref_capsule(name):
return root
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name,
sway_amp=1.0):
"""Eucalypt: pale chalky trunk, sparse olive canopy, low branches that a
landscaper would actually strap a sail to."""
rng = rng_for(seed_name)
@ -442,8 +457,23 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
join_group(trunk_parts, "trunk", root)
# Canopy: separate nodes — Lane A sways these, and only these.
# Canopy. `canopy` is the SWAY HANDLE: an empty at the trunk top that world.js
# rotates, with the blobs hanging off it as children so they swing about the
# trunk the way a real canopy does. Parenting them to the root instead — which
# is what shipped in Sprint 1 — leaves each blob's pivot at its own centre, so
# a lean just spins a sphere in place and the tree never visibly moves. The
# canopy lean IS the gust telegraph the player reads (world.js), so a canopy
# that can't sway silently costs the game its tell. Asserted in e.test.js.
top = (lean * trunk_h, 0, trunk_h)
canopy_grp = add_empty("canopy", top, root, size=0.6)
canopy_grp["sway_amp"] = sway_amp # per-tree lean multiplier
# Own RNG stream on purpose: drawing sway_phase from `rng` would consume a
# value and shift every blob draw after it, silently reshaping a tree the
# other lanes have already tuned against. Adding a handle must not move
# geometry.
canopy_grp["sway_phase"] = round(rng_for(f"{seed_name}:sway").uniform(0, math.tau), 3)
canopy_grp["sway_pivot_y"] = round(trunk_h, 3)
for i in range(canopy_blobs):
ang = math.tau * i / canopy_blobs + rng.uniform(-0.3, 0.3)
off = spread * rng.uniform(0.10, 0.24)
@ -453,9 +483,10 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
r = spread * rng.uniform(0.24, 0.32)
blob = add_ico(f"canopy_{i + 1:02d}", r, (cx, cy, cz),
leaf_a if i % 2 == 0 else leaf_b,
parent=root, subdiv=2,
scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
subdiv=2, scale=(1.0, 1.0, rng.uniform(0.55, 0.75)),
jitter=r * 0.10, rng=rng)
parent_keep_transform(blob, canopy_grp)
# Secondary motion if Lane A wants it: outer/higher blobs travel further.
blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3)
# branch_anchor_* — what Lane B queries. Empties, at the limb tips.
@ -471,13 +502,17 @@ def _gum_tree(name, height, canopy_blobs, spread, anchor_heights, seed_name):
def build_tree_gum_01(name):
# Big, heavy-limbed: leans less for the same wind.
return _gum_tree(name, height=8.4, canopy_blobs=3, spread=6.0,
anchor_heights=[2.6, 3.4, 4.3], seed_name=name)
anchor_heights=[2.6, 3.4, 4.3], seed_name=name,
sway_amp=0.85)
def build_tree_gum_02(name):
# Smaller and whippier — it should show a gust front first.
return _gum_tree(name, height=5.6, canopy_blobs=2, spread=4.4,
anchor_heights=[2.3, 3.1], seed_name=name)
anchor_heights=[2.3, 3.1], seed_name=name,
sway_amp=1.20)
def build_fence_post(name):
@ -1020,11 +1055,11 @@ ASSETS = [
nodes=["ref_capsule_mesh", "head_height"]),
dict(name="tree_gum_01", fn=build_tree_gum_01,
dims=((3.0, 7.5), (3.0, 7.5), (7.5, 9.5)),
nodes=["trunk", "canopy_01", "canopy_02", "canopy_03",
nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03",
"branch_anchor_01", "branch_anchor_02", "branch_anchor_03"]),
dict(name="tree_gum_02", fn=build_tree_gum_02,
dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)),
nodes=["trunk", "canopy_01", "canopy_02",
nodes=["trunk", "canopy", "canopy_01", "canopy_02",
"branch_anchor_01", "branch_anchor_02"]),
dict(name="fence_post", fn=build_fence_post,
dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 313 KiB

View File

@ -148,6 +148,34 @@ export default async function run(t) {
assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
});
// A canopy that can't sway is just decoration, and the gust front the player
// reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A
// rotates a `canopy` group; the blobs must swing about the TRUNK, not spin
// about their own centres — a sphere spinning in place is invisible, which is
// exactly the failure this catches.
t.test('canopy sways about the trunk when Lane A rotates it', () => {
const g = loaded.get('tree_gum_01');
assert(g, 'tree_gum_01 did not load');
const canopy = g.scene.getObjectByName('canopy');
assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree');
const blob = g.scene.getObjectByName('canopy_01');
assert(blob, 'canopy_01 missing');
g.scene.updateWorldMatrix(true, true);
const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
const restZ = canopy.rotation.z;
canopy.rotation.z = restZ + 0.20; // ≈ world.js's max lean of 0.22 rad
canopy.updateWorldMatrix(true, true);
const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
canopy.rotation.z = restZ;
canopy.updateWorldMatrix(true, true);
const moved = before.distanceTo(after);
assert(moved > 0.15,
`canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` +
'the blob centre, not the trunk top, so the tree cannot visibly sway');
});
// 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', () => {

Binary file not shown.

Binary file not shown.