feat(vagrant-skeleton): Subterrain-style lower-body control model

Add lower_control virtual bone (reparents legs for independent orientation).
Update look_at fields: body uses "soft" target with slerp=6, head uses
"aim" target with instant snap and ±60° clamp. Rename walk.anim.json to
walk_fwd_lower.anim.json and add walk_back_lower, walk_left_lower,
walk_right_lower anims ported from substrate. Port HandleAnimationLower:
signed-angle sector selection (±45°/±135°) drives 4-directional walk anim
choice; wedge clamp sets lower_control rotation. Separate aim/soft look
targets both track raw mouse for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Axel Meyer
2026-05-18 21:19:08 +02:00
parent 926cb9da29
commit 794287fd10
6 changed files with 234 additions and 86 deletions

View File

@@ -0,0 +1,28 @@
{
"id": "walk_back_lower",
"track": "lower",
"duration": 0.8,
"loop": true,
"keyframes": [
{ "t": 0.0,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
},
{ "t": 0.2,
"leg_l": {"scl": [-1, -1]}, "leg_r": {"scl": [ 1, 1]},
"foot_l": {"pos": [-0.044, 25.47]}, "foot_r": {"pos": [0.249, 25.85]}
},
{ "t": 0.4,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
},
{ "t": 0.6,
"leg_l": {"scl": [-1, 1]}, "leg_r": {"scl": [ 1, -1]},
"foot_l": {"pos": [ 0.044, -25.47]}, "foot_r": {"pos": [-0.249, -25.85]}
},
{ "t": 0.8,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
}
]
}

View File

@@ -1,5 +1,5 @@
{ {
"id": "walk", "id": "walk_fwd_lower",
"track": "lower", "track": "lower",
"duration": 0.8, "duration": 0.8,
"loop": true, "loop": true,

View File

@@ -0,0 +1,28 @@
{
"id": "walk_left_lower",
"track": "lower",
"duration": 0.8,
"loop": true,
"keyframes": [
{ "t": 0.0,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
},
{ "t": 0.2,
"leg_l": {"scl": [-1, -1]}, "leg_r": {"scl": [ 1, 1]},
"foot_l": {"pos": [-0.044, 25.47]}, "foot_r": {"pos": [0.249, 25.85]}
},
{ "t": 0.4,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
},
{ "t": 0.6,
"leg_l": {"scl": [-1, 1]}, "leg_r": {"scl": [ 1, -1]},
"foot_l": {"pos": [ 0.044, -25.47]}, "foot_r": {"pos": [-0.249, -25.85]}
},
{ "t": 0.8,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
}
]
}

View File

@@ -0,0 +1,28 @@
{
"id": "walk_right_lower",
"track": "lower",
"duration": 0.8,
"loop": true,
"keyframes": [
{ "t": 0.0,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
},
{ "t": 0.2,
"leg_l": {"scl": [-1, -1]}, "leg_r": {"scl": [ 1, 1]},
"foot_l": {"pos": [-0.044, 25.47]}, "foot_r": {"pos": [0.249, 25.85]}
},
{ "t": 0.4,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
},
{ "t": 0.6,
"leg_l": {"scl": [-1, 1]}, "leg_r": {"scl": [ 1, -1]},
"foot_l": {"pos": [ 0.044, -25.47]}, "foot_r": {"pos": [-0.249, -25.85]}
},
{ "t": 0.8,
"leg_l": {"scl": [-1, 0]}, "leg_r": {"scl": [ 1, 0]},
"foot_l": {"pos": [0, 0]}, "foot_r": {"pos": [0, 0]}
}
]
}

209
init.lua
View File

@@ -1,6 +1,8 @@
-- sporel-module-vagrant-skeleton v0.3.2 -- sporel-module-vagrant-skeleton v0.4.0
-- Sprite-mode test-chamber: 13-bone humanoid puppet + sprite-tilemap + -- Sprite-mode test-chamber: 13-bone humanoid puppet + sprite-tilemap +
-- hardcoded furniture. F.vagrant Tier-1 + Sprite-Upgrade-Bridge state. -- hardcoded furniture. Subterrain-style puppet control model:
-- multi-look-target (aim/soft), slerp body rotation, virtual lower_control bone,
-- 4 directional walk animations, HandleAnimationLower port.
local input = require("lib-core.input") local input = require("lib-core.input")
local camera = require("lib-core.camera") local camera = require("lib-core.camera")
@@ -17,10 +19,11 @@ local state = {
bed_tex = nil, bed_tex = nil,
bench_tex = nil, bench_tex = nil,
backpack_tex = nil, backpack_tex = nil,
is_walking = false, -- Subterrain-style controller state:
walk_phase = 0, -- 0..1 cycle position walk_anim_current = nil, -- "walk_fwd_lower" / "walk_back_lower" / etc. (nil = idle_lower)
walk_dir_rad = 0, -- world-angle of walk direction (sprite-top convention) lower_control_rot_deg = 0,
walk_dir_set = false, -- true after first move; legs orient even at idle once set move_x = 0,
move_y = 0,
} }
-- CI hook gating (mirrors Phase 1 convention). -- CI hook gating (mirrors Phase 1 convention).
@@ -33,23 +36,90 @@ local ci_last_state = nil
local ci_sprite_traced = false local ci_sprite_traced = false
local ci_assets_traced = false local ci_assets_traced = false
local function set_walk_or_idle(moving) -- All 4 directional walk animation ids.
local new_state = moving and "walk" or "idle" local WALK_ANIMS = {"walk_fwd_lower", "walk_back_lower", "walk_left_lower", "walk_right_lower"}
if moving then
-- Signed angle between two 2D vectors (in degrees, range [-180, 180]).
-- Returns the signed angle from v1 to v2, positive = counter-clockwise (left).
local function signed_angle_2d(v1x, v1y, v2x, v2y)
local cross = v1x * v2y - v1y * v2x
local dot = v1x * v2x + v1y * v2y
return math.deg(math.atan(cross, dot))
end
-- Given signed angle num_deg from body-forward to move-direction, pick the
-- appropriate walk animation and compute the lower_control offset rotation (degrees).
-- Substrate convention: body-forward = sprite top = -Y at rot=0.
local function pick_walk_anim(num_deg)
if num_deg >= -45 and num_deg <= 45 then
-- Movement roughly aligns with body-forward = backpedaling
return "walk_back_lower", math.max(-30, math.min(30, num_deg))
elseif num_deg > 45 and num_deg < 135 then
-- Movement ~90° left of body-forward = strafe left
return "walk_left_lower", math.max(60, math.min(120, num_deg)) - 90
elseif num_deg > -135 and num_deg < -45 then
-- Movement ~90° right of body-forward = strafe right
return "walk_right_lower", math.max(-120, math.min(-60, num_deg)) + 90
elseif num_deg > 0 then
-- Movement roughly opposite body-forward, positive side = walk forward
return "walk_fwd_lower", -(30 - (math.max(150, math.min(180, num_deg)) - 150))
else
-- Movement roughly opposite body-forward, negative side = walk forward
return "walk_fwd_lower", 30 + (math.max(-180, math.min(-150, num_deg)) + 150)
end
end
-- HandleAnimationLower port: chooses walk anim based on signed angle between
-- body-forward and move-direction. Manages idle_lower / walk_* transitions.
local function update_lower_anim(moving, move_x, move_y)
if not moving then
-- Idle: stop all walk anims, resume idle_lower.
if state.walk_anim_current ~= nil then
for _, a in ipairs(WALK_ANIMS) do
puppet.stop(state.player, a)
end
state.walk_anim_current = nil
puppet.play(state.player, "idle_lower", { loop = true })
puppet.stop(state.player, "walk_upper")
if not puppet.is_playing(state.player, "idle") then
puppet.play(state.player, "idle", { loop = true })
end
if CI_FRAME_PERF and ci_last_state ~= "idle" then
engine.print("vagrant: state=idle")
ci_last_state = "idle"
end
end
state.lower_control_rot_deg = 0
return
end
-- Walking: read body's previous-frame world.rot for signed angle computation.
local _, _, body_rot = puppet.bone_world_transform(state.player, "body")
-- Body forward direction (sprite-top-up convention): forward = -Y at rot=0.
-- In world coords: fwd_x = sin(rot), fwd_y = -cos(rot).
local fwd_x = math.sin(body_rot)
local fwd_y = -math.cos(body_rot)
local num = signed_angle_2d(fwd_x, fwd_y, move_x, move_y)
local anim_id, wedge_deg = pick_walk_anim(num)
state.lower_control_rot_deg = wedge_deg
if state.walk_anim_current ~= anim_id then
-- Swap to new walk anim.
for _, a in ipairs(WALK_ANIMS) do
if a ~= anim_id then puppet.stop(state.player, a) end
end
puppet.stop(state.player, "idle_lower")
puppet.stop(state.player, "idle")
puppet.play(state.player, anim_id, { loop = true })
if not puppet.is_playing(state.player, "walk_upper") then if not puppet.is_playing(state.player, "walk_upper") then
puppet.stop(state.player, "idle")
puppet.play(state.player, "walk_upper", { loop = true }) puppet.play(state.player, "walk_upper", { loop = true })
end end
else state.walk_anim_current = anim_id
if not puppet.is_playing(state.player, "idle") then if CI_FRAME_PERF and ci_last_state ~= "walk" then
puppet.stop(state.player, "walk_upper") engine.print("vagrant: state=walk")
puppet.play(state.player, "idle", { loop = true }) ci_last_state = "walk"
end end
end end
if CI_FRAME_PERF and new_state ~= ci_last_state then
engine.print("vagrant: state=" .. new_state)
ci_last_state = new_state
end
end end
function M.init(ctx) function M.init(ctx)
@@ -60,49 +130,33 @@ function M.init(ctx)
maps.set_current(state.map) maps.set_current(state.map)
maps.load_textures(aliases) maps.load_textures(aliases)
-- Puppet rig + sprite textures + animations. -- Puppet rig + sprite textures.
local rig = puppet.load_rig("rigs/humanoid.rig.json", aliases) local rig = puppet.load_rig("rigs/humanoid.rig.json", aliases)
state.player = puppet.spawn(rig, { x = 320, y = 240 }) state.player = puppet.spawn(rig, { x = 320, y = 240 })
local walk_upper = puppet.load_animation("animations/walk_upper.anim.json", rig)
local idle = puppet.load_animation("animations/idle.anim.json", rig)
puppet.register_animation(state.player, walk_upper)
puppet.register_animation(state.player, idle)
puppet.play(state.player, "idle", { loop = true })
-- Procedural lower-body: leg-cycle driven per-frame by walk-direction + phase. -- Load all animations.
-- Replaces keyframe walk.anim + idle_lower.anim. Runs every frame in local walk_fwd = puppet.load_animation("animations/walk_fwd_lower.anim.json", rig)
-- update_bone_recursive's procedural callback step. local walk_back = puppet.load_animation("animations/walk_back_lower.anim.json", rig)
puppet.set_procedural(state.player, "walk_cycle", function(handle, dt) local walk_left = puppet.load_animation("animations/walk_left_lower.anim.json", rig)
if state.is_walking then local walk_right = puppet.load_animation("animations/walk_right_lower.anim.json", rig)
-- Advance phase. 0.8s full cycle (1/0.8 = 1.25 Hz, matches substrate walk_fwd_lower). local walk_upper = puppet.load_animation("animations/walk_upper.anim.json", rig)
state.walk_phase = (state.walk_phase + dt * 1.25) % 1.0 local idle = puppet.load_animation("animations/idle.anim.json", rig)
local phase_rad = state.walk_phase * 2 * math.pi local idle_lower = puppet.load_animation("animations/idle_lower.anim.json", rig)
local cycle_l = math.sin(phase_rad)
local cycle_r = -cycle_l for _, a in ipairs({walk_fwd, walk_back, walk_left, walk_right, walk_upper, idle, idle_lower}) do
local walk_dir_deg = math.deg(state.walk_dir_rad) puppet.register_animation(state.player, a)
-- Leg-rotation orients along walk direction (root stays at 0; legs rotate directly) end
puppet.write_bone(handle, "leg_l", { rot = walk_dir_deg, scl = {-1, cycle_l} })
puppet.write_bone(handle, "leg_r", { rot = walk_dir_deg, scl = { 1, cycle_r} }) -- Start in idle state.
-- Foot: scale follows leg-extension absolute value; pos along leg axis puppet.play(state.player, "idle", { loop = true })
puppet.write_bone(handle, "foot_l", { puppet.play(state.player, "idle_lower", { loop = true })
scl = { 1, math.abs(cycle_l) },
pos = { 0, cycle_l > 0 and 25 or 0 } -- Procedural lower_control: applies the per-frame wedge rotation (set by update_lower_anim).
}) puppet.set_procedural(state.player, "lower_control", function(handle, dt)
puppet.write_bone(handle, "foot_r", { puppet.write_bone(handle, "lower_control", { rot = state.lower_control_rot_deg })
scl = { 1, math.abs(cycle_r) },
pos = { 0, cycle_r > 0 and 25 or 0 }
})
else
-- Idle: legs collapsed (scl=0), feet collapsed and centered
local walk_dir_deg = state.walk_dir_set and math.deg(state.walk_dir_rad) or 0
puppet.write_bone(handle, "leg_l", { rot = walk_dir_deg, scl = {-1, 0} })
puppet.write_bone(handle, "leg_r", { rot = walk_dir_deg, scl = { 1, 0} })
puppet.write_bone(handle, "foot_l", { scl = { 1, 0 }, pos = { 0, 0 } })
puppet.write_bone(handle, "foot_r", { scl = { 1, 0 }, pos = { 0, 0 } })
end
end) end)
-- Furniture textures (preload to avoid first-frame load stutter). -- Furniture textures.
local world_lib = aliases.world local world_lib = aliases.world
state.bed_tex = engine.asset.load_texture(world_lib .. "/assets/world/bed_001.png") state.bed_tex = engine.asset.load_texture(world_lib .. "/assets/world/bed_001.png")
state.bench_tex = engine.asset.load_texture(world_lib .. "/assets/world/bench_001.png") state.bench_tex = engine.asset.load_texture(world_lib .. "/assets/world/bench_001.png")
@@ -133,21 +187,20 @@ function M.update(ctx, dt)
return return
end end
-- WASD input → normalized move vector.
local dx, dy = 0, 0 local dx, dy = 0, 0
if input.is_action_down("walk_left") then dx = dx - 1 end if input.is_action_down("walk_left") then dx = dx - 1 end
if input.is_action_down("walk_right") then dx = dx + 1 end if input.is_action_down("walk_right") then dx = dx + 1 end
if input.is_action_down("walk_up") then dy = dy - 1 end if input.is_action_down("walk_up") then dy = dy - 1 end
if input.is_action_down("walk_down") then dy = dy + 1 end if input.is_action_down("walk_down") then dy = dy + 1 end
local moving = (dx ~= 0 or dy ~= 0) local moving = (dx ~= 0 or dy ~= 0)
-- CI_DRIVE: synthetic scripted move (preserve existing P0-S25b semantics).
if CI_DRIVE then if CI_DRIVE then
ci_drive_frame = ci_drive_frame + 1 ci_drive_frame = ci_drive_frame + 1
if ci_drive_frame > 20 and ci_drive_frame <= 40 then if ci_drive_frame > 20 and ci_drive_frame <= 40 then
dx = 1; dy = 0 dx = 1; dy = 0
moving = true moving = true
state.walk_dir_rad = math.atan(dx, -dy) -- atan(1, 0) = pi/2 = 90deg
state.walk_dir_set = true
local pos = puppet.position(state.player) local pos = puppet.position(state.player)
puppet.move_to(state.player, puppet.move_to(state.player,
pos.x + dx * state.player_speed * dt, pos.y) pos.x + dx * state.player_speed * dt, pos.y)
@@ -156,23 +209,33 @@ function M.update(ctx, dt)
end end
end end
-- Move puppet.
if moving and not CI_DRIVE then if moving and not CI_DRIVE then
local len = math.sqrt(dx * dx + dy * dy) local len = math.sqrt(dx * dx + dy * dy)
-- Update walk_dir_rad (sprite-top convention: angle=atan(dx,-dy)). state.move_x = dx / len
state.walk_dir_rad = math.atan(dx / len, -dy / len) state.move_y = dy / len
state.walk_dir_set = true
local pos = puppet.position(state.player) local pos = puppet.position(state.player)
puppet.move_to(state.player, puppet.move_to(state.player,
pos.x + dx / len * state.player_speed * dt, pos.x + state.move_x * state.player_speed * dt,
pos.y + dy / len * state.player_speed * dt) pos.y + state.move_y * state.player_speed * dt)
elseif not moving then
state.move_x = 0
state.move_y = 0
end end
state.is_walking = moving
set_walk_or_idle(moving)
-- Headtrack: head looks at mouse world-position. -- CI_DRIVE move_x/move_y for anim selection.
if CI_DRIVE and moving then
state.move_x = dx
state.move_y = dy
end
update_lower_anim(moving, state.move_x, state.move_y)
-- Look-targets: head tracks raw mouse (aim), body tracks same for now (soft).
local mx, my = engine.input.get_mouse_pos() local mx, my = engine.input.get_mouse_pos()
local wx, wy = camera.screen_to_world(mx, my) local wx, wy = camera.screen_to_world(mx, my)
puppet.set_look_target(state.player, wx, wy) puppet.set_look_target(state.player, "aim", wx, wy)
puppet.set_look_target(state.player, "soft", wx, wy)
-- Camera follows puppet position each frame. -- Camera follows puppet position each frame.
local pos = puppet.position(state.player) local pos = puppet.position(state.player)
@@ -194,7 +257,7 @@ end
function M.draw(ctx) function M.draw(ctx)
camera.begin() camera.begin()
-- Sprite-tilemap (maps.draw_map with sprite-mode + tile_rotations). -- Sprite-tilemap.
r_lib.draw_map() r_lib.draw_map()
-- Hardcoded furniture placements (test-chamber spirit). -- Hardcoded furniture placements (test-chamber spirit).
engine.render.draw_sprite_transform(state.bed_tex, 200, 150, 0, 1, 1, 32, 32, 0xFFFFFFFF) engine.render.draw_sprite_transform(state.bed_tex, 200, 150, 0, 1, 1, 32, 32, 0xFFFFFFFF)
@@ -213,8 +276,8 @@ function M.draw(ctx)
end end
-- Engine lifecycle globals (engine looks up by name; module-table is bridged). -- Engine lifecycle globals (engine looks up by name; module-table is bridged).
function init(ctx) M.init(ctx) end function init(ctx) M.init(ctx) end
function update(ctx, dt) M.update(ctx, dt) end function update(ctx, dt) M.update(ctx, dt) end
function render(ctx) M.draw(ctx) end function render(ctx) M.draw(ctx) end
return M return M

View File

@@ -2,18 +2,19 @@
"id": "humanoid", "id": "humanoid",
"asset_pack": "player", "asset_pack": "player",
"bones": [ "bones": [
{ "id": "root", "parent": null, "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": -1 }, { "id": "root", "parent": null, "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": -1 },
{ "id": "body", "parent": "root", "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": 2, "texture": "body_001", "look_at": true, "look_at_speed": 180 }, { "id": "lower_control", "parent": "root", "rest": {"x": 0, "y": 0, "rot": 0}, "virtual": true },
{ "id": "head", "parent": "body", "rest": {"x": 0, "y": -3, "rot": 0, "rot_min": -90, "rot_max": 90}, "z_order": 6, "texture": "head_001", "look_at": true, "sprite_rot": -90 }, { "id": "body", "parent": "root", "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": 2, "texture": "body_001", "look_at": "soft", "look_at_slerp": 6 },
{ "id": "shoulders", "parent": "body", "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": 5, "texture": "shoulder_001" }, { "id": "head", "parent": "body", "rest": {"x": 0, "y": -3, "rot": 0, "rot_min": -60, "rot_max": 60}, "z_order": 6, "texture": "head_001", "look_at": "aim", "sprite_rot": -90 },
{ "id": "upper_arm_l", "parent": "shoulders", "rest": {"x": -19, "y": -3, "rot": 0, "scl": [-1, 1]}, "z_order": 3, "texture": "upper_arm_001" }, { "id": "shoulders", "parent": "body", "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": 5, "texture": "shoulder_001" },
{ "id": "upper_arm_r", "parent": "shoulders", "rest": {"x": 20, "y": -4, "rot": 0}, "z_order": 3, "texture": "upper_arm_001" }, { "id": "upper_arm_l", "parent": "shoulders", "rest": {"x": -19, "y": -3, "rot": 0, "scl": [-1, 1]}, "z_order": 3, "texture": "upper_arm_001" },
{ "id": "lower_arm_l", "parent": "upper_arm_l","rest": {"x": 14, "y": -18, "rot": 0}, "z_order": 2, "texture": "lower_arm_001" }, { "id": "upper_arm_r", "parent": "shoulders", "rest": {"x": 20, "y": -4, "rot": 0}, "z_order": 3, "texture": "upper_arm_001" },
{ "id": "lower_arm_r", "parent": "upper_arm_r","rest": {"x": 13, "y": -17, "rot": 0}, "z_order": 2, "texture": "lower_arm_001" }, { "id": "lower_arm_l", "parent": "upper_arm_l", "rest": {"x": 14, "y": -18, "rot": 0}, "z_order": 2, "texture": "lower_arm_001" },
{ "id": "leg_l", "parent": "root", "rest": {"x": -9, "y": 1, "rot": 0, "scl": [-1, -1]}, "z_order": 1, "texture": "leg_001" }, { "id": "lower_arm_r", "parent": "upper_arm_r", "rest": {"x": 13, "y": -17, "rot": 0}, "z_order": 2, "texture": "lower_arm_001" },
{ "id": "leg_r", "parent": "root", "rest": {"x": 9, "y": 1, "rot": 0, "scl": [ 1, -1]}, "z_order": 1, "texture": "leg_001" }, { "id": "leg_l", "parent": "lower_control", "rest": {"x": -9, "y": 1, "rot": 0, "scl": [-1, -1]}, "z_order": 1, "texture": "leg_001" },
{ "id": "foot_l", "parent": "leg_l", "rest": {"x": 0, "y": 25, "rot": 0, "scl": [ 1, -1]}, "z_order": 0, "texture": "foot_001" }, { "id": "leg_r", "parent": "lower_control", "rest": {"x": 9, "y": 1, "rot": 0, "scl": [ 1, -1]}, "z_order": 1, "texture": "leg_001" },
{ "id": "foot_r", "parent": "leg_r", "rest": {"x": 0, "y": 26, "rot": 0, "scl": [ 1, -1]}, "z_order": 0, "texture": "foot_001" } { "id": "foot_l", "parent": "leg_l", "rest": {"x": 0, "y": 25, "rot": 0, "scl": [ 1, -1]}, "z_order": 0, "texture": "foot_001" },
{ "id": "foot_r", "parent": "leg_r", "rest": {"x": 0, "y": 26, "rot": 0, "scl": [ 1, -1]}, "z_order": 0, "texture": "foot_001" }
], ],
"tracks": [ "tracks": [
{ "id": "lower", "bones": ["leg_l", "leg_r", "foot_l", "foot_r"] }, { "id": "lower", "bones": ["leg_l", "leg_r", "foot_l", "foot_r"] },