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:
101
init.lua
101
init.lua
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user