-- sporel-module-vagrant-skeleton v0.5.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, hip-static legs, -- foot-body-orientation. Sprint (shift+wasd) + interaction (E near -- backpack) added v0.4.4. 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 interaction = require("lib-core.interaction") local M = {} -- Walk + sprint speed tunings. local WALK_SPEED_NORMAL = 120 local WALK_SPEED_SPRINT = 240 -- 2x local ANIM_SPEED_NORMAL = 0.45 local ANIM_SPEED_SPRINT = 0.9 -- 2x -- Furniture positions (matches M.draw hardcoded sprites). local BACKPACK_X, BACKPACK_Y = 350, 400 local INTERACTION_RANGE = 40 -- pixels local state = { player = nil, map = nil, world_tex = nil, -- subterrain_world atlas diffuse handle furniture_uvs = nil, -- name -> { x, y, w, h } from atlas JSON -- 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, sprinting = false, -- shift+wasd = 2x speed + 2x anim } -- 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 -- Lower-body controller: legs rotate to walk direction (NOT Substrate's -- body-relative wedge). User preference: leg stride visually aligns with -- movement axis. lower_control.rot = atan(move_x, -move_y) so leg sprites -- (which oscillate scl_y along their local Y) cycle along the walk axis. -- One generic walk_fwd_lower anim suffices since the rotation handles -- the directional change (4-direction anims kept on disk for future -- gait-style differentiation). local function update_lower_anim(moving, move_x, move_y) if not moving then -- Idle: stop walk anims, resume idle_lower. Preserve last -- lower_control_rot_deg so legs keep their last orientation -- (no snap-back to north on stop). 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 return end -- Walking: legs face walk direction (sprite-top convention: rot=0 = -Y). state.lower_control_rot_deg = math.deg(math.atan(move_x, -move_y)) -- Single generic walk anim (4-direction-picker deferred until we have -- differentiated gait styles per direction). local anim_id = "walk_fwd_lower" if state.walk_anim_current ~= anim_id then 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") -- Play with current speed (normal or sprint). local anim_speed = state.sprinting and ANIM_SPEED_SPRINT or ANIM_SPEED_NORMAL puppet.play(state.player, anim_id, { loop = true, speed = anim_speed }) if not puppet.is_playing(state.player, "walk_upper") then puppet.play(state.player, "walk_upper", { loop = true, speed = anim_speed }) 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 leg+foot orientation: -- * Hips are body-relative (legs.parent=body, position cascade follows body). -- * leg.world.rot = walk_dir (independent of body). Set leg.local.rot = -- walk_dir - body.world.rot so cascade gives correct world rot. -- * foot.world.rot = body.world.rot (user pref: feet stay body-oriented -- even when legs swing to walk-direction = "tactical twist"). Cascade: -- foot.world.rot = leg.world.rot + foot.local.rot = body.rot. -- → foot.local.rot = body.rot - leg.world.rot = body.rot - walk_dir -- = -leg_local_deg. -- Per-channel guard v0.4.2 allows leg.rot + foot.rot writes alongside -- walk_fwd_lower (which only writes leg.scl). puppet.set_procedural(state.player, "leg_foot_orientation", function(handle, dt) local _, _, body_rot = puppet.bone_world_transform(handle, "body") local leg_local_deg = state.lower_control_rot_deg - math.deg(body_rot) local foot_local_deg = -leg_local_deg puppet.write_bone(handle, "leg_l", { rot = leg_local_deg }) puppet.write_bone(handle, "leg_r", { rot = leg_local_deg }) puppet.write_bone(handle, "foot_l", { rot = foot_local_deg }) puppet.write_bone(handle, "foot_r", { rot = foot_local_deg }) end) -- Furniture atlas (subterrain_world): load JSON + diffuse texture once. local world_lib = aliases.subterrain_world local world_base = world_lib .. "/assets/atlases/subterrain_world" local world_meta = engine.asset.load_json(world_base .. "/tiles.atlas.json") state.world_tex = engine.asset.load_texture(world_base .. "/tiles.diffuse.atlas.png") state.furniture_uvs = {} for _, t in ipairs(world_meta.tiles) do state.furniture_uvs[t.name] = { x = t.uv[1], y = t.uv[2], w = t.uv[3], h = t.uv[4] } end -- Input bindings. input.bind("walk_left", { "a" }) input.bind("walk_right", { "d" }) input.bind("walk_up", { "w" }) input.bind("walk_down", { "s" }) input.bind("sprint", { "shift" }) -- held while walking → 2x speed input.bind("interact", { "e" }) -- fires nearest proximity-trigger input.bind("quit_to_launcher", { "escape" }) -- Proximity trigger on the backpack sprite (drawn at BACKPACK_X/Y in M.draw). -- Range 40px; press E while within that radius to fire the callback. interaction.register(BACKPACK_X, BACKPACK_Y, INTERACTION_RANGE, "interact", function(ctx) engine.print(string.format( "vagrant: interact with backpack (distance=%.1f)", ctx.distance)) end) -- CI trace: assets loaded count (3 atlas textures: player + tiles + world). if CI_FRAME_PERF and not ci_assets_traced then engine.print("vagrant: assets_loaded=3") 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) -- Sprint = shift held + actually moving. Updated BEFORE move so the -- selected speed matches; also feeds update_lower_anim's anim-speed -- pick at walk-start transitions. local sprint_held = input.is_action_down("sprint") state.sprinting = moving and sprint_held local current_walk_speed = state.sprinting and WALK_SPEED_SPRINT or WALK_SPEED_NORMAL -- 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 * WALK_SPEED_NORMAL * 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 * current_walk_speed * dt, pos.y + state.move_y * current_walk_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) -- Sprint toggle mid-walk: adjust play-speed of running anims without -- restarting their cycle (puppet v0.4.4 set_play_speed). Silent no-op -- if anim not currently playing. local anim_speed = state.sprinting and ANIM_SPEED_SPRINT or ANIM_SPEED_NORMAL puppet.set_play_speed(state.player, "walk_fwd_lower", anim_speed) puppet.set_play_speed(state.player, "walk_upper", anim_speed) -- 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) -- Interaction: dispatches the nearest matching proximity-trigger when -- 'interact' was pressed this frame. Uses player.position as actor. local pos = puppet.position(state.player) interaction.update(pos.x, pos.y) -- Camera follows puppet position each frame. 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() maps.draw_map_pre_entities() -- Hardcoded furniture placements (test-chamber spirit). -- Render via source-rect primitive using subterrain_world atlas UVs. local bed_uv = state.furniture_uvs["bed_001"] local bench_uv = state.furniture_uvs["bench_001"] local backpack_uv = state.furniture_uvs["backpack_001"] engine.render.draw_sprite_transform(state.world_tex, 200, 150, 0, 1, 1, bed_uv.w / 2, bed_uv.h / 2, 0xFFFFFFFF, bed_uv.x, bed_uv.y, bed_uv.w, bed_uv.h) engine.render.draw_sprite_transform(state.world_tex, 400, 300, 0, 1, 1, bench_uv.w / 2, bench_uv.h / 2, 0xFFFFFFFF, bench_uv.x, bench_uv.y, bench_uv.w, bench_uv.h) engine.render.draw_sprite_transform(state.world_tex, 350, 400, 0, 1, 1, backpack_uv.w / 2, backpack_uv.h / 2, 0xFFFFFFFF, backpack_uv.x, backpack_uv.y, backpack_uv.w, backpack_uv.h) -- Puppet (sprite-mode via texture_handles populated by load_rig+load_textures). puppet.render(state.player) maps.draw_map_post_entities() 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