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",
"duration": 0.8,
"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 +
-- 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 camera = require("lib-core.camera")
@@ -17,10 +19,11 @@ local state = {
bed_tex = nil,
bench_tex = nil,
backpack_tex = nil,
is_walking = false,
walk_phase = 0, -- 0..1 cycle position
walk_dir_rad = 0, -- world-angle of walk direction (sprite-top convention)
walk_dir_set = false, -- true after first move; legs orient even at idle once set
-- Subterrain-style controller state:
walk_anim_current = nil, -- "walk_fwd_lower" / "walk_back_lower" / etc. (nil = idle_lower)
lower_control_rot_deg = 0,
move_x = 0,
move_y = 0,
}
-- CI hook gating (mirrors Phase 1 convention).
@@ -33,23 +36,90 @@ local ci_last_state = nil
local ci_sprite_traced = false
local ci_assets_traced = false
local function set_walk_or_idle(moving)
local new_state = moving and "walk" or "idle"
if moving then
-- All 4 directional walk animation ids.
local WALK_ANIMS = {"walk_fwd_lower", "walk_back_lower", "walk_left_lower", "walk_right_lower"}
-- 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
puppet.stop(state.player, "idle")
puppet.play(state.player, "walk_upper", { loop = true })
end
else
if not puppet.is_playing(state.player, "idle") then
puppet.stop(state.player, "walk_upper")
puppet.play(state.player, "idle", { loop = true })
state.walk_anim_current = anim_id
if CI_FRAME_PERF and ci_last_state ~= "walk" then
engine.print("vagrant: state=walk")
ci_last_state = "walk"
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
function M.init(ctx)
@@ -60,49 +130,33 @@ function M.init(ctx)
maps.set_current(state.map)
maps.load_textures(aliases)
-- Puppet rig + sprite textures + animations.
local rig = puppet.load_rig("rigs/humanoid.rig.json", aliases)
-- Puppet rig + sprite textures.
local rig = puppet.load_rig("rigs/humanoid.rig.json", aliases)
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.
-- Replaces keyframe walk.anim + idle_lower.anim. Runs every frame in
-- update_bone_recursive's procedural callback step.
puppet.set_procedural(state.player, "walk_cycle", function(handle, dt)
if state.is_walking then
-- Advance phase. 0.8s full cycle (1/0.8 = 1.25 Hz, matches substrate walk_fwd_lower).
state.walk_phase = (state.walk_phase + dt * 1.25) % 1.0
local phase_rad = state.walk_phase * 2 * math.pi
local cycle_l = math.sin(phase_rad)
local cycle_r = -cycle_l
local walk_dir_deg = math.deg(state.walk_dir_rad)
-- Leg-rotation orients along walk direction (root stays at 0; legs rotate directly)
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} })
-- Foot: scale follows leg-extension absolute value; pos along leg axis
puppet.write_bone(handle, "foot_l", {
scl = { 1, math.abs(cycle_l) },
pos = { 0, cycle_l > 0 and 25 or 0 }
})
puppet.write_bone(handle, "foot_r", {
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
-- Load all animations.
local walk_fwd = puppet.load_animation("animations/walk_fwd_lower.anim.json", rig)
local walk_back = puppet.load_animation("animations/walk_back_lower.anim.json", rig)
local walk_left = puppet.load_animation("animations/walk_left_lower.anim.json", rig)
local walk_right = puppet.load_animation("animations/walk_right_lower.anim.json", rig)
local walk_upper = puppet.load_animation("animations/walk_upper.anim.json", rig)
local idle = puppet.load_animation("animations/idle.anim.json", rig)
local idle_lower = puppet.load_animation("animations/idle_lower.anim.json", rig)
for _, a in ipairs({walk_fwd, walk_back, walk_left, walk_right, walk_upper, idle, idle_lower}) do
puppet.register_animation(state.player, a)
end
-- Start in idle state.
puppet.play(state.player, "idle", { loop = true })
puppet.play(state.player, "idle_lower", { loop = true })
-- 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, "lower_control", { rot = state.lower_control_rot_deg })
end)
-- Furniture textures (preload to avoid first-frame load stutter).
-- Furniture textures.
local world_lib = aliases.world
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")
@@ -133,21 +187,20 @@ function M.update(ctx, dt)
return
end
-- WASD input → normalized move vector.
local dx, dy = 0, 0
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_up") then dy = dy - 1 end
if input.is_action_down("walk_down") then dy = dy + 1 end
local moving = (dx ~= 0 or dy ~= 0)
-- CI_DRIVE: synthetic scripted move (preserve existing P0-S25b semantics).
if CI_DRIVE then
ci_drive_frame = ci_drive_frame + 1
if ci_drive_frame > 20 and ci_drive_frame <= 40 then
dx = 1; dy = 0
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)
puppet.move_to(state.player,
pos.x + dx * state.player_speed * dt, pos.y)
@@ -156,23 +209,33 @@ function M.update(ctx, dt)
end
end
-- Move puppet.
if moving and not CI_DRIVE then
local len = math.sqrt(dx * dx + dy * dy)
-- Update walk_dir_rad (sprite-top convention: angle=atan(dx,-dy)).
state.walk_dir_rad = math.atan(dx / len, -dy / len)
state.walk_dir_set = true
state.move_x = dx / len
state.move_y = dy / len
local pos = puppet.position(state.player)
puppet.move_to(state.player,
pos.x + dx / len * state.player_speed * dt,
pos.y + dy / len * state.player_speed * dt)
pos.x + state.move_x * 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
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 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.
local pos = puppet.position(state.player)
@@ -194,7 +257,7 @@ end
function M.draw(ctx)
camera.begin()
-- Sprite-tilemap (maps.draw_map with sprite-mode + tile_rotations).
-- Sprite-tilemap.
r_lib.draw_map()
-- Hardcoded furniture placements (test-chamber spirit).
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
-- Engine lifecycle globals (engine looks up by name; module-table is bridged).
function init(ctx) M.init(ctx) end
function update(ctx, dt) M.update(ctx, dt) end
function render(ctx) M.draw(ctx) end
function init(ctx) M.init(ctx) end
function update(ctx, dt) M.update(ctx, dt) end
function render(ctx) M.draw(ctx) end
return M

View File

@@ -2,18 +2,19 @@
"id": "humanoid",
"asset_pack": "player",
"bones": [
{ "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": "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": "shoulders", "parent": "body", "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": 5, "texture": "shoulder_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": "upper_arm_r", "parent": "shoulders", "rest": {"x": 20, "y": -4, "rot": 0}, "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": "lower_arm_r", "parent": "upper_arm_r","rest": {"x": 13, "y": -17, "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": "leg_r", "parent": "root", "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": "foot_r", "parent": "leg_r", "rest": {"x": 0, "y": 26, "rot": 0, "scl": [ 1, -1]}, "z_order": 0, "texture": "foot_001" }
{ "id": "root", "parent": null, "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": -1 },
{ "id": "lower_control", "parent": "root", "rest": {"x": 0, "y": 0, "rot": 0}, "virtual": true },
{ "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": "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": "shoulders", "parent": "body", "rest": {"x": 0, "y": 0, "rot": 0}, "z_order": 5, "texture": "shoulder_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": "upper_arm_r", "parent": "shoulders", "rest": {"x": 20, "y": -4, "rot": 0}, "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": "lower_arm_r", "parent": "upper_arm_r", "rest": {"x": 13, "y": -17, "rot": 0}, "z_order": 2, "texture": "lower_arm_001" },
{ "id": "leg_l", "parent": "lower_control", "rest": {"x": -9, "y": 1, "rot": 0, "scl": [-1, -1]}, "z_order": 1, "texture": "leg_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_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": [
{ "id": "lower", "bones": ["leg_l", "leg_r", "foot_l", "foot_r"] },