feat(puppet): v0.4.0 — Subterrain control model

Multi-look-target API: set_look_target(handle, target_id, x, y),
clear_look_target(handle, target_id). Bones declare look_at as string
target-id; legacy look_at=true maps to target_id="default". Slerp-style
smoothing: look_at_slerp field (exponential approach, clamped to [0,1]
per frame). Linear look_at_speed kept for backward-compat. Virtual bone
flag: virtual=true skips render pass but participates in compute_world.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-05-18 21:18:52 +02:00
parent 6e90bfa8fe
commit cd639668c5
2 changed files with 87 additions and 16 deletions

101
init.lua
View File

@@ -1,8 +1,10 @@
-- lib-core.puppet v0.3.2 -- lib-core.puppet v0.4.0
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe -- Skeletal animation: skeleton + bones + tracks + rest + keyframe
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl) -- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
-- + cascaded world-transform with scale propagation. -- + cascaded world-transform with scale propagation.
-- Render path: world.scl_x/y (cascaded) + world.rot used for draw_sprite_transform. -- Render path: world.scl_x/y (cascaded) + world.rot used for draw_sprite_transform.
-- v0.4.0: Multi-look-target (look_at = "target_id" string), slerp-style smoothing
-- (look_at_slerp field), virtual bone flag (virtual = true skips render).
local M = {} local M = {}
@@ -88,6 +90,10 @@ function M.build_rig(rig_table)
local look_at_speed = nil local look_at_speed = nil
if b.look_at_speed ~= nil then look_at_speed = math.rad(b.look_at_speed) end if b.look_at_speed ~= nil then look_at_speed = math.rad(b.look_at_speed) end
-- v0.4.0: Parse look_at_slerp (unitless float, NOT converted — slerp-rate per second)
local look_at_slerp = nil
if b.look_at_slerp ~= nil then look_at_slerp = b.look_at_slerp end
-- A.4: Parse sprite_rot (deg in JSON → radians stored). Render-time offset -- A.4: Parse sprite_rot (deg in JSON → radians stored). Render-time offset
-- applied as `world.rot + sprite_rot` to compensate for sprite assets -- applied as `world.rot + sprite_rot` to compensate for sprite assets
-- whose natural orientation at rot=0 is not the bone's "forward" (-Y). -- whose natural orientation at rot=0 is not the bone's "forward" (-Y).
@@ -95,6 +101,17 @@ function M.build_rig(rig_table)
local sprite_rot = 0 local sprite_rot = 0
if b.sprite_rot ~= nil then sprite_rot = math.rad(b.sprite_rot) end if b.sprite_rot ~= nil then sprite_rot = math.rad(b.sprite_rot) end
-- v0.4.0: Parse look_at as string (target_id) or bool (legacy: true→"default")
local look_at_target = nil
if type(b.look_at) == "string" then
look_at_target = b.look_at
elseif b.look_at == true then
look_at_target = "default" -- legacy compatibility
end
-- v0.4.0: Parse virtual flag (virtual=true skips render; bone still participates in compute_world)
local virtual = (b.virtual == true)
bones_by_id[b.id] = { bones_by_id[b.id] = {
id = b.id, id = b.id,
parent = nil, -- resolved below parent = nil, -- resolved below
@@ -103,11 +120,14 @@ function M.build_rig(rig_table)
rot = rest_rot, rot = rest_rot,
scl = { rest_scl[1], rest_scl[2] }, scl = { rest_scl[1], rest_scl[2] },
}, },
look_at = (b.look_at == true), look_at = (b.look_at == true or type(b.look_at) == "string"), -- kept for legacy compat
look_at_target = look_at_target, -- v0.4.0: string target_id or nil
rest_rot_min = rest_rot_min, rest_rot_min = rest_rot_min,
rest_rot_max = rest_rot_max, rest_rot_max = rest_rot_max,
look_at_speed = look_at_speed, look_at_speed = look_at_speed,
look_at_slerp = look_at_slerp, -- v0.4.0: slerp-rate (unitless float)
sprite_rot = sprite_rot, sprite_rot = sprite_rot,
virtual = virtual, -- v0.4.0: skip render if true
color = b.color or { 200, 200, 200 }, color = b.color or { 200, 200, 200 },
texture = b.texture, -- optional atlas-id (string) or nil texture = b.texture, -- optional atlas-id (string) or nil
z_order = b.z_order or 0, -- render-sort key; default 0 z_order = b.z_order or 0, -- render-sort key; default 0
@@ -427,8 +447,9 @@ function M.spawn(rig, pos)
foot_state = foot_state, foot_state = foot_state,
bone_animated = {}, bone_animated = {},
last_move_time = 0, last_move_time = 0,
look_target = nil, look_target = nil, -- legacy single-target (deprecated but kept for backward-compat)
look_at_state = {}, -- {[bone_id] = { angle = X }} persistent rate-limit state look_targets = {}, -- v0.4.0: { [target_id] = { x, y } }
look_at_state = {}, -- {[bone_id] = { angle = X }} persistent slerp/rate-limit state
animations = {}, -- {[anim_id] = built_anim} animations = {}, -- {[anim_id] = built_anim}
playing = {}, -- {[anim_id] = { t = 0, loop, speed }} playing = {}, -- {[anim_id] = { t = 0, loop, speed }}
procedural = {}, -- {[name] = callback_fn} procedural = {}, -- {[name] = callback_fn}
@@ -498,12 +519,40 @@ end
-- ==================================================================== -- ====================================================================
-- Look-at + bone queries -- Look-at + bone queries
-- ==================================================================== -- ====================================================================
function M.set_look_target(handle, world_x, world_y) -- v0.4.0: set_look_target(handle, target_id, world_x, world_y) — multi-target API
all_puppets[handle].look_target = { x = world_x, y = world_y } -- Legacy 2-arg form: set_look_target(handle, world_x, world_y) — sets "default" target
function M.set_look_target(handle, target_id_or_x, world_x_or_y, world_y)
local p = all_puppets[handle]
if world_y ~= nil then
-- 3-arg form: (handle, target_id, x, y)
local target_id = target_id_or_x
p.look_targets[target_id] = { x = world_x_or_y, y = world_y }
-- Also set legacy look_target for backward-compat if target_id == "default"
if target_id == "default" then
p.look_target = { x = world_x_or_y, y = world_y }
end
else
-- 2-arg legacy form: (handle, x, y)
local world_x = target_id_or_x
local wy = world_x_or_y
p.look_targets["default"] = { x = world_x, y = wy }
p.look_target = { x = world_x, y = wy }
end
end end
function M.clear_look_target(handle) -- v0.4.0: clear_look_target(handle, target_id) — remove one target
all_puppets[handle].look_target = nil -- clear_look_target(handle) — clear all targets
function M.clear_look_target(handle, target_id)
local p = all_puppets[handle]
if target_id ~= nil then
p.look_targets[target_id] = nil
if target_id == "default" then
p.look_target = nil
end
else
p.look_targets = {}
p.look_target = nil
end
end end
function M.bone_angle(handle, bone_id) function M.bone_angle(handle, bone_id)
@@ -901,8 +950,18 @@ update_bone_recursive = function(p, b, dt)
p.bone_state[bid].rot = p.test_overrides[bid] p.bone_state[bid].rot = p.test_overrides[bid]
end end
-- 3.b Apply look-at constraint if this bone is marked look_at (scale-aware). -- 3.b Apply look-at constraint (v0.4.0: multi-target; scale-aware).
if b.look_at and p.look_target ~= nil then -- Bone must declare look_at_target (string) AND that target must be set in p.look_targets.
-- Legacy: look_at=true → look_at_target="default", still works with 2-arg set_look_target.
local look_target = nil
if b.look_at_target ~= nil then
look_target = p.look_targets[b.look_at_target]
-- Fall back to legacy look_target if this is the "default" target id
if look_target == nil and b.look_at_target == "default" then
look_target = p.look_target
end
end
if look_target ~= nil then
-- Compute bone-world-pos (scale-aware). -- Compute bone-world-pos (scale-aware).
local px, py, pa, psx, psy local px, py, pa, psx, psy
if b.parent then if b.parent then
@@ -915,14 +974,14 @@ update_bone_recursive = function(p, b, dt)
local lx, ly = b.rest.x, b.rest.y 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_x = px + (cos_a * lx * psx - sin_a * ly * psy)
local bone_world_y = py + (sin_a * lx * psx + cos_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 dx = look_target.x - bone_world_x
local dy = p.look_target.y - bone_world_y local dy = look_target.y - bone_world_y
-- Sprite-top convention: rot=0 means sprite top points up (-Y direction). -- Sprite-top convention: rot=0 means sprite top points up (-Y direction).
-- atan(dx, -dy): rot=0 when target is straight up, rot=pi/2 when target is to the right. -- atan(dx, -dy): rot=0 when target is straight up, rot=pi/2 when target is to the right.
local world_angle = math.atan(dx, -dy) local world_angle = math.atan(dx, -dy)
local target_local = world_angle - pa local target_local = world_angle - pa
-- Persistent state for rate-limit (carries angle across frames). -- Persistent state for slerp/rate-limit (carries angle across frames).
local state = p.look_at_state[bid] local state = p.look_at_state[bid]
if state == nil then if state == nil then
-- Initialize to bone's rest rotation so first frame advances from rest toward target. -- Initialize to bone's rest rotation so first frame advances from rest toward target.
@@ -930,8 +989,17 @@ update_bone_recursive = function(p, b, dt)
p.look_at_state[bid] = state p.look_at_state[bid] = state
end end
if b.look_at_speed then if b.look_at_slerp then
-- Rate-limit using persistent state (not current frame's bone_state.rot). -- v0.4.0: Slerp-style exponential approach. delta normalized to [-pi, pi].
local delta = target_local - state.angle
while delta > math.pi do delta = delta - 2 * math.pi end
while delta < -math.pi do delta = delta + 2 * math.pi end
local t = b.look_at_slerp * dt
if t > 1 then t = 1 end
if t < 0 then t = 0 end
state.angle = state.angle + delta * t
elseif b.look_at_speed then
-- Linear rate-limit (kept for backward-compat / linear-rotation use cases).
local delta = target_local - state.angle local delta = target_local - state.angle
while delta > math.pi do delta = delta - 2 * math.pi end while delta > math.pi do delta = delta - 2 * math.pi end
while delta < -math.pi do delta = delta + 2 * math.pi end while delta < -math.pi do delta = delta + 2 * math.pi end
@@ -987,6 +1055,8 @@ function M.render(handle)
if p == nil then return end if p == nil then return end
for _, b in ipairs(p.rig.bones_z_sorted) do for _, b in ipairs(p.rig.bones_z_sorted) do
-- v0.4.0: Skip virtual bones (they participate in compute_world but are not drawn).
if b.virtual then goto continue end
local ws = p.bone_state[b.id].world local ws = p.bone_state[b.id].world
if b.texture_handle then if b.texture_handle then
-- Cascaded world-scale (was: per-bone constant b.scale) -- Cascaded world-scale (was: per-bone constant b.scale)
@@ -1004,6 +1074,7 @@ function M.render(handle)
local color = engine.render.rgb(b.color[1], b.color[2], b.color[3]) 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) engine.render.draw_rect_rotated(ws.x, ws.y, 16, 4, ws.rot, color)
end end
::continue::
end end
end end

View File

@@ -1,6 +1,6 @@
{ {
"id": "lib-core.puppet", "id": "lib-core.puppet",
"version": "0.3.3", "version": "0.4.0",
"api_min": "0.1", "api_min": "0.1",
"deps": [] "deps": []
} }