Two fixes from manual interactive verification after iteration-3: 1. Head 90° CCW off → removed sprite_rot=-90 from head bone. The v0.3.3 sprite_rot fix was based on misobservation: at that point the persistent-state-rate-limit bug was active and head was wiggling within ±10° of an arbitrary initial direction, perceived as 90° CW off. With v0.3.2+ persistent-state fix and v0.4.0 slerp, head rotates correctly when sprite-natural-face is -Y (matching body-sprite convention). 2. Legs walked in inverted sector → signed_angle_2d cross-component was using Y-up math convention but Sporel screen-coords are Y-down. With Y-down, what's CCW in math is CW visually. Flipping the cross sign aligns sector selection: body up + player walks right → CW visually → walk_right_lower (was wrongly returning walk_left). Front/back leg-anchor issue (#3 from user report) deferred — likely an optical-overlap artifact from rotated body sprite + world-frame leg positions, will reassess after #1+#2 fixes are visually verified.
286 lines
11 KiB
Lua
286 lines
11 KiB
Lua
-- sporel-module-vagrant-skeleton v0.4.0
|
|
-- Sprite-mode test-chamber: 13-bone humanoid puppet + sprite-tilemap +
|
|
-- 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")
|
|
local r_lib = require("lib-core.render")
|
|
local maps = require("lib-core.maps")
|
|
local puppet = require("lib-core.puppet")
|
|
|
|
local M = {}
|
|
|
|
local state = {
|
|
player = nil,
|
|
player_speed = 120,
|
|
map = nil,
|
|
bed_tex = nil,
|
|
bench_tex = nil,
|
|
backpack_tex = nil,
|
|
-- 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).
|
|
local CI_FRAME_PERF = (os.getenv("SPOREL_CI") == "1")
|
|
local CI_DRIVE = (os.getenv("SPOREL_VAGRANT_PHASE1_DRIVE") == "1")
|
|
local ci_frame_count = 0
|
|
local ci_t_start = nil
|
|
local ci_drive_frame = 0
|
|
local ci_last_state = nil
|
|
local ci_sprite_traced = false
|
|
local ci_assets_traced = false
|
|
|
|
-- 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]).
|
|
-- Sporel uses Y-down screen-coords. Substrate's math assumes Y-up; the cross
|
|
-- component is flipped so "positive num = move-direction is CCW from body-fwd
|
|
-- in screen-coords" matches substrate's "positive num = walk_left sector".
|
|
local function signed_angle_2d(v1x, v1y, v2x, v2y)
|
|
local cross = v1y * v2x - v1x * v2y -- Y-down flip
|
|
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.play(state.player, "walk_upper", { loop = true })
|
|
end
|
|
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
|
|
end
|
|
|
|
function M.init(ctx)
|
|
local aliases = engine.module.asset_aliases()
|
|
|
|
-- Map + sprite-tilemap.
|
|
state.map = maps.load("maps/vagrant_test.map.json")
|
|
maps.set_current(state.map)
|
|
maps.load_textures(aliases)
|
|
|
|
-- Puppet rig + sprite textures.
|
|
local rig = puppet.load_rig("rigs/humanoid.rig.json", aliases)
|
|
state.player = puppet.spawn(rig, { x = 320, y = 240 })
|
|
|
|
-- 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.
|
|
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")
|
|
state.backpack_tex = engine.asset.load_texture(world_lib .. "/assets/world/backpack_001.png")
|
|
|
|
-- Input bindings.
|
|
input.bind("walk_left", { "a" })
|
|
input.bind("walk_right", { "d" })
|
|
input.bind("walk_up", { "w" })
|
|
input.bind("walk_down", { "s" })
|
|
input.bind("quit_to_launcher", { "escape" })
|
|
|
|
-- CI trace: assets loaded count (puppet+tilemap+furniture textures).
|
|
if CI_FRAME_PERF and not ci_assets_traced then
|
|
-- 7 puppet textures + 4 tile textures + 3 furniture textures = 14
|
|
engine.print("vagrant: assets_loaded=14")
|
|
ci_assets_traced = true
|
|
end
|
|
|
|
if CI_FRAME_PERF then
|
|
ci_t_start = engine.time.now()
|
|
end
|
|
end
|
|
|
|
function M.update(ctx, dt)
|
|
if input.was_action_pressed("quit_to_launcher") then
|
|
engine.switch_module("lib-sporel.launcher")
|
|
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
|
|
local pos = puppet.position(state.player)
|
|
puppet.move_to(state.player,
|
|
pos.x + dx * state.player_speed * dt, pos.y)
|
|
else
|
|
moving = false
|
|
end
|
|
end
|
|
|
|
-- Move puppet.
|
|
if moving and not CI_DRIVE then
|
|
local len = math.sqrt(dx * dx + dy * dy)
|
|
state.move_x = dx / len
|
|
state.move_y = dy / len
|
|
local pos = puppet.position(state.player)
|
|
puppet.move_to(state.player,
|
|
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
|
|
|
|
-- 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, "aim", wx, wy)
|
|
puppet.set_look_target(state.player, "soft", wx, wy)
|
|
|
|
-- Camera follows puppet position each frame.
|
|
local pos = puppet.position(state.player)
|
|
camera.set_target(pos.x, pos.y)
|
|
camera.update(dt)
|
|
|
|
puppet.update(dt)
|
|
|
|
if CI_FRAME_PERF and ci_t_start then
|
|
ci_frame_count = ci_frame_count + 1
|
|
if ci_frame_count == 60 then
|
|
local elapsed_ms = (engine.time.now() - ci_t_start) * 1000.0
|
|
local avg = elapsed_ms / 60.0
|
|
print(string.format("vagrant: frame_avg_ms=%.2f", avg))
|
|
engine.exit(0)
|
|
end
|
|
end
|
|
end
|
|
|
|
function M.draw(ctx)
|
|
camera.begin()
|
|
-- 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)
|
|
engine.render.draw_sprite_transform(state.bench_tex, 400, 300, 0, 1, 1, 32, 32, 0xFFFFFFFF)
|
|
engine.render.draw_sprite_transform(state.backpack_tex, 350, 400, 0, 1, 1, 16, 16, 0xFFFFFFFF)
|
|
-- Puppet (sprite-mode via texture_handles populated by load_rig+load_textures).
|
|
puppet.render(state.player)
|
|
camera.finish()
|
|
if CI_FRAME_PERF then
|
|
engine.print("vagrant: render_frame_ok")
|
|
if not ci_sprite_traced then
|
|
engine.print("vagrant: sprite_mode=on")
|
|
ci_sprite_traced = true
|
|
end
|
|
end
|
|
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
|
|
|
|
return M
|