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:
parent
de86aa1662
commit
7cd25a5d2a
@ -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": [
|
||||
|
||||
@ -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 |
@ -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.
Loading…
Reference in New Issue
Block a user