commit e62671060ee6f0fd5fa8e2efa465f18bdd31365f Author: Axel Meyer Date: Sun May 17 19:54:27 2026 +0200 feat: initial implementation with build_rig + build_animation Validates and cooks rig + animation data into internal representations: - build_rig: parses bones (with parent-resolution to object refs) and tracks (with bone-uniqueness validation). Converts rest angles from JSON degrees to radians. - build_animation: validates each keyframe references only bones in the animation's declared track. Converts keyframe angles from degrees to radians. Remaining functions (sample_animation, spawn, update, look-at, procedural, render, lifecycle) follow in subsequent commits. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..48dbb02 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# lib-core.puppet + +Skeletal animation primitive for top-down 2D characters: bones, +tracks, rest pose, keyframe animations, procedural animations +(Lua-side), and look-at constraint. No footplant IK in v0.1 +(deferred). + +**Version:** 0.1.0 +**Lib-ID:** lib-core.puppet +**Requires:** (none — pure Lua + engine.render.*) +**Tags:** animation, skeleton, puppet, character + +## Topology + + + + +## API + +### `puppet.build_rig(rig_table)` + +Validates a rig table (parsed from `*.rig.json`) and returns an +internal representation with `bones_by_id`, parent-resolved object +references, and track-membership maps. Raises on invalid data +(missing parents, bones in 2 tracks, etc.). + +### `puppet.build_animation(anim_table, rig)` + +Validates an animation table against a built rig (bones referenced +in keyframes must exist and be in the animation's declared track). + +### `puppet.sample_animation(animation, t)` + +Returns a frame `{ = { angle = , ... }, ... }` +sampling the animation at time `t` with linear interpolation. Wraps +on `t > duration` if `animation.loop` is true. + +### `puppet.spawn(rig, { x, y })`, `puppet.despawn(handle)` + +Spawn a new puppet instance at world position (x, y). Returns an +opaque handle. `despawn` removes the instance. + +### `puppet.update(dt)`, `puppet.render(handle)` + +`update(dt)` runs the pipeline for ALL spawned puppets (rest → +look-at → keyframes → procedural). `render(handle)` draws ONE +puppet via `engine.render.draw_rect_rotated` per bone. + +### `puppet.set_look_target(p, world_x, world_y)`, `puppet.clear_look_target(p)` + +Set or clear the look-at target. Bones marked `look_at: true` in the +rig rotate to face the target. + +### `puppet.bone_angle(p, bone_id)` + +Returns the current world-space angle of `bone_id` (after the +full pipeline tick). Used by tests + by callers needing bone-state. + +### `puppet.set_procedural(p, name, callback)`, `puppet.clear_procedural(p, name)` + +Register or unregister a per-frame procedural callback. Callback +signature: `function(handle, dt)`. Inside the callback, mutate +bones via `puppet.write_bone(handle, bone_id, { angle = })`. + +### `puppet.play(p, animation_name, { loop, speed })`, `puppet.stop(p, animation_name)`, `puppet.stop_all(p)`, `puppet.is_playing(p, animation_name)` + +Keyframe-layer controls. Animation must be registered via +`puppet.register_animation(p, anim)` first. + +### `puppet.register_animation(p, animation)` + +Binds a built animation (from `build_animation`) to a puppet +instance, making it available to `play`. + +### `puppet.load_rig(path)`, `puppet.load_animation(path)` + +Convenience wrappers: read JSON file via `engine.asset.read_file` + +parse + call `build_rig` / `build_animation`. + +### `puppet.position(p)`, `puppet.facing(p)`, `puppet.move_to(p, x, y)` + +Puppet owns its world position. `move_to` sets it instantly (caller +applies speed). `facing` returns the last-movement-direction angle. + +### `puppet.write_bone(handle, bone_id, { angle })` + +Procedural-callback-only API to mutate a bone. Validates track +ownership at write time. + +## Conventions + +- World-coords + pixel-units throughout (per ADR-0031). +- Bone angles in radians (internal). JSON rest-angles and animation + keyframe angles are in degrees, converted on load. +- Update pipeline runs once per frame (`puppet.update(dt)`) for ALL + spawned puppets. `render` is per-puppet so callers can interleave + with other rendering. + +## CHANGELOG + +### v0.1.0 +- Initial release: skeleton + bones + tracks + rest + keyframe + + procedural + look-at constraint. Footplant IK deferred. + +## References + +- `architecture/puppet.md` (Reference) +- ADR-0037 (Tests-as-Libs) +- ADR-0038 (API-Doc-Convention) diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..8bdc772 --- /dev/null +++ b/init.lua @@ -0,0 +1,181 @@ +-- 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 = math.rad(v.angle or 0), -- JSON degrees → radians + } + 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 + +return M diff --git a/manifest.lib b/manifest.lib new file mode 100644 index 0000000..7ec5721 --- /dev/null +++ b/manifest.lib @@ -0,0 +1,6 @@ +{ + "id": "lib-core.puppet", + "version": "0.1.0", + "api_min": "0.1", + "deps": [] +}