Refactor: Player gesplittet in zwei Handles. state.actor (composition- Entity) ist canonical position-owner. state.player (puppet handle) bleibt für Animation + Render zuständig und syncs each frame via puppet.move_to(state.player, actor.position(state.actor)). vagrant_player-Template ist UNTAGGED — render.draw_entities sieht ihn nicht, da puppet.render(state.player) das Sprite-Rendering schon macht. Tag-Konflikt vermieden. Movement-Refactor in M.update: - CI_DRIVE-Branch: actor.move(state.actor, dx*speed*dt, 0) - Normal-Movement: actor.move(state.actor, mx*speed*dt, my*speed*dt) - Sync-Punkt nach beiden Branches: puppet.move_to(state.player, actor.position(state.actor)) - interaction.update + camera.set_target lesen actor.position Sprint-Logic unverändert (eigene WALK_SPEED constants; movement_speed auf actor ist informational/120). Bumps: 0.6.0 → 0.7.0; +actor v0.1.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
437 lines
19 KiB
Lua
437 lines
19 KiB
Lua
-- sporel-module-vagrant-skeleton v0.7.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.
|
|
--
|
|
-- v0.6.0 (Phase A.4): Backpack promoted to composition-Entity (Atlas-UV
|
|
-- render-path via render.draw_entities{tag="renderable"}). Bed + Bench
|
|
-- remain hardcoded atlas-furniture in v0.6.0 (no interaction-trigger,
|
|
-- no demo-pflicht to be entities yet).
|
|
--
|
|
-- v0.7.0 (Phase A.6): Player split — actor-entity (state.actor) owns
|
|
-- canonical position; puppet (state.player) syncs from actor each frame
|
|
-- via puppet.move_to. Movement-Logic moves the actor; puppet renders.
|
|
-- Untagged actor — render.draw_entities doesn't see it; puppet handles
|
|
-- the render via puppet.render(state.player) unchanged.
|
|
|
|
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 composition = require("lib-core.composition")
|
|
local actor = require("lib-core.actor")
|
|
|
|
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 (Bed/Bench remain hardcoded for v0.1; Backpack
|
|
-- promoted to Inactive-Entity via composition. Bed/Bench have no
|
|
-- interaction-trigger and no demo-pflicht to be entities — they stay
|
|
-- atlas-furniture until Phase B (container-on-bed) or similar triggers).
|
|
local BACKPACK_X, BACKPACK_Y = 350, 400
|
|
local INTERACTION_RANGE = 40 -- pixels
|
|
|
|
-- Backpack-Template (Phase A.4 — Atlas-UV render-path).
|
|
-- Real sprite_atlas + sprite_uv values are filled in M.init after the
|
|
-- atlas-JSON is parsed; defaults here are placeholders so the inert-
|
|
-- declaration types are correct (string + numbers).
|
|
composition.define_template{
|
|
id = "backpack",
|
|
properties = {
|
|
sprite_atlas = "",
|
|
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
|
|
position = {x = 0, y = 0},
|
|
},
|
|
tags = {"renderable"},
|
|
}
|
|
|
|
-- Player-Actor-Template (Phase A.6). Vagrant's player is an actor whose
|
|
-- position drives the puppet (sync each frame). NOT tagged renderable —
|
|
-- the puppet handles rendering, render.draw_entities doesn't see this
|
|
-- entity. movement_speed is informational; vagrant's sprint-logic uses
|
|
-- its own WALK_SPEED constants directly for the per-frame move.
|
|
composition.define_template{
|
|
id = "vagrant_player",
|
|
properties = {
|
|
position = {x = 0, y = 0},
|
|
movement_speed = 120, -- WALK_SPEED_NORMAL
|
|
},
|
|
tags = {}, -- intentionally untagged: not renderable
|
|
}
|
|
|
|
local state = {
|
|
player = nil, -- puppet handle (state-bearing for animation)
|
|
actor = nil, -- composition-entity owning position
|
|
map = nil,
|
|
world_tex = nil, -- subterrain_world atlas diffuse handle (used for bed/bench)
|
|
furniture_uvs = nil, -- name -> { x, y, w, h } from atlas JSON
|
|
backpack = nil, -- composition-entity, created in M.init after atlas load
|
|
-- 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)
|
|
|
|
-- Player-Actor owns canonical position (Phase A.6). Puppet is spawned
|
|
-- at the same coords and stays in sync via puppet.move_to each frame.
|
|
state.actor = actor.create{
|
|
template = "vagrant_player",
|
|
properties = {
|
|
position = {x = 320, y = 240},
|
|
movement_speed = 120, -- WALK_SPEED_NORMAL
|
|
},
|
|
}
|
|
|
|
-- 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_atlas_png = world_base .. "/tiles.diffuse.atlas.png"
|
|
local world_meta = engine.asset.load_json(world_base .. "/tiles.atlas.json")
|
|
state.world_tex = engine.asset.load_texture(world_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
|
|
|
|
-- Spawn Backpack-Entity with real Atlas-UV values now that the
|
|
-- atlas-json is parsed. lib-core.render's atlas-cache will load
|
|
-- the texture lazy-on-first-draw (separate from state.world_tex
|
|
-- which serves bed/bench's direct draw_sprite_transform call;
|
|
-- ineffizient aber kein-Schaden, single-texture-handle-merge ist
|
|
-- ein späterer Slice).
|
|
local backpack_uv = state.furniture_uvs["backpack_001"]
|
|
state.backpack = composition.create{
|
|
template = "backpack",
|
|
properties = {
|
|
sprite_atlas = world_atlas_png,
|
|
sprite_uv = {x = backpack_uv.x, y = backpack_uv.y,
|
|
w = backpack_uv.w, h = backpack_uv.h},
|
|
position = {x = BACKPACK_X, y = BACKPACK_Y},
|
|
},
|
|
}
|
|
|
|
-- 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-Entity. Anchor read from the
|
|
-- entity's position-Properties; sign is stationary so a snapshot is
|
|
-- correct (interaction-API takes raw numbers, not closures, in v0.1).
|
|
interaction.register(
|
|
state.backpack:get_property("position.x"),
|
|
state.backpack:get_property("position.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-management.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
|
|
actor.move(state.actor, dx * WALK_SPEED_NORMAL * dt, 0)
|
|
else
|
|
moving = false
|
|
end
|
|
end
|
|
|
|
-- Move actor (position-owner). Puppet syncs from actor below.
|
|
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
|
|
actor.move(state.actor,
|
|
state.move_x * current_walk_speed * dt,
|
|
state.move_y * current_walk_speed * dt)
|
|
elseif not moving then
|
|
state.move_x = 0
|
|
state.move_y = 0
|
|
end
|
|
|
|
-- Sync puppet position from actor (Phase A.6: actor is source-of-truth).
|
|
-- Done once per frame after movement, before puppet.update consumes
|
|
-- position for animation.
|
|
puppet.move_to(state.player, actor.position(state.actor))
|
|
|
|
-- 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 actor.position as anchor.
|
|
interaction.update(actor.position(state.actor))
|
|
|
|
-- Camera follows actor position each frame.
|
|
camera.set_target(actor.position(state.actor))
|
|
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()
|
|
-- Bed + Bench remain hardcoded furniture (no interaction-trigger →
|
|
-- no entity-Pflicht in Phase A; see Backpack-Template comment).
|
|
local bed_uv = state.furniture_uvs["bed_001"]
|
|
local bench_uv = state.furniture_uvs["bench_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)
|
|
-- Backpack (and any other renderable entity) via Phase-A.2 tag-based
|
|
-- render. The atlas-cache loads tiles.diffuse.atlas.png lazy-on-first
|
|
-- call (separate handle from state.world_tex — see M.init note).
|
|
r_lib.draw_entities{ tag = "renderable" }
|
|
-- 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
|