-- lib-core.puppet v0.1.0 -- Skeletal animation: skeleton + bones + tracks + rest + keyframe -- + procedural + look-at constraint. No footplant in v0.1. 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 -- Subsequent tasks add: sample_animation, -- spawn, despawn, update, render, set_look_target, clear_look_target, -- bone_angle, set_procedural, clear_procedural, write_bone, play, stop, -- stop_all, is_playing, position, facing, move_to, register_animation, -- load_rig, load_animation. -- ==================================================================== -- 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 bones_by_id[b.id] = { id = b.id, parent = nil, -- resolved below rest = { x = b.rest.x, y = b.rest.y, -- JSON angles in degrees; convert to radians once. angle = math.rad(b.rest.angle or 0), }, look_at = (b.look_at == true), color = b.color or { 200, 200, 200 }, } 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 return { id = rig_table.id or "anonymous", 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, } 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 -- Validate each keyframe: t in [0, duration], bones referenced are in this animation's track. 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 cooked.bones[k] = { angle = v.angle or 0, -- keep in degrees; interpolation stays in degrees } 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 -- ==================================================================== 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 -- Find segment [kf_i, kf_{i+1}] containing t. 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 equals last keyframe's t (or beyond and not looping), snap to last. if t >= kfs[#kfs].t then prev_kf = kfs[#kfs] next_kf = kfs[#kfs] end -- Linear interpolation per bone present in either keyframe. local frame = {} local span = next_kf.t - prev_kf.t local alpha = (span > 0) and ((t - prev_kf.t) / span) or 0 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 prev_v = prev_kf.bones[bid] local next_v = next_kf.bones[bid] if prev_v and next_v then frame[bid] = { -- keyframe angles are stored in degrees; interpolate in degrees. angle = prev_v.angle * (1 - alpha) + next_v.angle * alpha, } elseif prev_v then frame[bid] = { angle = prev_v.angle } elseif next_v then frame[bid] = { angle = next_v.angle } end 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 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) -- Returns the raw stored angle: radians for look_at-driven bones, -- degrees for keyframe- and procedural-driven bones. 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 -- Store the caller-supplied value directly; write_bone accepts degrees -- (same units as rest-pose angles and keyframe angles). p.bone_state[bone_id].angle = 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 -- sample_animation returns degrees; store directly (bone_state -- uses degrees for keyframe/procedural channels, radians for -- look_at which writes via math.atan). p.bone_state[bid].angle = bone_kf.angle end end -- 4. Apply procedural callbacks (via write_bone -- auto-validated). -- Use pcall per callback so that a track-conflict from one callback -- removes only that offending procedural and re-raises, rather than -- leaving other puppets' callbacks un-executed on subsequent calls. for name, cb in pairs(p.procedural) do local ok, err = pcall(cb, 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 end -- ==================================================================== -- Rendering (engine.render.* -- only callable in render-phase) -- ==================================================================== function M.render(handle) local p = all_puppets[handle] if p == nil then return end -- Draw each bone as a rotated rect centered at the bone's world-pos. -- v0.1 simplification: bone's world-pos = puppet origin + bone.rest offset. -- Bone "length" is approximated by a small constant; later versions will -- derive from rig data. local BONE_LENGTH = 16 local BONE_THICKNESS = 4 for _, b_src in ipairs(p.rig.bones) do local b = p.rig.bones_by_id[b_src.id] local x = p.x + b.rest.x local y = p.y + b.rest.y local angle = p.bone_state[b.id].angle local c = b.color local color = engine.render.rgb(c[1], c[2], c[3]) engine.render.draw_rect_rotated(x, y, BONE_LENGTH, BONE_THICKNESS, angle, color) 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. function M.load_rig(path) local rig_table = engine.asset.load_json(path) return M.build_rig(rig_table) 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