Composer module: single-player character as puppet (humanoid 4-bone rig: torso, head, leg_l, leg_r) at world (320, 240) on a 20x20 v0.1-format tilemap. - WASD direct movement, normalized for diagonals, 120 px/s. - Camera-chase via lib-core.camera target-provider (camera follows puppet position frame-to-frame). - Idle and walk keyframe animations: idle is a 2s torso-sway on the upper track; walk is a 0.6s leg-cycle on the lower track. - Animation-state transitions driven by movement input: walk while WASD-active, idle when released. - Headtrack: head bone (marked look_at: true in rig) rotates each frame to face the mouse cursor's world position via puppet.set_look_target. - ESC -> launcher. CI hooks (env-var-gated, no-op in normal play): - SPOREL_CI=1: emits 'vagrant: frame_avg_ms=N' after 60 frames and 'vagrant: render_frame_ok' per frame from the render hook, then engine.exit(0). Smoke stages consume these markers.
131 lines
4.3 KiB
Lua
131 lines
4.3 KiB
Lua
-- sporel-module-vagrant-skeleton v0.1.0
|
|
-- F.vagrant Tier-1 Phase 1: single-player + puppet + WASD + maps v0.1.
|
|
|
|
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,
|
|
}
|
|
|
|
-- CI hook gating (mirrors squad-prototype convention)
|
|
local CI_FRAME_PERF = (os.getenv("SPOREL_CI") == "1")
|
|
local ci_frame_count = 0
|
|
local ci_t_start = nil
|
|
|
|
local function set_walk_or_idle(moving)
|
|
if moving then
|
|
if not puppet.is_playing(state.player, "walk") then
|
|
puppet.stop(state.player, "idle")
|
|
puppet.play(state.player, "walk", { loop = true })
|
|
end
|
|
else
|
|
if not puppet.is_playing(state.player, "idle") then
|
|
puppet.stop(state.player, "walk")
|
|
puppet.play(state.player, "idle", { loop = true })
|
|
end
|
|
end
|
|
end
|
|
|
|
function M.init(ctx)
|
|
state.map = maps.load("maps/vagrant_test.map.json")
|
|
maps.set_current(state.map)
|
|
|
|
-- Load puppet rig + animations
|
|
local rig = puppet.load_rig("rigs/humanoid.rig.json")
|
|
state.player = puppet.spawn(rig, { x = 320, y = 240 })
|
|
|
|
local idle = puppet.load_animation("animations/idle.anim.json", rig)
|
|
local walk = puppet.load_animation("animations/walk.anim.json", rig)
|
|
puppet.register_animation(state.player, idle)
|
|
puppet.register_animation(state.player, walk)
|
|
puppet.play(state.player, "idle", { loop = true })
|
|
|
|
-- Input bindings (WASD direct movement + ESC)
|
|
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" })
|
|
|
|
-- Camera follows player position (no manual pan in vagrant-style)
|
|
camera.set_target_provider(function()
|
|
return puppet.position(state.player)
|
|
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
|
|
|
|
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)
|
|
if moving then
|
|
local len = math.sqrt(dx * dx + dy * dy)
|
|
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)
|
|
end
|
|
set_walk_or_idle(moving)
|
|
|
|
-- Headtrack: head looks at mouse world-position
|
|
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)
|
|
|
|
camera.update(dt)
|
|
puppet.update(dt)
|
|
|
|
-- CI: perf canary. After 60 frames emit avg ms, exit.
|
|
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)
|
|
r_lib.draw_map()
|
|
puppet.render(state.player)
|
|
-- Render-hook trace for smoke (P0-S25a). MUST emit from render(), not update().
|
|
-- Lesson from P.4.c: smoke stages that exit in update never prove the render
|
|
-- hook works.
|
|
if CI_FRAME_PERF then
|
|
engine.print("vagrant: render_frame_ok")
|
|
end
|
|
end
|
|
|
|
-- ====================================================================
|
|
-- Engine lifecycle hooks (global functions; engine calls these by name)
|
|
-- See P.4.c discovery: engine looks up lua_getglobal "init", "update",
|
|
-- "render" — not M.init etc. The module-table pattern is bridged here.
|
|
-- ====================================================================
|
|
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
|