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:
Axel Meyer
2026-05-17 19:57:03 +02:00
parent c3bb096b5b
commit 0b2bd7eab0

185
init.lua
View File

@@ -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