feat(puppet): cascaded world-transform + multi-channel keyframes
- BoneState extended with pos/scl (per-frame override channels) and world.scl_x/y (cascaded world-scale) - compute_world() ports substrate puppet.c:692-718 cascade: local.pos multiplied by parent.world.scl before rotation+translation; world.scl = parent.world.scl * local.scl - build_animation parses rot/pos/scl channels; rejects legacy 'angle' - sample_animation interpolates each channel independently; single-side-keyframe holds the present value - look_at math now scale-aware (uses parent.world.scl in bone-world-pos) - build_rig rejects legacy rest.angle; requires rest.rot + optional rest.scl - bone-rig top-level 'scale' field removed; per-frame state replaces it 17 existing puppet-test assertions remain GREEN after fixture migration.
This commit is contained in:
271
init.lua
271
init.lua
@@ -1,6 +1,7 @@
|
||||
-- lib-core.puppet v0.2.0
|
||||
-- lib-core.puppet v0.3.0
|
||||
-- Skeletal animation: skeleton + bones + tracks + rest + keyframe
|
||||
-- + procedural + look-at constraint. No footplant in v0.1.
|
||||
-- + procedural + look-at constraint. Multi-channel animation (rot/pos/scl)
|
||||
-- + cascaded world-transform with scale propagation.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -28,12 +29,6 @@ local function clamp_angle_rad(a)
|
||||
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
|
||||
-- ====================================================================
|
||||
@@ -57,19 +52,35 @@ function M.build_rig(rig_table)
|
||||
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
|
||||
|
||||
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),
|
||||
rot = rest_rot,
|
||||
scl = { rest_scl[1], rest_scl[2] },
|
||||
},
|
||||
look_at = (b.look_at == true),
|
||||
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
|
||||
scale = b.scale or { 1.0, 1.0 }, -- [sx, sy]; negative = mirror
|
||||
anchor = b.anchor, -- optional [x, y] override of atlas-anchor; resolved in load_textures
|
||||
texture_handle = nil, -- populated by load_textures
|
||||
}
|
||||
@@ -144,8 +155,8 @@ function M.build_animation(anim_table, rig)
|
||||
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")
|
||||
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")
|
||||
@@ -154,7 +165,6 @@ function M.build_animation(anim_table, rig)
|
||||
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
|
||||
@@ -175,9 +185,29 @@ function M.build_animation(anim_table, rig)
|
||||
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 for internal storage
|
||||
}
|
||||
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
|
||||
@@ -193,10 +223,10 @@ function M.build_animation(anim_table, rig)
|
||||
end
|
||||
|
||||
-- ====================================================================
|
||||
-- Animation sampling
|
||||
-- Animation sampling (multi-channel: rot, pos, scl)
|
||||
-- ====================================================================
|
||||
function M.sample_animation(anim, t)
|
||||
-- Wrap t for loops.
|
||||
-- Wrap t for loops
|
||||
if anim.loop and t > anim.duration then
|
||||
t = t % anim.duration
|
||||
end
|
||||
@@ -204,7 +234,6 @@ function M.sample_animation(anim, t)
|
||||
if t > anim.duration then t = anim.duration end
|
||||
|
||||
local kfs = anim.keyframes
|
||||
-- Find segment [kf_i, kf_{i+1}] containing t.
|
||||
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
|
||||
@@ -213,34 +242,54 @@ function M.sample_animation(anim, t)
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
-- If t equals last keyframe's t (or beyond and not looping), snap to last.
|
||||
if t >= kfs[#kfs].t then
|
||||
prev_kf = kfs[#kfs]
|
||||
next_kf = kfs[#kfs]
|
||||
end
|
||||
|
||||
-- Linear interpolation per bone present in either keyframe.
|
||||
local frame = {}
|
||||
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 prev_v = prev_kf.bones[bid]
|
||||
local next_v = next_kf.bones[bid]
|
||||
if prev_v and next_v then
|
||||
frame[bid] = {
|
||||
angle = prev_v.angle * (1 - alpha) + next_v.angle * alpha,
|
||||
}
|
||||
elseif prev_v then
|
||||
frame[bid] = { angle = prev_v.angle }
|
||||
elseif next_v then
|
||||
frame[bid] = { angle = next_v.angle }
|
||||
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
|
||||
@@ -257,12 +306,19 @@ function M.spawn(rig, pos)
|
||||
local handle = next_handle
|
||||
next_handle = next_handle + 1
|
||||
|
||||
-- Per-bone live state (current angle, current world-pos cache).
|
||||
-- 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] = {
|
||||
angle = b.rest.angle,
|
||||
world = { x = 0, y = 0, angle = b.rest.angle },
|
||||
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
|
||||
|
||||
@@ -279,6 +335,10 @@ function M.spawn(rig, pos)
|
||||
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
|
||||
foot_state = {}, -- populated by locomotion at spawn (Task 3.7)
|
||||
bone_animated = {}, -- {[bone_id] = true} per-frame keyframe flag (Task 3.12)
|
||||
last_move_time = 0, -- engine.time.now() at last position-change (Task 3.10)
|
||||
}
|
||||
return handle
|
||||
end
|
||||
@@ -350,8 +410,8 @@ function M.clear_look_target(handle)
|
||||
end
|
||||
|
||||
function M.bone_angle(handle, bone_id)
|
||||
-- Returns the stored angle in radians (uniform across all channels).
|
||||
return all_puppets[handle].bone_state[bone_id].angle
|
||||
-- Returns the stored rot in radians.
|
||||
return all_puppets[handle].bone_state[bone_id].rot
|
||||
end
|
||||
|
||||
-- ====================================================================
|
||||
@@ -385,7 +445,10 @@ function M.write_bone(handle, bone_id, values)
|
||||
end
|
||||
end
|
||||
if values.angle ~= nil then
|
||||
p.bone_state[bone_id].angle = math.rad(values.angle) -- caller passes degrees
|
||||
p.bone_state[bone_id].rot = math.rad(values.angle) -- caller passes degrees (legacy)
|
||||
end
|
||||
if values.rot ~= nil then
|
||||
p.bone_state[bone_id].rot = math.rad(values.rot) -- caller passes degrees
|
||||
end
|
||||
end
|
||||
|
||||
@@ -401,11 +464,13 @@ function M.bone_world_transform(handle, bone_id)
|
||||
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.angle
|
||||
return ws.x, ws.y, ws.rot
|
||||
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)
|
||||
@@ -416,7 +481,32 @@ function M.write_bone_test_only(handle, bone_id, angle_rad)
|
||||
if p.bone_state[bone_id] == nil then
|
||||
error("puppet.write_bone_test_only: bone '" .. tostring(bone_id) .. "' not in rig")
|
||||
end
|
||||
p.bone_state[bone_id].angle = angle_rad
|
||||
p.test_overrides[bone_id] = angle_rad
|
||||
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
|
||||
|
||||
-- ====================================================================
|
||||
@@ -428,19 +518,30 @@ local update_bone_recursive
|
||||
-- Update pipeline (per-frame, depth-first traversal).
|
||||
--
|
||||
-- For each puppet:
|
||||
-- 1. Reset all bones to rest pose
|
||||
-- 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 for bone if on active track
|
||||
-- b. Apply look-at if bone has look_at=true (uses parent's already-cascaded world)
|
||||
-- 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 from parent.world + own rest + own angle
|
||||
-- d. Compute world-transform (cascaded with scale)
|
||||
-- ====================================================================
|
||||
function M.update(dt)
|
||||
for handle, p in pairs(all_puppets) do
|
||||
-- 1. Reset to rest pose.
|
||||
-- 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].angle = b.rest.angle
|
||||
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.
|
||||
@@ -468,7 +569,7 @@ end
|
||||
update_bone_recursive = function(p, b, dt)
|
||||
local bid = b.id
|
||||
|
||||
-- 3.a Sample keyframe for this bone from any playing anim on its track.
|
||||
-- 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
|
||||
@@ -476,34 +577,50 @@ update_bone_recursive = function(p, b, dt)
|
||||
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 and bone_kf.angle ~= nil then
|
||||
p.bone_state[bid].angle = bone_kf.angle -- already radians per sample_animation
|
||||
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.b Apply look-at constraint if this bone is marked look_at.
|
||||
-- 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
|
||||
-- Compute this bone's world-pos from parent's world + own rest offset
|
||||
-- (parent's world has been computed earlier in the traversal).
|
||||
local px, py, pa
|
||||
local px, py, pa, psx, psy
|
||||
if b.parent then
|
||||
local pw = p.bone_state[b.parent.id].world
|
||||
px, py, pa = pw.x, pw.y, pw.angle
|
||||
px, py, pa, psx, psy = pw.x, pw.y, pw.rot, pw.scl_x, pw.scl_y
|
||||
else
|
||||
px, py, pa = p.x, p.y, 0
|
||||
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 + lx * cos_a - ly * sin_a
|
||||
local bone_world_y = py + lx * sin_a + ly * cos_a
|
||||
-- Direction to target in world-space.
|
||||
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
|
||||
-- World-angle for look-at = atan2(dy, dx). Convert to local (subtract parent's accumulated).
|
||||
local world_angle = math.atan(dy, dx)
|
||||
p.bone_state[bid].angle = world_angle - pa
|
||||
p.bone_state[bid].rot = world_angle - pa
|
||||
end
|
||||
|
||||
-- 3.c Run procedural callbacks at root-visit (once per puppet, before world-transform compute).
|
||||
@@ -517,20 +634,8 @@ update_bone_recursive = function(p, b, dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- 3.d Compute this bone's world-transform from parent + own.
|
||||
local px, py, pa
|
||||
if b.parent then
|
||||
local pw = p.bone_state[b.parent.id].world
|
||||
px, py, pa = pw.x, pw.y, pw.angle
|
||||
else
|
||||
px, py, pa = p.x, p.y, 0
|
||||
end
|
||||
local cos_a, sin_a = math.cos(pa), math.sin(pa)
|
||||
local lx, ly = b.rest.x, b.rest.y
|
||||
local wx = px + lx * cos_a - ly * sin_a
|
||||
local wy = py + lx * sin_a + ly * cos_a
|
||||
local wa = pa + p.bone_state[bid].angle
|
||||
p.bone_state[bid].world = { x = wx, y = wy, angle = wa }
|
||||
-- 3.d Compute world-transform (cascaded with scale).
|
||||
compute_world(p, b)
|
||||
|
||||
-- Recurse to children.
|
||||
for _, child_src in ipairs(p.rig.bones) do
|
||||
@@ -545,6 +650,7 @@ 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)
|
||||
@@ -554,19 +660,20 @@ function M.render(handle)
|
||||
for _, b in ipairs(p.rig.bones_z_sorted) do
|
||||
local ws = p.bone_state[b.id].world
|
||||
if b.texture_handle then
|
||||
local sx, sy = b.scale[1], b.scale[2]
|
||||
local ax, ay = b.anchor and b.anchor[1] or 0,
|
||||
b.anchor and b.anchor[2] or 0
|
||||
-- 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.angle,
|
||||
ws.x, ws.y, ws.rot,
|
||||
sx, sy, ax, ay,
|
||||
0xFFFFFFFF
|
||||
)
|
||||
else
|
||||
-- Phase-1 fallback: colored rotated rect (16x4 px).
|
||||
-- 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.angle, color)
|
||||
engine.render.draw_rect_rotated(ws.x, ws.y, 16, 4, ws.rot, color)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user