Previously the look-at-rate-limit read p.bone_state[bid].rot as 'current' and advanced from it. But bone_state.rot is reset-to-rest + keyframe- sampled at frame start, so 'current' was always near rest (~0) and the rate-limit only advanced one frame's worth (~7deg) per frame. Visual result: head/body rotation appeared capped at ~10deg. Fix: persistent p.look_at_state[bid] table carries angle across frames. Rate-limit operates on look_at_state.angle (not bone_state.rot). After rate-limit + clamp, value is written to bone_state.rot. Init: look_at_state[bid].angle = b.rest.rot on first encounter, so the first frame advances from rest pose toward target (matches substrate LookAtState init convention).
1068 lines
42 KiB
Lua
1068 lines
42 KiB
Lua
-- lib-core.puppet v0.3.2
|
|
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe
|
|
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
|
|
-- + cascaded world-transform with scale propagation.
|
|
-- Render path: world.scl_x/y (cascaded) + world.rot used for draw_sprite_transform.
|
|
|
|
local M = {}
|
|
|
|
-- ====================================================================
|
|
-- Module state (lib-singleton)
|
|
-- ====================================================================
|
|
local all_puppets = {} -- {[handle] = puppet_instance}
|
|
local next_handle = 1
|
|
|
|
-- ====================================================================
|
|
-- Utility helpers
|
|
-- ====================================================================
|
|
local function deep_copy(t)
|
|
if type(t) ~= "table" then return t end
|
|
local copy = {}
|
|
for k, v in pairs(t) do copy[k] = deep_copy(v) end
|
|
return copy
|
|
end
|
|
|
|
local function clamp_angle_rad(a)
|
|
-- Normalize to [-pi, pi]
|
|
local pi = math.pi
|
|
while a > pi do a = a - 2 * pi end
|
|
while a < -pi do a = a + 2 * pi end
|
|
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
|
|
-- ====================================================================
|
|
function M.build_rig(rig_table)
|
|
if type(rig_table) ~= "table" then
|
|
error("puppet.build_rig: rig_table must be a table")
|
|
end
|
|
if type(rig_table.bones) ~= "table" or #rig_table.bones == 0 then
|
|
error("puppet.build_rig: rig.bones must be a non-empty array")
|
|
end
|
|
if type(rig_table.tracks) ~= "table" then
|
|
error("puppet.build_rig: rig.tracks must be an array")
|
|
end
|
|
|
|
-- Build bones_by_id with shallow copies of rest pose.
|
|
local bones_by_id = {}
|
|
for _, b in ipairs(rig_table.bones) do
|
|
if type(b.id) ~= "string" then
|
|
error("puppet.build_rig: bone.id must be string")
|
|
end
|
|
if bones_by_id[b.id] ~= nil then
|
|
error("puppet.build_rig: duplicate bone id: " .. b.id)
|
|
end
|
|
|
|
if type(b.rest) ~= "table" then
|
|
error("puppet.build_rig: bone '" .. b.id .. "' missing rest pose")
|
|
end
|
|
if b.rest.angle ~= nil then
|
|
error("puppet.build_rig: bone '" .. b.id
|
|
.. "' uses legacy `rest.angle`; rename to `rest.rot` (radians or set via degrees and lib will math.rad). Substrate-Parity v0.3.0 schema.")
|
|
end
|
|
-- New: rest.rot (degrees in JSON, math.rad to radians here)
|
|
local rest_rot = math.rad(b.rest.rot or 0)
|
|
-- New: rest.scl as [sx, sy], default [1, 1]
|
|
local rest_scl = b.rest.scl
|
|
if rest_scl == nil then rest_scl = {1, 1} end
|
|
if type(rest_scl) ~= "table" or rest_scl[1] == nil or rest_scl[2] == nil then
|
|
error("puppet.build_rig: bone '" .. b.id .. "' rest.scl must be [sx, sy] table")
|
|
end
|
|
|
|
-- A.2: Parse rot_min/rot_max (degrees in JSON → radians stored)
|
|
local rest_rot_min = nil
|
|
local rest_rot_max = nil
|
|
if b.rest.rot_min ~= nil then rest_rot_min = math.rad(b.rest.rot_min) end
|
|
if b.rest.rot_max ~= nil then rest_rot_max = math.rad(b.rest.rot_max) end
|
|
|
|
-- A.3: Parse look_at_speed (deg/s in JSON → radians/s stored)
|
|
local look_at_speed = nil
|
|
if b.look_at_speed ~= nil then look_at_speed = math.rad(b.look_at_speed) end
|
|
|
|
bones_by_id[b.id] = {
|
|
id = b.id,
|
|
parent = nil, -- resolved below
|
|
rest = {
|
|
x = b.rest.x, y = b.rest.y,
|
|
rot = rest_rot,
|
|
scl = { rest_scl[1], rest_scl[2] },
|
|
},
|
|
look_at = (b.look_at == true),
|
|
rest_rot_min = rest_rot_min,
|
|
rest_rot_max = rest_rot_max,
|
|
look_at_speed = look_at_speed,
|
|
color = b.color or { 200, 200, 200 },
|
|
texture = b.texture, -- optional atlas-id (string) or nil
|
|
z_order = b.z_order or 0, -- render-sort key; default 0
|
|
anchor = b.anchor, -- optional [x, y] override of atlas-anchor; resolved in load_textures
|
|
texture_handle = nil, -- populated by load_textures
|
|
}
|
|
end
|
|
|
|
-- Resolve parents (object references).
|
|
for _, b_src in ipairs(rig_table.bones) do
|
|
local b = bones_by_id[b_src.id]
|
|
if b_src.parent ~= nil then
|
|
local parent_obj = bones_by_id[b_src.parent]
|
|
if parent_obj == nil then
|
|
error("puppet.build_rig: parent '" .. tostring(b_src.parent)
|
|
.. "' of bone '" .. b.id .. "' not found")
|
|
end
|
|
b.parent = parent_obj
|
|
end
|
|
end
|
|
|
|
-- Build tracks + validate each bone is in EXACTLY ONE track.
|
|
local bone_to_track = {}
|
|
local tracks_by_id = {}
|
|
for _, t in ipairs(rig_table.tracks) do
|
|
if type(t.id) ~= "string" then
|
|
error("puppet.build_rig: track.id must be string")
|
|
end
|
|
if tracks_by_id[t.id] ~= nil then
|
|
error("puppet.build_rig: duplicate track id: " .. t.id)
|
|
end
|
|
local bones_in_track = {}
|
|
for _, bid in ipairs(t.bones) do
|
|
if bones_by_id[bid] == nil then
|
|
error("puppet.build_rig: track '" .. t.id
|
|
.. "' references unknown bone '" .. bid .. "'")
|
|
end
|
|
if bone_to_track[bid] ~= nil then
|
|
error("puppet.build_rig: bone '" .. bid
|
|
.. "' assigned to multiple tracks ('"
|
|
.. bone_to_track[bid] .. "' and '" .. t.id .. "')")
|
|
end
|
|
bone_to_track[bid] = t.id
|
|
bones_in_track[#bones_in_track + 1] = bid
|
|
end
|
|
tracks_by_id[t.id] = { id = t.id, bones = bones_in_track }
|
|
end
|
|
|
|
-- Build z-sorted bone list (lower z_order first = back, higher = front).
|
|
local bones_z_sorted = {}
|
|
for _, b_src in ipairs(rig_table.bones) do
|
|
bones_z_sorted[#bones_z_sorted + 1] = bones_by_id[b_src.id]
|
|
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
|
|
bones = rig_table.bones, -- keep original-order array
|
|
bones_by_id = bones_by_id,
|
|
tracks_by_id = tracks_by_id,
|
|
bone_to_track = bone_to_track,
|
|
bones_z_sorted = bones_z_sorted,
|
|
locomotion = loco,
|
|
}
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Animation validation + build
|
|
-- ====================================================================
|
|
function M.build_animation(anim_table, rig)
|
|
if type(anim_table) ~= "table" then
|
|
error("puppet.build_animation: anim_table must be a table")
|
|
end
|
|
if type(anim_table.track) ~= "string" then
|
|
error("puppet.build_animation: animation.track must be string")
|
|
end
|
|
if rig.tracks_by_id[anim_table.track] == nil then
|
|
error("puppet.build_animation: animation.track '" .. anim_table.track
|
|
.. "' not found in rig")
|
|
end
|
|
if type(anim_table.duration) ~= "number" or anim_table.duration <= 0 then
|
|
error("puppet.build_animation: animation.duration must be positive number")
|
|
end
|
|
if type(anim_table.keyframes) ~= "table" or #anim_table.keyframes == 0 then
|
|
error("puppet.build_animation: animation.keyframes must be non-empty array")
|
|
end
|
|
|
|
local track_bones = {}
|
|
for _, bid in ipairs(rig.tracks_by_id[anim_table.track].bones) do
|
|
track_bones[bid] = true
|
|
end
|
|
|
|
local keyframes = {}
|
|
for i, kf in ipairs(anim_table.keyframes) do
|
|
if type(kf.t) ~= "number" or kf.t < 0 or kf.t > anim_table.duration + 1e-9 then
|
|
error("puppet.build_animation: keyframe[" .. i .. "].t out of [0, duration]")
|
|
end
|
|
local cooked = { t = kf.t, bones = {} }
|
|
for k, v in pairs(kf) do
|
|
if k ~= "t" then
|
|
if rig.bones_by_id[k] == nil then
|
|
error("puppet.build_animation: keyframe references unknown bone '" .. k .. "'")
|
|
end
|
|
if not track_bones[k] then
|
|
error("puppet.build_animation: keyframe writes bone '" .. k
|
|
.. "' which is not in animation's track '" .. anim_table.track .. "'")
|
|
end
|
|
if v.angle ~= nil then
|
|
error("puppet.build_animation: keyframe bone '" .. k
|
|
.. "' uses legacy `angle` channel; rename to `rot` (Substrate-Parity v0.3.0 schema)")
|
|
end
|
|
local ch = {}
|
|
if v.rot ~= nil then
|
|
ch.rot = math.rad(v.rot) -- JSON degrees → radians
|
|
end
|
|
if v.pos ~= nil then
|
|
if type(v.pos) ~= "table" or v.pos[1] == nil or v.pos[2] == nil then
|
|
error("puppet.build_animation: keyframe[" .. i .. "] bone '" .. k
|
|
.. "' pos must be [x, y] table")
|
|
end
|
|
ch.pos = { x = v.pos[1], y = v.pos[2] }
|
|
end
|
|
if v.scl ~= nil then
|
|
if type(v.scl) ~= "table" or v.scl[1] == nil or v.scl[2] == nil then
|
|
error("puppet.build_animation: keyframe[" .. i .. "] bone '" .. k
|
|
.. "' scl must be [sx, sy] table")
|
|
end
|
|
ch.scl = { x = v.scl[1], y = v.scl[2] }
|
|
end
|
|
cooked.bones[k] = ch
|
|
end
|
|
end
|
|
keyframes[i] = cooked
|
|
end
|
|
|
|
return {
|
|
id = anim_table.id or "anonymous",
|
|
track = anim_table.track,
|
|
duration = anim_table.duration,
|
|
loop = (anim_table.loop == true),
|
|
keyframes = keyframes,
|
|
}
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Animation sampling (multi-channel: rot, pos, scl)
|
|
-- ====================================================================
|
|
function M.sample_animation(anim, t)
|
|
-- Wrap t for loops
|
|
if anim.loop and t > anim.duration then
|
|
t = t % anim.duration
|
|
end
|
|
if t < 0 then t = 0 end
|
|
if t > anim.duration then t = anim.duration end
|
|
|
|
local kfs = anim.keyframes
|
|
local prev_kf, next_kf = kfs[1], kfs[1]
|
|
for i = 1, #kfs - 1 do
|
|
if t >= kfs[i].t and t <= kfs[i + 1].t then
|
|
prev_kf = kfs[i]
|
|
next_kf = kfs[i + 1]
|
|
break
|
|
end
|
|
end
|
|
if t >= kfs[#kfs].t then
|
|
prev_kf = kfs[#kfs]
|
|
next_kf = kfs[#kfs]
|
|
end
|
|
|
|
local span = next_kf.t - prev_kf.t
|
|
local alpha = (span > 0) and ((t - prev_kf.t) / span) or 0
|
|
|
|
local frame = {}
|
|
local all_bones = {}
|
|
for bid, _ in pairs(prev_kf.bones) do all_bones[bid] = true end
|
|
for bid, _ in pairs(next_kf.bones) do all_bones[bid] = true end
|
|
|
|
for bid, _ in pairs(all_bones) do
|
|
local p_ch = prev_kf.bones[bid]
|
|
local n_ch = next_kf.bones[bid]
|
|
local out = {}
|
|
-- rot channel
|
|
if p_ch and p_ch.rot ~= nil and n_ch and n_ch.rot ~= nil then
|
|
out.rot = p_ch.rot * (1 - alpha) + n_ch.rot * alpha
|
|
elseif p_ch and p_ch.rot ~= nil then
|
|
out.rot = p_ch.rot
|
|
elseif n_ch and n_ch.rot ~= nil then
|
|
out.rot = n_ch.rot
|
|
end
|
|
-- pos channel
|
|
if p_ch and p_ch.pos and n_ch and n_ch.pos then
|
|
out.pos = {
|
|
x = p_ch.pos.x * (1 - alpha) + n_ch.pos.x * alpha,
|
|
y = p_ch.pos.y * (1 - alpha) + n_ch.pos.y * alpha,
|
|
}
|
|
elseif p_ch and p_ch.pos then
|
|
out.pos = { x = p_ch.pos.x, y = p_ch.pos.y }
|
|
elseif n_ch and n_ch.pos then
|
|
out.pos = { x = n_ch.pos.x, y = n_ch.pos.y }
|
|
end
|
|
-- scl channel
|
|
if p_ch and p_ch.scl and n_ch and n_ch.scl then
|
|
out.scl = {
|
|
x = p_ch.scl.x * (1 - alpha) + n_ch.scl.x * alpha,
|
|
y = p_ch.scl.y * (1 - alpha) + n_ch.scl.y * alpha,
|
|
}
|
|
elseif p_ch and p_ch.scl then
|
|
out.scl = { x = p_ch.scl.x, y = p_ch.scl.y }
|
|
elseif n_ch and n_ch.scl then
|
|
out.scl = { x = n_ch.scl.x, y = n_ch.scl.y }
|
|
end
|
|
frame[bid] = out
|
|
end
|
|
|
|
return frame
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Puppet instance lifecycle
|
|
-- ====================================================================
|
|
function M.spawn(rig, pos)
|
|
if type(rig) ~= "table" or rig.bones_by_id == nil then
|
|
error("puppet.spawn: rig must be a built rig (use puppet.build_rig)")
|
|
end
|
|
pos = pos or { x = 0, y = 0 }
|
|
local handle = next_handle
|
|
next_handle = next_handle + 1
|
|
|
|
-- Per-bone live state (current local pos/rot/scl + cached world-transform).
|
|
local bone_state = {}
|
|
for bid, b in pairs(rig.bones_by_id) do
|
|
bone_state[bid] = {
|
|
rot = b.rest.rot, -- radians
|
|
pos = { x = b.rest.x, y = b.rest.y },
|
|
scl = { x = b.rest.scl[1], y = b.rest.scl[2] },
|
|
world = {
|
|
x = 0, y = 0,
|
|
rot = b.rest.rot,
|
|
scl_x = b.rest.scl[1],
|
|
scl_y = b.rest.scl[2],
|
|
},
|
|
}
|
|
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,
|
|
x = pos.x, y = pos.y,
|
|
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,
|
|
look_at_state = {}, -- {[bone_id] = { angle = X }} persistent rate-limit state
|
|
animations = {}, -- {[anim_id] = built_anim}
|
|
playing = {}, -- {[anim_id] = { t = 0, loop, speed }}
|
|
procedural = {}, -- {[name] = callback_fn}
|
|
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
|
|
}
|
|
return handle
|
|
end
|
|
|
|
function M.despawn(handle)
|
|
all_puppets[handle] = nil
|
|
end
|
|
|
|
function M.position(handle)
|
|
local p = all_puppets[handle]
|
|
return { x = p.x, y = p.y }
|
|
end
|
|
|
|
function M.facing(handle)
|
|
return all_puppets[handle].facing
|
|
end
|
|
|
|
function M.move_to(handle, x, y)
|
|
local p = all_puppets[handle]
|
|
local dx = x - p.x
|
|
local dy = y - p.y
|
|
if dx ~= 0 or dy ~= 0 then
|
|
p.facing = math.atan(dy, dx)
|
|
p.last_move_x = x
|
|
p.last_move_y = y
|
|
p.last_move_time = engine.time.now()
|
|
end
|
|
p.x = x
|
|
p.y = y
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Animation registration + playback control
|
|
-- ====================================================================
|
|
function M.register_animation(handle, anim)
|
|
local p = all_puppets[handle]
|
|
p.animations[anim.id] = anim
|
|
end
|
|
|
|
function M.play(handle, anim_id, opts)
|
|
local p = all_puppets[handle]
|
|
if p.animations[anim_id] == nil then
|
|
error("puppet.play: animation '" .. tostring(anim_id) .. "' not registered")
|
|
end
|
|
opts = opts or {}
|
|
p.playing[anim_id] = { t = 0, loop = (opts.loop == true), speed = (opts.speed or 1.0) }
|
|
end
|
|
|
|
function M.stop(handle, anim_id)
|
|
all_puppets[handle].playing[anim_id] = nil
|
|
end
|
|
|
|
function M.stop_all(handle)
|
|
all_puppets[handle].playing = {}
|
|
end
|
|
|
|
function M.is_playing(handle, anim_id)
|
|
return all_puppets[handle].playing[anim_id] ~= nil
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Look-at + bone queries
|
|
-- ====================================================================
|
|
function M.set_look_target(handle, world_x, world_y)
|
|
all_puppets[handle].look_target = { x = world_x, y = world_y }
|
|
end
|
|
|
|
function M.clear_look_target(handle)
|
|
all_puppets[handle].look_target = nil
|
|
end
|
|
|
|
function M.bone_angle(handle, bone_id)
|
|
-- Returns the stored rot in radians.
|
|
return all_puppets[handle].bone_state[bone_id].rot
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Procedural layer
|
|
-- ====================================================================
|
|
function M.set_procedural(handle, name, callback)
|
|
if type(callback) ~= "function" then
|
|
error("puppet.set_procedural: callback must be function")
|
|
end
|
|
all_puppets[handle].procedural[name] = callback
|
|
end
|
|
|
|
function M.clear_procedural(handle, name)
|
|
all_puppets[handle].procedural[name] = nil
|
|
end
|
|
|
|
function M.write_bone(handle, bone_id, values)
|
|
local p = all_puppets[handle]
|
|
-- A.6: Validate bone exists in rig (not necessarily in a track).
|
|
if p.rig.bones_by_id[bone_id] == nil then
|
|
error("puppet.write_bone: bone '" .. bone_id .. "' not in rig")
|
|
end
|
|
local track_id = p.rig.bone_to_track[bone_id]
|
|
if track_id ~= nil then
|
|
-- Only check track-conflict if bone is in a track.
|
|
for anim_id, _ in pairs(p.playing) do
|
|
local anim = p.animations[anim_id]
|
|
if anim and anim.track == track_id then
|
|
error("puppet.write_bone: bone '" .. bone_id
|
|
.. "' is on track '" .. track_id
|
|
.. "' currently active by keyframe '" .. anim_id .. "'")
|
|
end
|
|
end
|
|
end
|
|
-- A.7: Accept rot/pos/scl channels; reject legacy 'angle'.
|
|
if values.angle ~= nil then
|
|
error("puppet.write_bone: 'angle' channel renamed to 'rot' in v0.3.0")
|
|
end
|
|
if values.rot ~= nil then
|
|
p.bone_state[bone_id].rot = math.rad(values.rot) -- caller passes degrees
|
|
end
|
|
if values.pos ~= nil then
|
|
if type(values.pos) ~= "table" or values.pos[1] == nil or values.pos[2] == nil then
|
|
error("puppet.write_bone: pos must be [x, y] table")
|
|
end
|
|
p.bone_state[bone_id].pos.x = values.pos[1]
|
|
p.bone_state[bone_id].pos.y = values.pos[2]
|
|
end
|
|
if values.scl ~= nil then
|
|
if type(values.scl) ~= "table" or values.scl[1] == nil or values.scl[2] == nil then
|
|
error("puppet.write_bone: scl must be [sx, sy] table")
|
|
end
|
|
p.bone_state[bone_id].scl.x = values.scl[1]
|
|
p.bone_state[bone_id].scl.y = values.scl[2]
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- World-transform query (public API; reads from cached bone_state.world)
|
|
-- ====================================================================
|
|
function M.bone_world_transform(handle, bone_id)
|
|
local p = all_puppets[handle]
|
|
if p == nil then
|
|
error("puppet.bone_world_transform: handle " .. tostring(handle) .. " not spawned")
|
|
end
|
|
local ws = p.bone_state[bone_id] and p.bone_state[bone_id].world
|
|
if ws == nil then
|
|
error("puppet.bone_world_transform: bone '" .. tostring(bone_id) .. "' has no cached world-transform")
|
|
end
|
|
return ws.x, ws.y, ws.rot
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- World-scale query (public API; reads from cached bone_state.world).
|
|
-- Returns scl_x, scl_y of the bone's cached world-state.
|
|
-- ====================================================================
|
|
function M.bone_world_scale(handle, bone_id)
|
|
local p = all_puppets[handle]
|
|
if p == nil then
|
|
error("puppet.bone_world_scale: handle " .. tostring(handle) .. " not spawned")
|
|
end
|
|
local ws = p.bone_state[bone_id] and p.bone_state[bone_id].world
|
|
if ws == nil then
|
|
error("puppet.bone_world_scale: bone '" .. tostring(bone_id) .. "' has no cached world-state")
|
|
end
|
|
return ws.scl_x, ws.scl_y
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Test-only: direct bone-angle write bypassing track-conflict guard.
|
|
-- Stored in test_overrides and applied during update after rest-reset
|
|
-- and keyframe sampling, so it survives the per-frame reset.
|
|
-- Not for production module use.
|
|
-- ====================================================================
|
|
function M.write_bone_test_only(handle, bone_id, angle_rad)
|
|
local p = all_puppets[handle]
|
|
if p == nil then
|
|
error("puppet.write_bone_test_only: handle " .. tostring(handle) .. " not spawned")
|
|
end
|
|
if p.bone_state[bone_id] == nil then
|
|
error("puppet.write_bone_test_only: bone '" .. tostring(bone_id) .. "' not in rig")
|
|
end
|
|
p.test_overrides[bone_id] = angle_rad
|
|
end
|
|
|
|
-- Test-only: override sprite_reach for a foot_state entry without loading textures.
|
|
-- Not for production module use.
|
|
function M.set_foot_sprite_reach_test_only(handle, leg_index, value)
|
|
local p = all_puppets[handle]
|
|
if p == nil or p.foot_state[leg_index] == nil then
|
|
error("puppet.set_foot_sprite_reach_test_only: invalid handle or leg_index")
|
|
end
|
|
p.foot_state[leg_index].sprite_reach = value
|
|
end
|
|
|
|
-- Test-only: read raw foot_state table for a leg by index.
|
|
-- Not for production module use.
|
|
function M.foot_state_test_only(handle, leg_index)
|
|
local p = all_puppets[handle]
|
|
return p and p.foot_state[leg_index] or nil
|
|
end
|
|
|
|
-- Test-only: read per-frame bone_animated flag for a bone.
|
|
-- Returns true if a keyframe set at least one channel this frame, nil otherwise.
|
|
-- Not for production module use.
|
|
function M.bone_animated_test_only(handle, bone_id)
|
|
local p = all_puppets[handle]
|
|
return p and p.bone_animated[bone_id] or nil
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Cascaded world-transform (substrate puppet.c:692-718 port).
|
|
-- Local pos is multiplied by parent.world.scl before rotation + translation.
|
|
-- Used in update_bone_recursive step 3.d.
|
|
-- ====================================================================
|
|
local function compute_world(p, b)
|
|
local bs = p.bone_state[b.id]
|
|
if b.parent == nil then
|
|
bs.world.x = p.x + bs.pos.x
|
|
bs.world.y = p.y + bs.pos.y
|
|
bs.world.rot = bs.rot
|
|
bs.world.scl_x = bs.scl.x
|
|
bs.world.scl_y = bs.scl.y
|
|
else
|
|
local pw = p.bone_state[b.parent.id].world
|
|
local cos_r, sin_r = math.cos(pw.rot), math.sin(pw.rot)
|
|
local sx, sy = pw.scl_x, pw.scl_y
|
|
bs.world.x = pw.x + (cos_r * bs.pos.x * sx - sin_r * bs.pos.y * sy)
|
|
bs.world.y = pw.y + (sin_r * bs.pos.x * sx + cos_r * bs.pos.y * sy)
|
|
bs.world.rot = pw.rot + bs.rot
|
|
bs.world.scl_x = pw.scl_x * bs.scl.x
|
|
bs.world.scl_y = pw.scl_y * bs.scl.y
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Forward-declared recursive helper (forward decl required for child-recursion).
|
|
-- ====================================================================
|
|
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
|
|
|
|
if (now - p.last_move_time) > loco.idle_return_delay then
|
|
local any_stepping = false
|
|
for i, _ in ipairs(loco.legs) do
|
|
if p.foot_state[i].state ~= "planted" then any_stepping = true; break end
|
|
end
|
|
if not any_stepping then
|
|
local best_i, best_dist = nil, 0
|
|
for i, leg_def in ipairs(loco.legs) do
|
|
local fs = p.foot_state[i]
|
|
if fs.state == "planted" then
|
|
local hip_wx = p.bone_state[leg_def.leg].world.x
|
|
local hip_wy = p.bone_state[leg_def.leg].world.y
|
|
local d = math.sqrt((hip_wx - fs.anchor_x)^2 + (hip_wy - fs.anchor_y)^2)
|
|
if d > best_dist then
|
|
best_dist = d
|
|
best_i = i
|
|
end
|
|
end
|
|
end
|
|
if best_i and best_dist > 1 then
|
|
local fs = p.foot_state[best_i]
|
|
local hip_wx = p.bone_state[loco.legs[best_i].leg].world.x
|
|
local hip_wy = p.bone_state[loco.legs[best_i].leg].world.y
|
|
fs.state = "stepping"
|
|
fs.step_from_x = fs.cur_x
|
|
fs.step_from_y = fs.cur_y
|
|
fs.step_to_x = hip_wx
|
|
fs.step_to_y = hip_wy
|
|
fs.step_timer = 0
|
|
fs.step_total = loco.idle_step_duration
|
|
p.last_move_time = now - loco.idle_return_delay + loco.idle_return_stagger
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Update pipeline (per-frame, depth-first traversal).
|
|
--
|
|
-- For each puppet:
|
|
-- 1. Reset all bones to rest pose (rot, pos, scl)
|
|
-- 1b. Reset per-frame bone_animated flags
|
|
-- 2. Advance keyframe-anim sample-times
|
|
-- 3. Depth-first traversal root -> children:
|
|
-- a. Sample keyframe channels (rot/pos/scl) for bone if on active track
|
|
-- a2. Apply test-only overrides
|
|
-- b. Apply look-at if bone has look_at=true (scale-aware)
|
|
-- c. Run procedural callbacks (once per puppet at root-visit)
|
|
-- d. Compute world-transform (cascaded with scale)
|
|
-- ====================================================================
|
|
function M.update(dt)
|
|
for handle, p in pairs(all_puppets) do
|
|
-- 1. Reset bone state to rest pose (rot, pos, scl).
|
|
for bid, b in pairs(p.rig.bones_by_id) do
|
|
p.bone_state[bid].rot = b.rest.rot
|
|
p.bone_state[bid].pos.x = b.rest.x
|
|
p.bone_state[bid].pos.y = b.rest.y
|
|
p.bone_state[bid].scl.x = b.rest.scl[1]
|
|
p.bone_state[bid].scl.y = b.rest.scl[2]
|
|
end
|
|
|
|
-- 1.b Reset per-frame keyframe-flag (used by locomotion in step 4 to skip animated bones)
|
|
for bid, _ in pairs(p.rig.bones_by_id) do
|
|
p.bone_animated[bid] = nil
|
|
end
|
|
|
|
-- 2. Advance keyframe sample-times.
|
|
for anim_id, play_state in pairs(p.playing) do
|
|
local anim = p.animations[anim_id]
|
|
play_state.t = play_state.t + dt * play_state.speed
|
|
if play_state.loop and play_state.t > anim.duration then
|
|
play_state.t = play_state.t % anim.duration
|
|
end
|
|
end
|
|
|
|
-- 3. Depth-first traversal: roots first, then children, recursively.
|
|
for _, b_src in ipairs(p.rig.bones) do
|
|
local b = p.rig.bones_by_id[b_src.id]
|
|
if b.parent == nil then
|
|
update_bone_recursive(p, b, dt)
|
|
end
|
|
end
|
|
|
|
-- 4. Procedural locomotion (post-cascade, mutates world.* directly)
|
|
apply_locomotion(p, dt)
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Depth-first update of one bone, then its children.
|
|
-- ====================================================================
|
|
update_bone_recursive = function(p, b, dt)
|
|
local bid = b.id
|
|
|
|
-- 3.a Sample keyframes (multi-channel: rot/pos/scl)
|
|
local track_id = p.rig.bone_to_track[bid]
|
|
if track_id then
|
|
for anim_id, play_state in pairs(p.playing) do
|
|
local anim = p.animations[anim_id]
|
|
if anim and anim.track == track_id then
|
|
local frame = M.sample_animation(anim, play_state.t)
|
|
local bone_kf = frame[bid]
|
|
if bone_kf then
|
|
if bone_kf.rot ~= nil then
|
|
p.bone_state[bid].rot = bone_kf.rot
|
|
p.bone_animated[bid] = true
|
|
end
|
|
if bone_kf.pos ~= nil then
|
|
p.bone_state[bid].pos.x = bone_kf.pos.x
|
|
p.bone_state[bid].pos.y = bone_kf.pos.y
|
|
p.bone_animated[bid] = true
|
|
end
|
|
if bone_kf.scl ~= nil then
|
|
p.bone_state[bid].scl.x = bone_kf.scl.x
|
|
p.bone_state[bid].scl.y = bone_kf.scl.y
|
|
p.bone_animated[bid] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 3.a2 Apply test-only overrides (written by write_bone_test_only before update call).
|
|
-- These survive the rest-reset done at the top of update; applied after keyframe
|
|
-- sampling so they always win for the current frame.
|
|
if p.test_overrides[bid] ~= nil then
|
|
p.bone_state[bid].rot = p.test_overrides[bid]
|
|
end
|
|
|
|
-- 3.b Apply look-at constraint if this bone is marked look_at (scale-aware).
|
|
if b.look_at and p.look_target ~= nil then
|
|
-- Compute bone-world-pos (scale-aware).
|
|
local px, py, pa, psx, psy
|
|
if b.parent then
|
|
local pw = p.bone_state[b.parent.id].world
|
|
px, py, pa, psx, psy = pw.x, pw.y, pw.rot, pw.scl_x, pw.scl_y
|
|
else
|
|
px, py, pa, psx, psy = p.x, p.y, 0, 1, 1
|
|
end
|
|
local cos_a, sin_a = math.cos(pa), math.sin(pa)
|
|
local lx, ly = b.rest.x, b.rest.y
|
|
local bone_world_x = px + (cos_a * lx * psx - sin_a * ly * psy)
|
|
local bone_world_y = py + (sin_a * lx * psx + cos_a * ly * psy)
|
|
local dx = p.look_target.x - bone_world_x
|
|
local dy = p.look_target.y - bone_world_y
|
|
-- Sprite-top convention: rot=0 means sprite top points up (-Y direction).
|
|
-- atan(dx, -dy): rot=0 when target is straight up, rot=pi/2 when target is to the right.
|
|
local world_angle = math.atan(dx, -dy)
|
|
local target_local = world_angle - pa
|
|
|
|
-- Persistent state for rate-limit (carries angle across frames).
|
|
local state = p.look_at_state[bid]
|
|
if state == nil then
|
|
-- Initialize to bone's rest rotation so first frame advances from rest toward target.
|
|
state = { angle = b.rest.rot or 0 }
|
|
p.look_at_state[bid] = state
|
|
end
|
|
|
|
if b.look_at_speed then
|
|
-- Rate-limit using persistent state (not current frame's bone_state.rot).
|
|
local delta = target_local - state.angle
|
|
while delta > math.pi do delta = delta - 2 * math.pi end
|
|
while delta < -math.pi do delta = delta + 2 * math.pi end
|
|
local max_step = b.look_at_speed * dt
|
|
if delta > max_step then delta = max_step end
|
|
if delta < -max_step then delta = -max_step end
|
|
state.angle = state.angle + delta
|
|
else
|
|
-- Instant snap (no speed limit).
|
|
state.angle = target_local
|
|
end
|
|
|
|
-- Apply rot_min/rot_max clamp (local-frame radians).
|
|
if b.rest_rot_min and state.angle < b.rest_rot_min then state.angle = b.rest_rot_min end
|
|
if b.rest_rot_max and state.angle > b.rest_rot_max then state.angle = b.rest_rot_max end
|
|
|
|
-- Write into bone_state (overrides keyframe-sampled rot).
|
|
p.bone_state[bid].rot = state.angle
|
|
end
|
|
|
|
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).
|
|
if b.parent == nil then
|
|
for name, cb in pairs(p.procedural) do
|
|
local ok, err = pcall(cb, p.handle, dt)
|
|
if not ok then
|
|
p.procedural[name] = nil -- disarm offending callback to avoid repeat errors
|
|
error(err, 0) -- re-raise so caller (or test pcall) sees the conflict
|
|
end
|
|
end
|
|
end
|
|
|
|
-- 3.d Compute world-transform (cascaded with scale).
|
|
compute_world(p, b)
|
|
|
|
-- Recurse to children.
|
|
for _, child_src in ipairs(p.rig.bones) do
|
|
local child = p.rig.bones_by_id[child_src.id]
|
|
if child.parent == b then
|
|
update_bone_recursive(p, child, dt)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Rendering (engine.render.*; only callable in render-phase).
|
|
-- Walks bones in z-sorted order (back-to-front). For each bone:
|
|
-- - If bone has texture_handle: draw via engine.render.draw_sprite_transform
|
|
-- using cascaded world.scl_x/y and world.rot
|
|
-- - Else: fall back to engine.render.draw_rect_rotated with bone.color
|
|
-- ====================================================================
|
|
function M.render(handle)
|
|
local p = all_puppets[handle]
|
|
if p == nil then return end
|
|
|
|
for _, b in ipairs(p.rig.bones_z_sorted) do
|
|
local ws = p.bone_state[b.id].world
|
|
if b.texture_handle then
|
|
-- Cascaded world-scale (was: per-bone constant b.scale)
|
|
local sx, sy = ws.scl_x, ws.scl_y
|
|
local ax = b.anchor and b.anchor[1] or 0
|
|
local ay = b.anchor and b.anchor[2] or 0
|
|
engine.render.draw_sprite_transform(
|
|
b.texture_handle,
|
|
ws.x, ws.y, ws.rot,
|
|
sx, sy, ax, ay,
|
|
0xFFFFFFFF
|
|
)
|
|
else
|
|
-- Fallback: colored rotated rect (Phase-1 behavior)
|
|
local color = engine.render.rgb(b.color[1], b.color[2], b.color[3])
|
|
engine.render.draw_rect_rotated(ws.x, ws.y, 16, 4, ws.rot, color)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Asset loading (convenience wrappers around build_rig/build_animation)
|
|
-- ====================================================================
|
|
-- engine.asset.load_json(path) reads the file and parses JSON in one
|
|
-- call, returning a Lua table directly. No separate decode step needed.
|
|
|
|
-- ====================================================================
|
|
-- Resolve atlas-ids to texture-handles via asset-lib indirection.
|
|
-- asset_aliases: { [alias-key] = asset-lib-id } from module's manifest.
|
|
-- ====================================================================
|
|
function M.load_textures(rig, asset_aliases)
|
|
if rig.asset_pack == nil then return end -- no-texture rig, nothing to do
|
|
local lib_id = asset_aliases[rig.asset_pack]
|
|
if lib_id == nil then
|
|
error("puppet.load_textures: asset-pack alias '" .. rig.asset_pack
|
|
.. "' not found in module asset_aliases")
|
|
end
|
|
local atlas_path = lib_id .. "/assets/atlas.json"
|
|
local atlas = engine.asset.load_json(atlas_path)
|
|
local pack = atlas[rig.asset_pack]
|
|
if pack == nil then
|
|
error("puppet.load_textures: asset_pack '" .. rig.asset_pack
|
|
.. "' not declared in atlas of '" .. lib_id .. "'")
|
|
end
|
|
for _, b in pairs(rig.bones_by_id) do
|
|
if b.texture then
|
|
local entry = pack[b.texture]
|
|
if entry == nil then
|
|
error("puppet.load_textures: atlas-id '" .. b.texture
|
|
.. "' not in asset_pack '" .. rig.asset_pack
|
|
.. "' of '" .. lib_id .. "'")
|
|
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 }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Load a rig from a JSON file and optionally resolve textures.
|
|
-- asset_aliases is the module-level alias map (from manifest.module);
|
|
-- pass engine.module.asset_aliases() in module init.lua.
|
|
-- ====================================================================
|
|
function M.load_rig(path, asset_aliases)
|
|
local rig_table = engine.asset.load_json(path)
|
|
local rig = M.build_rig(rig_table)
|
|
if asset_aliases then
|
|
M.load_textures(rig, asset_aliases)
|
|
end
|
|
return rig
|
|
end
|
|
|
|
function M.load_animation(path, rig)
|
|
local anim_table = engine.asset.load_json(path)
|
|
return M.build_animation(anim_table, rig)
|
|
end
|
|
|
|
return M
|