feat(puppet): procedural-locomotion layer (substrate port)

- build_rig parses optional rig.locomotion block (mode/step_trigger/
  step_placement/step_duration/step_height/idle_return_delay/legs[])
- spawn initializes per-puppet foot_state[] table (planted/stepping
  state, anchor/cur world-positions, sprite_reach from leg-texture)
- apply_locomotion (substrate puppet.c:1000-1156 port): step-trigger
  on hip-displacement > step_trigger; smoothstep step-progression with
  arc-height; leg-aim via atan2(-dx, dy); leg-stretch via scl_y clamp
  [0.2, 2.5]; foot world-pos override
- Skips legs where bone_animated is set on leg or foot (keyframe priority)
- Integrated as step 4 in M.update post-cascade
- load_textures caches texture_width/height per bone for sprite_reach

No new tests yet (added in Phase 3e). Existing 24 assertions remain
GREEN (locomotion is no-op for rigs without locomotion-block).
This commit is contained in:
Axel Meyer
2026-05-18 18:29:00 +02:00
parent 7b6d3b3b70
commit 9519897ba2

190
init.lua
View File

@@ -30,6 +30,14 @@ local function clamp_angle_rad(a)
return a
end
local function smoothstep(t)
if t < 0 then return 0 end
if t > 1 then return 1 end
return t * t * (3 - 2 * t)
end
local function lerp(a, b, t) return a + (b - a) * t end
-- ====================================================================
-- Rig validation + build
-- ====================================================================
@@ -134,6 +142,42 @@ function M.build_rig(rig_table)
end
table.sort(bones_z_sorted, function(a, b) return a.z_order < b.z_order end)
-- Parse optional locomotion block (substrate-port: footplant config)
local loco = nil
if rig_table.locomotion ~= nil then
local jloco = rig_table.locomotion
if type(jloco) ~= "table" then
error("puppet.build_rig: rig.locomotion must be a table")
end
local legs_def = {}
for i, leg_entry in ipairs(jloco.legs or {}) do
if bones_by_id[leg_entry.leg] == nil then
error("puppet.build_rig: rig.locomotion.legs["..i.."].leg='"
.. tostring(leg_entry.leg) .. "' not in rig")
end
if bones_by_id[leg_entry.foot] == nil then
error("puppet.build_rig: rig.locomotion.legs["..i.."].foot='"
.. tostring(leg_entry.foot) .. "' not in rig")
end
legs_def[i] = {
leg = leg_entry.leg,
foot = leg_entry.foot,
group = leg_entry.group or (i - 1),
}
end
loco = {
mode = jloco.mode or "procedural",
step_trigger = jloco.step_trigger or { 6, 24 },
step_placement = jloco.step_placement or { 16, 28 },
step_duration = jloco.step_duration or 0.18,
step_height = jloco.step_height or 0,
idle_return_delay = jloco.idle_return_delay or 0.5,
idle_return_stagger = jloco.idle_return_stagger or 0.3,
idle_step_duration = jloco.idle_step_duration or 0.25,
legs = legs_def,
}
end
return {
id = rig_table.id or "anonymous",
asset_pack = rig_table.asset_pack, -- optional alias-key into module's asset_aliases
@@ -142,6 +186,7 @@ function M.build_rig(rig_table)
tracks_by_id = tracks_by_id,
bone_to_track = bone_to_track,
bones_z_sorted = bones_z_sorted,
locomotion = loco,
}
end
@@ -323,6 +368,34 @@ function M.spawn(rig, pos)
}
end
-- Initialize foot_state per locomotion.legs[]
local foot_state = {}
if rig.locomotion then
for i, leg in ipairs(rig.locomotion.legs) do
local leg_bone = rig.bones_by_id[leg.leg]
foot_state[i] = {
state = "planted",
anchor_x = 0, anchor_y = 0,
step_from_x = 0, step_from_y = 0,
step_to_x = 0, step_to_y = 0,
step_timer = 0,
step_total = 0,
cur_x = 0, cur_y = 0,
initialized = false,
-- sprite_reach: leg-sprite extent below pivot. Computed from
-- texture.height - anchor.y. If texture not loaded yet (rig
-- built without aliases), fallback uses spec default 30.
sprite_reach = 30.0,
}
-- If texture is loaded, compute sprite_reach from actual sprite dim
if leg_bone.texture_handle and leg_bone.texture_height then
local anchor_y = (leg_bone.anchor and leg_bone.anchor[2]) or 0
foot_state[i].sprite_reach = leg_bone.texture_height - anchor_y
if foot_state[i].sprite_reach < 1 then foot_state[i].sprite_reach = 1 end
end
end
end
all_puppets[handle] = {
handle = handle,
rig = rig,
@@ -330,6 +403,9 @@ function M.spawn(rig, pos)
facing = 0,
last_move_x = pos.x, last_move_y = pos.y,
bone_state = bone_state,
foot_state = foot_state,
bone_animated = {},
last_move_time = 0,
look_target = nil,
animations = {}, -- {[anim_id] = built_anim}
playing = {}, -- {[anim_id] = { t = 0, loop, speed }}
@@ -337,9 +413,6 @@ function M.spawn(rig, pos)
write_target_bone = nil, -- set during procedural callback to track track-conflicts
write_target_track = nil,
test_overrides = {}, -- {[bone_id] = angle_rad} set by write_bone_test_only; applied after reset+keyframe
foot_state = {}, -- populated by locomotion at spawn (Task 3.7)
bone_animated = {}, -- {[bone_id] = true} per-frame keyframe flag (Task 3.12)
last_move_time = 0, -- engine.time.now() at last position-change (Task 3.10)
}
return handle
end
@@ -531,6 +604,110 @@ end
-- ====================================================================
local update_bone_recursive
-- ====================================================================
-- Procedural locomotion (substrate puppet.c:1000-1156 port).
-- Runs after compute_world for all bones. Mutates leg.world.{rot, scl_x, scl_y}
-- and foot.world.{x, y} directly. Skipped per-leg if bone_animated is set
-- on either leg or foot (keyframe takes precedence).
-- ====================================================================
local function apply_locomotion(p, dt)
if p.rig.locomotion == nil then return end
local loco = p.rig.locomotion
if loco.mode ~= "procedural" then return end
local now = engine.time.now()
for i, leg_def in ipairs(loco.legs) do
local leg_bone = p.rig.bones_by_id[leg_def.leg]
local foot_bone = p.rig.bones_by_id[leg_def.foot]
if p.bone_animated[leg_def.leg] or p.bone_animated[leg_def.foot] then
-- keyframe has priority; skip
goto continue
end
local fs = p.foot_state[i]
local leg_ws = p.bone_state[leg_def.leg].world
local hip_wx, hip_wy = leg_ws.x, leg_ws.y
-- Initialize anchor on first frame
if not fs.initialized then
fs.anchor_x = hip_wx
fs.anchor_y = hip_wy
fs.cur_x = hip_wx
fs.cur_y = hip_wy
fs.initialized = true
end
if fs.state == "planted" then
local dx = hip_wx - fs.anchor_x
local dy = hip_wy - fs.anchor_y
local lateral_trigger = loco.step_trigger[1]
local longi_trigger = loco.step_trigger[2]
local need_step = (math.abs(dx) > lateral_trigger)
or (math.abs(dy) > longi_trigger)
if need_step then
fs.state = "stepping"
fs.step_from_x = fs.cur_x
fs.step_from_y = fs.cur_y
-- Step target: hip + step_placement in facing direction.
-- Simplified: place at hip_wx + sign(dx)*step_placement[1] etc.
-- sign-of-zero must be 0 (no spurious axis-displacement)
local sign_x = (dx > 0 and 1) or (dx < 0 and -1) or 0
local sign_y = (dy > 0 and 1) or (dy < 0 and -1) or 0
fs.step_to_x = hip_wx + sign_x * loco.step_placement[1]
fs.step_to_y = hip_wy + sign_y * loco.step_placement[2]
fs.step_timer = 0
fs.step_total = loco.step_duration
end
end
if fs.state == "stepping" then
fs.step_timer = fs.step_timer + dt
local t = fs.step_timer / fs.step_total
if t >= 1 then
fs.state = "planted"
fs.anchor_x = fs.step_to_x
fs.anchor_y = fs.step_to_y
fs.cur_x = fs.step_to_x
fs.cur_y = fs.step_to_y
else
local a = smoothstep(t)
fs.cur_x = lerp(fs.step_from_x, fs.step_to_x, a)
fs.cur_y = lerp(fs.step_from_y, fs.step_to_y, a)
fs.cur_y = fs.cur_y - math.sin(t * math.pi) * loco.step_height
end
end
-- Leg-aim + leg-stretch (substrate puppet.c:1111-1156)
local to_foot_x = fs.cur_x - hip_wx
local to_foot_y = fs.cur_y - hip_wy
local dist = math.sqrt(to_foot_x * to_foot_x + to_foot_y * to_foot_y)
-- foot_angle: substrate uses atan2(-to_foot_x, to_foot_y) → rotates 90°
-- because leg sprite points in +Y direction at rot=0.
local foot_angle = math.atan(-to_foot_x, to_foot_y)
local parent_world_rot = 0
if leg_bone.parent then
parent_world_rot = p.bone_state[leg_bone.parent.id].world.rot
end
leg_ws.rot = foot_angle - parent_world_rot
-- sprite_reach: leg-bone texture height minus anchor.y (set by Task 3.10)
local sprite_reach = p.foot_state[i].sprite_reach or 30.0 -- temp default
if sprite_reach < 1 then sprite_reach = 1 end
local stretch = dist / sprite_reach
if stretch > 2.5 then stretch = 2.5 end
if stretch < 0.2 then stretch = 0.2 end
leg_ws.scl_y = stretch
leg_ws.scl_x = leg_bone.rest.scl[1] -- preserve mirror
-- Foot world-pos override
local foot_ws = p.bone_state[leg_def.foot].world
foot_ws.x = fs.cur_x
foot_ws.y = fs.cur_y
::continue::
end
end
-- ====================================================================
-- Update pipeline (per-frame, depth-first traversal).
--
@@ -577,6 +754,9 @@ function M.update(dt)
update_bone_recursive(p, b, dt)
end
end
-- 4. Procedural locomotion (post-cascade, mutates world.* directly)
apply_locomotion(p, dt)
end
end
@@ -729,6 +909,10 @@ function M.load_textures(rig, asset_aliases)
end
local tex_path = lib_id .. "/assets/" .. entry.file
b.texture_handle = engine.asset.load_texture(tex_path)
-- Cache texture dimensions for locomotion sprite_reach
local tw, th = engine.asset.texture_size(b.texture_handle)
b.texture_width = tw
b.texture_height = th
-- Resolve anchor: bone.anchor override beats atlas-default.
if b.anchor == nil then
b.anchor = entry.anchor or { 0, 0 }