From 0b2bd7eab0c7964623bbb88ed2b4276220808aa4 Mon Sep 17 00:00:00 2001 From: Axel Meyer Date: Sun, 17 May 2026 19:57:03 +0200 Subject: [PATCH] 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. --- init.lua | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/init.lua b/init.lua index e641fb0..2847e6e 100644 --- a/init.lua +++ b/init.lua @@ -232,4 +232,189 @@ function M.sample_animation(anim, t) 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 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