feat: add spawn/despawn + animation playback + look-at + procedural
Per-puppet instance state: position, facing, bone-state cache, look-target, playing animations, registered procedural callbacks. Per-frame update pipeline: 1. Reset bones to rest pose 2. Apply look-at constraint (bones marked look_at: true rotate to face the target's world-position) 3. Advance + sample keyframe animations on their tracks 4. Run procedural callbacks (write_bone validates that the bone's track is not currently keyframe-active) Look-at math: bone world-pos = puppet origin + bone.rest offset (parent-chain resolution stays simple in v0.1 since the only look-at bone (head) attaches directly to torso at the origin). Full parent-chain transform reserved for v0.2 when multi-level rigs exist.
This commit is contained in:
185
init.lua
185
init.lua
@@ -232,4 +232,189 @@ function M.sample_animation(anim, t)
|
|||||||
return frame
|
return frame
|
||||||
end
|
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 angle, current world-pos cache).
|
||||||
|
local bone_state = {}
|
||||||
|
for bid, b in pairs(rig.bones_by_id) do
|
||||||
|
bone_state[bid] = { angle = b.rest.angle, world_x = 0, world_y = 0 }
|
||||||
|
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,
|
||||||
|
look_target = nil,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
return all_puppets[handle].bone_state[bone_id].angle
|
||||||
|
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]
|
||||||
|
-- Validate this bone's track is not currently keyframe-active.
|
||||||
|
local track_id = p.rig.bone_to_track[bone_id]
|
||||||
|
if track_id == nil then
|
||||||
|
error("puppet.write_bone: bone '" .. bone_id .. "' not in rig")
|
||||||
|
end
|
||||||
|
-- A track is keyframe-active if any playing animation uses it.
|
||||||
|
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
|
||||||
|
if values.angle ~= nil then
|
||||||
|
p.bone_state[bone_id].angle = math.rad(values.angle)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ====================================================================
|
||||||
|
-- Update pipeline (per-frame)
|
||||||
|
-- ====================================================================
|
||||||
|
function M.update(dt)
|
||||||
|
for handle, p in pairs(all_puppets) do
|
||||||
|
-- 1. Reset bones to rest pose.
|
||||||
|
for bid, b in pairs(p.rig.bones_by_id) do
|
||||||
|
p.bone_state[bid].angle = b.rest.angle
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 2. Apply look-at constraint (writes to bones marked look_at: true).
|
||||||
|
if p.look_target ~= nil then
|
||||||
|
for bid, b in pairs(p.rig.bones_by_id) do
|
||||||
|
if b.look_at then
|
||||||
|
-- Compute bone's world-pos by chaining parents.
|
||||||
|
-- For minimal v0.1: assume bone's parent is at puppet origin (torso).
|
||||||
|
-- Simplified: bone world-pos = puppet pos + bone.rest offset.
|
||||||
|
local bone_world_x = p.x + b.rest.x
|
||||||
|
local bone_world_y = p.y + b.rest.y
|
||||||
|
local dx = p.look_target.x - bone_world_x
|
||||||
|
local dy = p.look_target.y - bone_world_y
|
||||||
|
p.bone_state[bid].angle = math.atan(dy, dx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3. Advance + apply keyframe animations.
|
||||||
|
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
|
||||||
|
local frame = M.sample_animation(anim, play_state.t)
|
||||||
|
for bid, bone_kf in pairs(frame) do
|
||||||
|
p.bone_state[bid].angle = math.rad(bone_kf.angle)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4. Apply procedural callbacks (via write_bone -- auto-validated).
|
||||||
|
for name, cb in pairs(p.procedural) do
|
||||||
|
cb(handle, dt)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
return M
|
return M
|
||||||
|
|||||||
Reference in New Issue
Block a user