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.
This commit is contained in:
24
LICENSE
Normal file
24
LICENSE
Normal file
@@ -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
|
||||||
109
README.md
Normal file
109
README.md
Normal file
@@ -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
|
||||||
|
|
||||||
|
<!-- topology:start (auto-generated; do not edit) -->
|
||||||
|
<!-- topology:end -->
|
||||||
|
|
||||||
|
## 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 `{ <bone_id> = { angle = <number>, ... }, ... }`
|
||||||
|
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 = <number> })`.
|
||||||
|
|
||||||
|
### `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)
|
||||||
181
init.lua
Normal file
181
init.lua
Normal file
@@ -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
|
||||||
6
manifest.lib
Normal file
6
manifest.lib
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"id": "lib-core.puppet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"api_min": "0.1",
|
||||||
|
"deps": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user