-- lib-core.puppet v0.3.1 -- Skeletal animation: skeleton + bones + tracks + rest + keyframe -- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl) -- + cascaded world-transform with scale propagation. -- Render path: world.scl_x/y (cascaded) + world.rot used for draw_sprite_transform. 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 local function smoothstep(t) if t < 0 then return 0 end if t > 1 then return 1 end return t * t * (3 - 2 * t) end local function lerp(a, b, t) return a + (b - a) * t end -- ==================================================================== -- 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 if type(b.rest) ~= "table" then error("puppet.build_rig: bone '" .. b.id .. "' missing rest pose") end if b.rest.angle ~= nil then error("puppet.build_rig: bone '" .. b.id .. "' uses legacy `rest.angle`; rename to `rest.rot` (radians or set via degrees and lib will math.rad). Substrate-Parity v0.3.0 schema.") end -- New: rest.rot (degrees in JSON, math.rad to radians here) local rest_rot = math.rad(b.rest.rot or 0) -- New: rest.scl as [sx, sy], default [1, 1] local rest_scl = b.rest.scl if rest_scl == nil then rest_scl = {1, 1} end if type(rest_scl) ~= "table" or rest_scl[1] == nil or rest_scl[2] == nil then error("puppet.build_rig: bone '" .. b.id .. "' rest.scl must be [sx, sy] table") end -- A.2: Parse rot_min/rot_max (degrees in JSON → radians stored) local rest_rot_min = nil local rest_rot_max = nil if b.rest.rot_min ~= nil then rest_rot_min = math.rad(b.rest.rot_min) end if b.rest.rot_max ~= nil then rest_rot_max = math.rad(b.rest.rot_max) end -- A.3: Parse look_at_speed (deg/s in JSON → radians/s stored) local look_at_speed = nil if b.look_at_speed ~= nil then look_at_speed = math.rad(b.look_at_speed) end bones_by_id[b.id] = { id = b.id, parent = nil, -- resolved below rest = { x = b.rest.x, y = b.rest.y, rot = rest_rot, scl = { rest_scl[1], rest_scl[2] }, }, look_at = (b.look_at == true), rest_rot_min = rest_rot_min, rest_rot_max = rest_rot_max, look_at_speed = look_at_speed, color = b.color or { 200, 200, 200 }, texture = b.texture, -- optional atlas-id (string) or nil z_order = b.z_order or 0, -- render-sort key; default 0 anchor = b.anchor, -- optional [x, y] override of atlas-anchor; resolved in load_textures texture_handle = nil, -- populated by load_textures } 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 -- Build z-sorted bone list (lower z_order first = back, higher = front). local bones_z_sorted = {} for _, b_src in ipairs(rig_table.bones) do bones_z_sorted[#bones_z_sorted + 1] = bones_by_id[b_src.id] end table.sort(bones_z_sorted, function(a, b) return a.z_order < b.z_order end) -- Parse optional locomotion block (substrate-port: footplant config) local loco = nil if rig_table.locomotion ~= nil then local jloco = rig_table.locomotion if type(jloco) ~= "table" then error("puppet.build_rig: rig.locomotion must be a table") end local legs_def = {} for i, leg_entry in ipairs(jloco.legs or {}) do if bones_by_id[leg_entry.leg] == nil then error("puppet.build_rig: rig.locomotion.legs["..i.."].leg='" .. tostring(leg_entry.leg) .. "' not in rig") end if bones_by_id[leg_entry.foot] == nil then error("puppet.build_rig: rig.locomotion.legs["..i.."].foot='" .. tostring(leg_entry.foot) .. "' not in rig") end legs_def[i] = { leg = leg_entry.leg, foot = leg_entry.foot, group = leg_entry.group or (i - 1), } end loco = { mode = jloco.mode or "procedural", step_trigger = jloco.step_trigger or { 6, 24 }, step_placement = jloco.step_placement or { 16, 28 }, step_duration = jloco.step_duration or 0.18, step_height = jloco.step_height or 0, idle_return_delay = jloco.idle_return_delay or 0.5, idle_return_stagger = jloco.idle_return_stagger or 0.3, idle_step_duration = jloco.idle_step_duration or 0.25, legs = legs_def, } end return { id = rig_table.id or "anonymous", asset_pack = rig_table.asset_pack, -- optional alias-key into module's asset_aliases 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, bones_z_sorted = bones_z_sorted, locomotion = loco, } 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 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 if v.angle ~= nil then error("puppet.build_animation: keyframe bone '" .. k .. "' uses legacy `angle` channel; rename to `rot` (Substrate-Parity v0.3.0 schema)") end local ch = {} if v.rot ~= nil then ch.rot = math.rad(v.rot) -- JSON degrees → radians end if v.pos ~= nil then if type(v.pos) ~= "table" or v.pos[1] == nil or v.pos[2] == nil then error("puppet.build_animation: keyframe[" .. i .. "] bone '" .. k .. "' pos must be [x, y] table") end ch.pos = { x = v.pos[1], y = v.pos[2] } end if v.scl ~= nil then if type(v.scl) ~= "table" or v.scl[1] == nil or v.scl[2] == nil then error("puppet.build_animation: keyframe[" .. i .. "] bone '" .. k .. "' scl must be [sx, sy] table") end ch.scl = { x = v.scl[1], y = v.scl[2] } end cooked.bones[k] = ch 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 (multi-channel: rot, pos, scl) -- ==================================================================== 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 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 >= kfs[#kfs].t then prev_kf = kfs[#kfs] next_kf = kfs[#kfs] end local span = next_kf.t - prev_kf.t local alpha = (span > 0) and ((t - prev_kf.t) / span) or 0 local frame = {} 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 p_ch = prev_kf.bones[bid] local n_ch = next_kf.bones[bid] local out = {} -- rot channel if p_ch and p_ch.rot ~= nil and n_ch and n_ch.rot ~= nil then out.rot = p_ch.rot * (1 - alpha) + n_ch.rot * alpha elseif p_ch and p_ch.rot ~= nil then out.rot = p_ch.rot elseif n_ch and n_ch.rot ~= nil then out.rot = n_ch.rot end -- pos channel if p_ch and p_ch.pos and n_ch and n_ch.pos then out.pos = { x = p_ch.pos.x * (1 - alpha) + n_ch.pos.x * alpha, y = p_ch.pos.y * (1 - alpha) + n_ch.pos.y * alpha, } elseif p_ch and p_ch.pos then out.pos = { x = p_ch.pos.x, y = p_ch.pos.y } elseif n_ch and n_ch.pos then out.pos = { x = n_ch.pos.x, y = n_ch.pos.y } end -- scl channel if p_ch and p_ch.scl and n_ch and n_ch.scl then out.scl = { x = p_ch.scl.x * (1 - alpha) + n_ch.scl.x * alpha, y = p_ch.scl.y * (1 - alpha) + n_ch.scl.y * alpha, } elseif p_ch and p_ch.scl then out.scl = { x = p_ch.scl.x, y = p_ch.scl.y } elseif n_ch and n_ch.scl then out.scl = { x = n_ch.scl.x, y = n_ch.scl.y } end frame[bid] = out 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 local pos/rot/scl + cached world-transform). local bone_state = {} for bid, b in pairs(rig.bones_by_id) do bone_state[bid] = { rot = b.rest.rot, -- radians pos = { x = b.rest.x, y = b.rest.y }, scl = { x = b.rest.scl[1], y = b.rest.scl[2] }, world = { x = 0, y = 0, rot = b.rest.rot, scl_x = b.rest.scl[1], scl_y = b.rest.scl[2], }, } end -- Initialize foot_state per locomotion.legs[] local foot_state = {} if rig.locomotion then for i, leg in ipairs(rig.locomotion.legs) do local leg_bone = rig.bones_by_id[leg.leg] foot_state[i] = { state = "planted", anchor_x = 0, anchor_y = 0, step_from_x = 0, step_from_y = 0, step_to_x = 0, step_to_y = 0, step_timer = 0, step_total = 0, cur_x = 0, cur_y = 0, initialized = false, -- sprite_reach: leg-sprite extent below pivot. Computed from -- texture.height - anchor.y. If texture not loaded yet (rig -- built without aliases), fallback uses spec default 30. sprite_reach = 30.0, } -- If texture is loaded, compute sprite_reach from actual sprite dim if leg_bone.texture_handle and leg_bone.texture_height then local anchor_y = (leg_bone.anchor and leg_bone.anchor[2]) or 0 foot_state[i].sprite_reach = leg_bone.texture_height - anchor_y if foot_state[i].sprite_reach < 1 then foot_state[i].sprite_reach = 1 end end end 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, foot_state = foot_state, bone_animated = {}, last_move_time = 0, 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, test_overrides = {}, -- {[bone_id] = angle_rad} set by write_bone_test_only; applied after reset+keyframe } 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 p.last_move_time = engine.time.now() 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 stored rot in radians. return all_puppets[handle].bone_state[bone_id].rot 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] -- A.6: Validate bone exists in rig (not necessarily in a track). if p.rig.bones_by_id[bone_id] == nil then error("puppet.write_bone: bone '" .. bone_id .. "' not in rig") end local track_id = p.rig.bone_to_track[bone_id] if track_id ~= nil then -- Only check track-conflict if bone is in a track. 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 end -- A.7: Accept rot/pos/scl channels; reject legacy 'angle'. if values.angle ~= nil then error("puppet.write_bone: 'angle' channel renamed to 'rot' in v0.3.0") end if values.rot ~= nil then p.bone_state[bone_id].rot = math.rad(values.rot) -- caller passes degrees end if values.pos ~= nil then if type(values.pos) ~= "table" or values.pos[1] == nil or values.pos[2] == nil then error("puppet.write_bone: pos must be [x, y] table") end p.bone_state[bone_id].pos.x = values.pos[1] p.bone_state[bone_id].pos.y = values.pos[2] end if values.scl ~= nil then if type(values.scl) ~= "table" or values.scl[1] == nil or values.scl[2] == nil then error("puppet.write_bone: scl must be [sx, sy] table") end p.bone_state[bone_id].scl.x = values.scl[1] p.bone_state[bone_id].scl.y = values.scl[2] end end -- ==================================================================== -- World-transform query (public API; reads from cached bone_state.world) -- ==================================================================== function M.bone_world_transform(handle, bone_id) local p = all_puppets[handle] if p == nil then error("puppet.bone_world_transform: handle " .. tostring(handle) .. " not spawned") end local ws = p.bone_state[bone_id] and p.bone_state[bone_id].world if ws == nil then error("puppet.bone_world_transform: bone '" .. tostring(bone_id) .. "' has no cached world-transform") end return ws.x, ws.y, ws.rot end -- ==================================================================== -- World-scale query (public API; reads from cached bone_state.world). -- Returns scl_x, scl_y of the bone's cached world-state. -- ==================================================================== function M.bone_world_scale(handle, bone_id) local p = all_puppets[handle] if p == nil then error("puppet.bone_world_scale: handle " .. tostring(handle) .. " not spawned") end local ws = p.bone_state[bone_id] and p.bone_state[bone_id].world if ws == nil then error("puppet.bone_world_scale: bone '" .. tostring(bone_id) .. "' has no cached world-state") end return ws.scl_x, ws.scl_y end -- ==================================================================== -- Test-only: direct bone-angle write bypassing track-conflict guard. -- Stored in test_overrides and applied during update after rest-reset -- and keyframe sampling, so it survives the per-frame reset. -- Not for production module use. -- ==================================================================== function M.write_bone_test_only(handle, bone_id, angle_rad) local p = all_puppets[handle] if p == nil then error("puppet.write_bone_test_only: handle " .. tostring(handle) .. " not spawned") end if p.bone_state[bone_id] == nil then error("puppet.write_bone_test_only: bone '" .. tostring(bone_id) .. "' not in rig") end p.test_overrides[bone_id] = angle_rad end -- Test-only: override sprite_reach for a foot_state entry without loading textures. -- Not for production module use. function M.set_foot_sprite_reach_test_only(handle, leg_index, value) local p = all_puppets[handle] if p == nil or p.foot_state[leg_index] == nil then error("puppet.set_foot_sprite_reach_test_only: invalid handle or leg_index") end p.foot_state[leg_index].sprite_reach = value end -- Test-only: read raw foot_state table for a leg by index. -- Not for production module use. function M.foot_state_test_only(handle, leg_index) local p = all_puppets[handle] return p and p.foot_state[leg_index] or nil end -- Test-only: read per-frame bone_animated flag for a bone. -- Returns true if a keyframe set at least one channel this frame, nil otherwise. -- Not for production module use. function M.bone_animated_test_only(handle, bone_id) local p = all_puppets[handle] return p and p.bone_animated[bone_id] or nil end -- ==================================================================== -- Cascaded world-transform (substrate puppet.c:692-718 port). -- Local pos is multiplied by parent.world.scl before rotation + translation. -- Used in update_bone_recursive step 3.d. -- ==================================================================== local function compute_world(p, b) local bs = p.bone_state[b.id] if b.parent == nil then bs.world.x = p.x + bs.pos.x bs.world.y = p.y + bs.pos.y bs.world.rot = bs.rot bs.world.scl_x = bs.scl.x bs.world.scl_y = bs.scl.y else local pw = p.bone_state[b.parent.id].world local cos_r, sin_r = math.cos(pw.rot), math.sin(pw.rot) local sx, sy = pw.scl_x, pw.scl_y bs.world.x = pw.x + (cos_r * bs.pos.x * sx - sin_r * bs.pos.y * sy) bs.world.y = pw.y + (sin_r * bs.pos.x * sx + cos_r * bs.pos.y * sy) bs.world.rot = pw.rot + bs.rot bs.world.scl_x = pw.scl_x * bs.scl.x bs.world.scl_y = pw.scl_y * bs.scl.y end end -- ==================================================================== -- Forward-declared recursive helper (forward decl required for child-recursion). -- ==================================================================== local update_bone_recursive -- ==================================================================== -- Procedural locomotion (substrate puppet.c:1000-1156 port). -- Runs after compute_world for all bones. Mutates leg.world.{rot, scl_x, scl_y} -- and foot.world.{x, y} directly. Skipped per-leg if bone_animated is set -- on either leg or foot (keyframe takes precedence). -- ==================================================================== local function apply_locomotion(p, dt) if p.rig.locomotion == nil then return end local loco = p.rig.locomotion if loco.mode ~= "procedural" then return end local now = engine.time.now() for i, leg_def in ipairs(loco.legs) do local leg_bone = p.rig.bones_by_id[leg_def.leg] local foot_bone = p.rig.bones_by_id[leg_def.foot] if p.bone_animated[leg_def.leg] or p.bone_animated[leg_def.foot] then -- keyframe has priority; skip goto continue end local fs = p.foot_state[i] local leg_ws = p.bone_state[leg_def.leg].world local hip_wx, hip_wy = leg_ws.x, leg_ws.y -- Initialize anchor on first frame if not fs.initialized then fs.anchor_x = hip_wx fs.anchor_y = hip_wy fs.cur_x = hip_wx fs.cur_y = hip_wy fs.initialized = true end if fs.state == "planted" then local dx = hip_wx - fs.anchor_x local dy = hip_wy - fs.anchor_y local lateral_trigger = loco.step_trigger[1] local longi_trigger = loco.step_trigger[2] local need_step = (math.abs(dx) > lateral_trigger) or (math.abs(dy) > longi_trigger) if need_step then fs.state = "stepping" fs.step_from_x = fs.cur_x fs.step_from_y = fs.cur_y -- Step target: hip + step_placement in facing direction. -- Simplified: place at hip_wx + sign(dx)*step_placement[1] etc. -- sign-of-zero must be 0 (no spurious axis-displacement) local sign_x = (dx > 0 and 1) or (dx < 0 and -1) or 0 local sign_y = (dy > 0 and 1) or (dy < 0 and -1) or 0 fs.step_to_x = hip_wx + sign_x * loco.step_placement[1] fs.step_to_y = hip_wy + sign_y * loco.step_placement[2] fs.step_timer = 0 fs.step_total = loco.step_duration end end if fs.state == "stepping" then fs.step_timer = fs.step_timer + dt local t = fs.step_timer / fs.step_total if t >= 1 then fs.state = "planted" fs.anchor_x = fs.step_to_x fs.anchor_y = fs.step_to_y fs.cur_x = fs.step_to_x fs.cur_y = fs.step_to_y else local a = smoothstep(t) fs.cur_x = lerp(fs.step_from_x, fs.step_to_x, a) fs.cur_y = lerp(fs.step_from_y, fs.step_to_y, a) fs.cur_y = fs.cur_y - math.sin(t * math.pi) * loco.step_height end end -- Leg-aim + leg-stretch (substrate puppet.c:1111-1156) local to_foot_x = fs.cur_x - hip_wx local to_foot_y = fs.cur_y - hip_wy local dist = math.sqrt(to_foot_x * to_foot_x + to_foot_y * to_foot_y) -- foot_angle: substrate uses atan2(-to_foot_x, to_foot_y) → rotates 90° -- because leg sprite points in +Y direction at rot=0. local foot_angle = math.atan(-to_foot_x, to_foot_y) local parent_world_rot = 0 if leg_bone.parent then parent_world_rot = p.bone_state[leg_bone.parent.id].world.rot end leg_ws.rot = foot_angle - parent_world_rot -- sprite_reach: leg-bone texture height minus anchor.y (set by Task 3.10) local sprite_reach = p.foot_state[i].sprite_reach or 30.0 -- temp default if sprite_reach < 1 then sprite_reach = 1 end local stretch = dist / sprite_reach if stretch > 2.5 then stretch = 2.5 end if stretch < 0.2 then stretch = 0.2 end leg_ws.scl_y = stretch leg_ws.scl_x = leg_bone.rest.scl[1] -- preserve mirror -- Foot world-pos override local foot_ws = p.bone_state[leg_def.foot].world foot_ws.x = fs.cur_x foot_ws.y = fs.cur_y ::continue:: end if (now - p.last_move_time) > loco.idle_return_delay then local any_stepping = false for i, _ in ipairs(loco.legs) do if p.foot_state[i].state ~= "planted" then any_stepping = true; break end end if not any_stepping then local best_i, best_dist = nil, 0 for i, leg_def in ipairs(loco.legs) do local fs = p.foot_state[i] if fs.state == "planted" then local hip_wx = p.bone_state[leg_def.leg].world.x local hip_wy = p.bone_state[leg_def.leg].world.y local d = math.sqrt((hip_wx - fs.anchor_x)^2 + (hip_wy - fs.anchor_y)^2) if d > best_dist then best_dist = d best_i = i end end end if best_i and best_dist > 1 then local fs = p.foot_state[best_i] local hip_wx = p.bone_state[loco.legs[best_i].leg].world.x local hip_wy = p.bone_state[loco.legs[best_i].leg].world.y fs.state = "stepping" fs.step_from_x = fs.cur_x fs.step_from_y = fs.cur_y fs.step_to_x = hip_wx fs.step_to_y = hip_wy fs.step_timer = 0 fs.step_total = loco.idle_step_duration p.last_move_time = now - loco.idle_return_delay + loco.idle_return_stagger end end end end -- ==================================================================== -- Update pipeline (per-frame, depth-first traversal). -- -- For each puppet: -- 1. Reset all bones to rest pose (rot, pos, scl) -- 1b. Reset per-frame bone_animated flags -- 2. Advance keyframe-anim sample-times -- 3. Depth-first traversal root -> children: -- a. Sample keyframe channels (rot/pos/scl) for bone if on active track -- a2. Apply test-only overrides -- b. Apply look-at if bone has look_at=true (scale-aware) -- c. Run procedural callbacks (once per puppet at root-visit) -- d. Compute world-transform (cascaded with scale) -- ==================================================================== function M.update(dt) for handle, p in pairs(all_puppets) do -- 1. Reset bone state to rest pose (rot, pos, scl). for bid, b in pairs(p.rig.bones_by_id) do p.bone_state[bid].rot = b.rest.rot p.bone_state[bid].pos.x = b.rest.x p.bone_state[bid].pos.y = b.rest.y p.bone_state[bid].scl.x = b.rest.scl[1] p.bone_state[bid].scl.y = b.rest.scl[2] end -- 1.b Reset per-frame keyframe-flag (used by locomotion in step 4 to skip animated bones) for bid, _ in pairs(p.rig.bones_by_id) do p.bone_animated[bid] = nil end -- 2. Advance keyframe sample-times. 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 end -- 3. Depth-first traversal: roots first, then children, recursively. for _, b_src in ipairs(p.rig.bones) do local b = p.rig.bones_by_id[b_src.id] if b.parent == nil then update_bone_recursive(p, b, dt) end end -- 4. Procedural locomotion (post-cascade, mutates world.* directly) apply_locomotion(p, dt) end end -- ==================================================================== -- Depth-first update of one bone, then its children. -- ==================================================================== update_bone_recursive = function(p, b, dt) local bid = b.id -- 3.a Sample keyframes (multi-channel: rot/pos/scl) local track_id = p.rig.bone_to_track[bid] if track_id then for anim_id, play_state in pairs(p.playing) do local anim = p.animations[anim_id] if anim and anim.track == track_id then local frame = M.sample_animation(anim, play_state.t) local bone_kf = frame[bid] if bone_kf then if bone_kf.rot ~= nil then p.bone_state[bid].rot = bone_kf.rot p.bone_animated[bid] = true end if bone_kf.pos ~= nil then p.bone_state[bid].pos.x = bone_kf.pos.x p.bone_state[bid].pos.y = bone_kf.pos.y p.bone_animated[bid] = true end if bone_kf.scl ~= nil then p.bone_state[bid].scl.x = bone_kf.scl.x p.bone_state[bid].scl.y = bone_kf.scl.y p.bone_animated[bid] = true end end end end end -- 3.a2 Apply test-only overrides (written by write_bone_test_only before update call). -- These survive the rest-reset done at the top of update; applied after keyframe -- sampling so they always win for the current frame. if p.test_overrides[bid] ~= nil then p.bone_state[bid].rot = p.test_overrides[bid] end -- 3.b Apply look-at constraint if this bone is marked look_at (scale-aware). if b.look_at and p.look_target ~= nil then local px, py, pa, psx, psy if b.parent then local pw = p.bone_state[b.parent.id].world px, py, pa, psx, psy = pw.x, pw.y, pw.rot, pw.scl_x, pw.scl_y else px, py, pa, psx, psy = p.x, p.y, 0, 1, 1 end local cos_a, sin_a = math.cos(pa), math.sin(pa) local lx, ly = b.rest.x, b.rest.y local bone_world_x = px + (cos_a * lx * psx - sin_a * ly * psy) local bone_world_y = py + (sin_a * lx * psx + cos_a * ly * psy) local dx = p.look_target.x - bone_world_x local dy = p.look_target.y - bone_world_y -- A.1: Substrate convention (puppet.c:748): sprite top extends in -Y direction at rot=0. -- atan(dx, -dy) means: rot=0 when target is straight up (dy<0), rot=pi/2 when target is to the right. local world_angle = math.atan(dx, -dy) -- A.4: Apply look_at_speed (rate-limited tracking) + rot_min/rot_max clamp (local frame). local target_local = world_angle - pa local current = p.bone_state[bid].rot local next_value if b.look_at_speed then -- Rate-limited rotation: allow up to look_at_speed * dt per frame. local delta = target_local - current -- Normalize delta to [-pi, pi] for shortest-path rotation. while delta > math.pi do delta = delta - 2 * math.pi end while delta < -math.pi do delta = delta + 2 * math.pi end local max_step = b.look_at_speed * dt if delta > max_step then delta = max_step end if delta < -max_step then delta = -max_step end next_value = current + delta else next_value = target_local end -- Apply rot_min/rot_max clamp (local-frame radians). if b.rest_rot_min and next_value < b.rest_rot_min then next_value = b.rest_rot_min end if b.rest_rot_max and next_value > b.rest_rot_max then next_value = b.rest_rot_max end p.bone_state[bid].rot = next_value end -- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute). if b.parent == nil then for name, cb in pairs(p.procedural) do local ok, err = pcall(cb, p.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 -- 3.d Compute world-transform (cascaded with scale). compute_world(p, b) -- Recurse to children. for _, child_src in ipairs(p.rig.bones) do local child = p.rig.bones_by_id[child_src.id] if child.parent == b then update_bone_recursive(p, child, dt) end end end -- ==================================================================== -- Rendering (engine.render.*; only callable in render-phase). -- Walks bones in z-sorted order (back-to-front). For each bone: -- - If bone has texture_handle: draw via engine.render.draw_sprite_transform -- using cascaded world.scl_x/y and world.rot -- - Else: fall back to engine.render.draw_rect_rotated with bone.color -- ==================================================================== function M.render(handle) local p = all_puppets[handle] if p == nil then return end for _, b in ipairs(p.rig.bones_z_sorted) do local ws = p.bone_state[b.id].world if b.texture_handle then -- Cascaded world-scale (was: per-bone constant b.scale) local sx, sy = ws.scl_x, ws.scl_y local ax = b.anchor and b.anchor[1] or 0 local ay = b.anchor and b.anchor[2] or 0 engine.render.draw_sprite_transform( b.texture_handle, ws.x, ws.y, ws.rot, sx, sy, ax, ay, 0xFFFFFFFF ) else -- Fallback: colored rotated rect (Phase-1 behavior) local color = engine.render.rgb(b.color[1], b.color[2], b.color[3]) engine.render.draw_rect_rotated(ws.x, ws.y, 16, 4, ws.rot, color) end 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. -- ==================================================================== -- Resolve atlas-ids to texture-handles via asset-lib indirection. -- asset_aliases: { [alias-key] = asset-lib-id } from module's manifest. -- ==================================================================== function M.load_textures(rig, asset_aliases) if rig.asset_pack == nil then return end -- no-texture rig, nothing to do local lib_id = asset_aliases[rig.asset_pack] if lib_id == nil then error("puppet.load_textures: asset-pack alias '" .. rig.asset_pack .. "' not found in module asset_aliases") end local atlas_path = lib_id .. "/assets/atlas.json" local atlas = engine.asset.load_json(atlas_path) local pack = atlas[rig.asset_pack] if pack == nil then error("puppet.load_textures: asset_pack '" .. rig.asset_pack .. "' not declared in atlas of '" .. lib_id .. "'") end for _, b in pairs(rig.bones_by_id) do if b.texture then local entry = pack[b.texture] if entry == nil then error("puppet.load_textures: atlas-id '" .. b.texture .. "' not in asset_pack '" .. rig.asset_pack .. "' of '" .. lib_id .. "'") end local tex_path = lib_id .. "/assets/" .. entry.file b.texture_handle = engine.asset.load_texture(tex_path) -- Cache texture dimensions for locomotion sprite_reach local tw, th = engine.asset.texture_size(b.texture_handle) b.texture_width = tw b.texture_height = th -- Resolve anchor: bone.anchor override beats atlas-default. if b.anchor == nil then b.anchor = entry.anchor or { 0, 0 } end end end end -- ==================================================================== -- Load a rig from a JSON file and optionally resolve textures. -- asset_aliases is the module-level alias map (from manifest.module); -- pass engine.module.asset_aliases() in module init.lua. -- ==================================================================== function M.load_rig(path, asset_aliases) local rig_table = engine.asset.load_json(path) local rig = M.build_rig(rig_table) if asset_aliases then M.load_textures(rig, asset_aliases) end return rig 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