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:
190
init.lua
190
init.lua
@@ -30,6 +30,14 @@ local function clamp_angle_rad(a)
|
|||||||
return a
|
return a
|
||||||
end
|
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
|
-- Rig validation + build
|
||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
@@ -134,6 +142,42 @@ function M.build_rig(rig_table)
|
|||||||
end
|
end
|
||||||
table.sort(bones_z_sorted, function(a, b) return a.z_order < b.z_order 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 {
|
return {
|
||||||
id = rig_table.id or "anonymous",
|
id = rig_table.id or "anonymous",
|
||||||
asset_pack = rig_table.asset_pack, -- optional alias-key into module's asset_aliases
|
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,
|
tracks_by_id = tracks_by_id,
|
||||||
bone_to_track = bone_to_track,
|
bone_to_track = bone_to_track,
|
||||||
bones_z_sorted = bones_z_sorted,
|
bones_z_sorted = bones_z_sorted,
|
||||||
|
locomotion = loco,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -323,6 +368,34 @@ function M.spawn(rig, pos)
|
|||||||
}
|
}
|
||||||
end
|
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] = {
|
all_puppets[handle] = {
|
||||||
handle = handle,
|
handle = handle,
|
||||||
rig = rig,
|
rig = rig,
|
||||||
@@ -330,6 +403,9 @@ function M.spawn(rig, pos)
|
|||||||
facing = 0,
|
facing = 0,
|
||||||
last_move_x = pos.x, last_move_y = pos.y,
|
last_move_x = pos.x, last_move_y = pos.y,
|
||||||
bone_state = bone_state,
|
bone_state = bone_state,
|
||||||
|
foot_state = foot_state,
|
||||||
|
bone_animated = {},
|
||||||
|
last_move_time = 0,
|
||||||
look_target = nil,
|
look_target = nil,
|
||||||
animations = {}, -- {[anim_id] = built_anim}
|
animations = {}, -- {[anim_id] = built_anim}
|
||||||
playing = {}, -- {[anim_id] = { t = 0, loop, speed }}
|
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_bone = nil, -- set during procedural callback to track track-conflicts
|
||||||
write_target_track = nil,
|
write_target_track = nil,
|
||||||
test_overrides = {}, -- {[bone_id] = angle_rad} set by write_bone_test_only; applied after reset+keyframe
|
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
|
return handle
|
||||||
end
|
end
|
||||||
@@ -531,6 +604,110 @@ end
|
|||||||
-- ====================================================================
|
-- ====================================================================
|
||||||
local update_bone_recursive
|
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).
|
-- Update pipeline (per-frame, depth-first traversal).
|
||||||
--
|
--
|
||||||
@@ -577,6 +754,9 @@ function M.update(dt)
|
|||||||
update_bone_recursive(p, b, dt)
|
update_bone_recursive(p, b, dt)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 4. Procedural locomotion (post-cascade, mutates world.* directly)
|
||||||
|
apply_locomotion(p, dt)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -729,6 +909,10 @@ function M.load_textures(rig, asset_aliases)
|
|||||||
end
|
end
|
||||||
local tex_path = lib_id .. "/assets/" .. entry.file
|
local tex_path = lib_id .. "/assets/" .. entry.file
|
||||||
b.texture_handle = engine.asset.load_texture(tex_path)
|
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.
|
-- Resolve anchor: bone.anchor override beats atlas-default.
|
||||||
if b.anchor == nil then
|
if b.anchor == nil then
|
||||||
b.anchor = entry.anchor or { 0, 0 }
|
b.anchor = entry.anchor or { 0, 0 }
|
||||||
|
|||||||
Reference in New Issue
Block a user