From 9519897ba20bba64604008cd84ccc3c2dc993408 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Mon, 18 May 2026 18:29:00 +0200 Subject: [PATCH] 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). --- init.lua | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 3 deletions(-) diff --git a/init.lua b/init.lua index 603c2b7..a2ba6dd 100644 --- a/init.lua +++ b/init.lua @@ -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 }