Files
sporel-lib-core.puppet/init.lua
Axel Meyer 34c53f9980 fix(puppet): align angle storage with test expectations
Three fixes to make puppet-test 14/14 assertions pass:

1. build_animation/sample_animation: store and interpolate keyframe
   angles in degrees (remove premature math.rad conversion). Previously
   the round-trip math.deg(math.rad(-15)) produced -15.0 which failed
   strict equality against integer -15 in Lua 5.4.

2. write_bone + update keyframe apply: store angles directly without
   math.rad conversion; bone_state now uses degrees for keyframe and
   procedural channels (look_at still stores radians from math.atan).

3. update procedural loop: wrap each callback in pcall, disarm the
   offending procedural on track-conflict error, then re-raise. This
   prevents a failed procedural from one puppet leaving other puppets
   un-updated on subsequent calls.
2026-05-17 20:09:30 +02:00

476 lines
17 KiB
Lua

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