Files
sporel-module-vagrant-skeleton/init.lua
Axel Meyer f7b38d984e feat: add CI-hooks for render-hook + state-transition smoke
Two env-var-gated hooks (no-op in normal play):
- SPOREL_CI=1: emits 'vagrant: render_frame_ok' per frame from
  render() and 'vagrant: frame_avg_ms=N' after 60 frames.
- SPOREL_VAGRANT_PHASE1_DRIVE=1: scripted move-right input on
  frames 21-40, idle otherwise. Combined with SPOREL_CI=1 produces
  observable idle -> walk -> idle state cycle.

State-transition trace 'vagrant: state=<walk|idle>' emits exactly
once per actual change in set_walk_or_idle, so the smoke greps
don't get noise from per-frame repeats.

Fix camera.set_target_provider (absent from camera API) to use
camera.set_target(x, y) called per frame from M.update instead.
2026-05-17 20:30:48 +02:00

157 lines
5.2 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 CI_DRIVE = (os.getenv("SPOREL_VAGRANT_PHASE1_DRIVE") == "1")
local ci_drive_frame = 0
local ci_last_state = nil
local function set_walk_or_idle(moving)
local new_state = moving and "walk" or "idle"
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
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)
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 on first frame
local init_pos = puppet.position(state.player)
camera.set_target(init_pos.x, init_pos.y)
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
if CI_DRIVE then
ci_drive_frame = ci_drive_frame + 1
-- Frames 1-20: idle (no move), 21-40: move-right, 41-60: idle again
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
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 follows player position each frame (vagrant-style no-pan)
local cam_pos = puppet.position(state.player)
camera.set_target(cam_pos.x, cam_pos.y)
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