Files
sporel-module-vagrant-skeleton/init.lua
Axel Meyer 31333111c0 feat: v0.11.0 crafting integration
Stick pickup neben rock spawnt; 2 Recipes (rock_pick, rock_pile);
C-key oeffnet Crafting-Panel via lib-core.crafting-display.
Craft + Inspect Actions konsumieren notify-Channels fuer Erfolg +
Detail-Modal.

Deps: +crafting 0.1.0, +crafting-display 0.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 13:57:19 +02:00

933 lines
40 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- sporel-module-vagrant-skeleton v0.11.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
-- pickup-item) 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.
--
-- v0.8.0: Backpack template gains container={kind="list"}.
-- End-to-end pickup/drop loop: E on rock → inventory.add(backpack, rock)
-- → rock disappears from draw_entities; Q → rock reappears at player
-- position. engine.print shows inventory count + item-IDs after each
-- add/remove. Tree-check (CI-gated) verifies child placement in
-- composition tree.
--
-- v0.9.0: Inventory panel UI wired in. Tab toggles a panel overlay
-- showing backpack contents. Right-click on an item row opens a context
-- menu with Drop and Inspect actions. Q-drop (LIFO quick-drop) remains
-- available in parallel.
--
-- v0.9.1: Rock template gains player-facing properties (name,
-- description, weight, volume, composition.stone). Inspect prints the
-- name+description+weight+composition block to the console; future
-- notification system will route this to an in-game text panel. Module-
-- side label_resolver override removed — default_label_resolver reads
-- the `name` property.
--
-- v0.10.0: Notification system wired in. Inspect routes to a modal
-- detail-panel (notify-display detail-channel); pickup spawns a
-- world-floating "+1 Rock" overlay; Drop emits an event-toast. L
-- opens the game-log panel listing all events chronologically.
-- engine.print mirror remains enabled — dev tools that parse the log
-- continue to work.
--
-- v0.11.0: Crafting system wired in (lib-core.crafting +
-- lib-core.crafting-display). Stick spawns as a second pickup-item
-- next to the rock. C-key opens a Crafting panel listing the two
-- known recipes (rock_pick = 1 rock + 1 stick, rock_pile = 3 rocks).
-- Right-click a recipe row → Craft + Inspect actions; Craft consumes
-- inputs from the backpack and adds the output; Inspect routes
-- recipe details to a notify-display detail-channel.
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 inventory = require("lib-core.inventory-list")
local panel = require("lib-core.panel")
local inv_display = require("lib-core.inventory-list-display")
local notify = require("lib-core.notify")
local notify_display = require("lib-core.notify-display")
local world_overlay = require("lib-core.world-overlay")
local crafting = require("lib-core.crafting")
local crafting_display = require("lib-core.crafting-display")
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 a container-on-bed feature or similar triggers).
local BACKPACK_X, BACKPACK_Y = 350, 400
local INTERACTION_RANGE = 40 -- pixels
-- World-overlay float colors (pickup confirmation)
local COLOR_PICKUP_FLOAT = 0xFFFF80FF
-- Backpack-Template (Phase A.4 — Atlas-UV render-path).
-- v0.8.0: container={kind="list"} declared so the backpack receives
-- items via inventory.add (capability-by-declaration).
-- 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"},
container = { kind = "list" }, -- receives items via inventory.add
}
-- Rock-Template: an item sitting in the world that the player can pick up.
-- sprite_atlas + sprite_uv are filled in M.init after the blob_rect_stone atlas
-- is loaded; placeholder defaults here keep the inert-declaration types correct.
-- slot_00_isolated is a standalone isolated stone tile — visually reads as a
-- single rock on the ground.
composition.define_template{
id = "rock",
properties = {
stack_mode = "individual", -- required by inventory-list
-- Player-facing properties (item-template-convention v0.x):
name = "Rock", -- shown by inventory display
description = "A heavy stone. Could be useful for crafting.",
weight = 2.5, -- kg (real units)
volume = 0.001, -- L (real units)
-- Material composition (composition-model.md §4 — 0-1 platonic;
-- 1.0 = pure stone). Phase E (property-driven recipes) reads this.
["composition.stone"] = 1.0,
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- Stick-Template: placeholder pickup-item that joins rock in the world.
-- Re-uses the blob_rect_stone atlas for visual placeholder (per Phase C-Q10
-- pragmatic resolution — echtes Stick-Sprite kommt mit dem ersten
-- atlas-baker-decals-Slice). slot_04_straight reads as a long horizontal
-- bar — visually distinct from the rock's slot_00_isolated blob.
composition.define_template{
id = "stick",
properties = {
stack_mode = "individual",
name = "Stick",
description = "A wooden stick. Useful for crafting tool handles.",
weight = 0.3, -- kg
volume = 0.0005, -- L
["composition.wood"] = 1.0,
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- Stone-Pick-Template: output of rock_pick recipe (1 rock + 1 stick).
-- Lives only in the backpack post-craft in v0.11 (no world-drop path yet
-- via the Crafting-Panel). slot_05_tee_open reads as a tool-shaped tile.
composition.define_template{
id = "rock_pick",
properties = {
stack_mode = "individual",
name = "Stone Pick",
description = "A crude pick for mining stone.",
weight = 2.8, -- rock + stick (real units)
volume = 0.0015,
["composition.stone"] = 0.9,
["composition.wood"] = 0.1,
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- Rock-Pile-Template: output of 3×rock recipe. slot_13_solid reads as
-- a dense cluster tile — visually distinct from a single rock.
composition.define_template{
id = "rock_pile",
properties = {
stack_mode = "individual",
name = "Rock Pile",
description = "A stack of rocks bundled together for storage.",
weight = 7.5, -- 3x rock
volume = 0.003,
["composition.stone"] = 1.0,
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- 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
}
-- Item-Template-Convention:
-- An "item" is a composition-template with:
-- - properties.stack_mode = "individual" (required by inventory-list)
-- - properties.sprite_atlas + sprite_uv (Atlas-UV render path)
-- - properties.position = {x = 0, y = 0} (world anchor)
-- - tags = {"renderable", "item"} ("renderable" → drawn by
-- render.draw_entities;
-- "item" → reserved/visible to
-- future inventory queries)
-- Pickup: register_pickup(item) registers an interaction-trigger at the
-- item's current position; on interact, the item moves into state.backpack
-- via inventory.add (renderable-tag flips off → world stops drawing it).
-- Drop: input-action "drop" pops the last item out of state.backpack,
-- re-parents it to the world at the player position, and re-registers a
-- pickup-trigger at the new location.
--
-- Rock template (v0.7.3) and container block on backpack (v0.8.0) activate this
-- convention end-to-end. The pickup/drop glue (v0.7.2) sets up the wiring.
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
rock = nil, -- composition-entity (item), created in M.init after blob_rect_stone load
stick = nil, -- composition-entity (item), placeholder for crafting demo
crafting_widget = nil, -- crafting-display widget handle
-- 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"}
-- register_pickup(item)
-- Registers an interaction-trigger at the item's current world position.
-- When the player presses E within INTERACTION_RANGE, the item is moved
-- into state.backpack via inventory.add (renderable-tag flips off) and
-- the trigger is unregistered so the item no longer shows as interactable.
-- Called after placing the rock, and by drop-glue after relocation.
local function register_pickup(item)
local x = item:get_property("position.x")
local y = item:get_property("position.y")
local trigger_id
trigger_id = interaction.register(x, y, INTERACTION_RANGE, "interact",
function()
inventory.add(state.backpack, item)
-- World-float "+1 <item-name>" anchored on the player
local item_name = item:get_property("name") or "item"
world_overlay.spawn{
kind = "text",
text = "+1 " .. item_name,
anchor = state.actor, -- follow player
offset = { x = -20, y = -48 },
ttl = 1.2,
font_size = 16,
color = COLOR_PICKUP_FLOAT,
rise_px = 24,
}
-- Pickup event-toast + log entry
notify.post{
tags = {"pickup", "inventory"},
text = string.format("Picked up %s", item_name),
}
interaction.unregister(trigger_id)
if CI_FRAME_PERF then engine.print("vagrant: event=pickup") end
-- Inventory inspection trace (unconditional — user-facing demo output).
local count = inventory.count(state.backpack)
local items = inventory.contents(state.backpack)
local ids = {}
for _, it in ipairs(items) do
table.insert(ids, tostring(it:get_property("composition.reg_id")))
end
engine.print(string.format("vagrant: backpack count=%d items=[%s]",
count, table.concat(ids, ",")))
-- Tree-check (CI-gated): verify the item was actually placed as a
-- child of the backpack entity in the composition tree.
if CI_FRAME_PERF then
local found = false
for slot_name, _ in pairs(state.backpack:get_children()) do
if slot_name:match("^item%.%d+$") then found = true; break end
end
engine.print(string.format("vagrant: tree_check_after_pickup=%s",
found and "ok" or "MISSING"))
end
end)
end
-- 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},
},
}
-- Blob-rect-stone atlas: load JSON + diffuse texture for the rock sprite.
-- blob_rect_stone alias resolves to lib-asset.prototype-blob-geom. The
-- "slot_00_isolated" tile is the standalone isolated stone shape — reads as
-- a single rock on the ground. Separate atlas/texture from world atlas
-- (lib-core.render's atlas-cache de-duplicates on path, no wasted load).
local stone_lib = aliases.blob_rect_stone
local stone_base = stone_lib .. "/assets/atlases/blob_rect_stone"
local stone_atlas_png = stone_base .. "/tiles.diffuse.atlas.png"
local stone_meta = engine.asset.load_json(stone_base .. "/tiles.atlas.json")
local stone_uvs = {}
for _, t in ipairs(stone_meta.tiles) do
stone_uvs[t.name] = { x = t.uv[1], y = t.uv[2], w = t.uv[3], h = t.uv[4] }
end
-- Spawn Rock-Entity 80px to the left of the backpack so it sits in a
-- distinct position: visible to the player on first load, not overlapping
-- the backpack, reachable by walking left from spawn.
local ROCK_X, ROCK_Y = BACKPACK_X - 80, BACKPACK_Y
local rock_uv = stone_uvs["slot_00_isolated"]
state.rock = composition.create{
template = "rock",
properties = {
sprite_atlas = stone_atlas_png,
sprite_uv = {x = rock_uv.x, y = rock_uv.y,
w = rock_uv.w, h = rock_uv.h},
position = {x = ROCK_X, y = ROCK_Y},
},
}
register_pickup(state.rock)
if CI_FRAME_PERF then engine.print("vagrant: rock_spawned") end
-- Stick spawn 80px to the right of the rock (160px left of the backpack
-- on the same Y as rock + backpack). Placeholder visual: slot_04_straight
-- from blob_rect_stone reads as a long horizontal bar (Phase C-Q10 atlas
-- re-use; echtes Stick-Sprite folgt mit atlas-baker-decals).
local STICK_X, STICK_Y = BACKPACK_X - 160, BACKPACK_Y
local stick_uv = stone_uvs["slot_04_straight"]
state.stick = composition.create{
template = "stick",
properties = {
sprite_atlas = stone_atlas_png,
sprite_uv = {x = stick_uv.x, y = stick_uv.y,
w = stick_uv.w, h = stick_uv.h},
position = {x = STICK_X, y = STICK_Y},
},
}
register_pickup(state.stick)
if CI_FRAME_PERF then engine.print("vagrant: stick_spawned") end
-- ====== Crafting Recipes ======
crafting.define_recipe{
id = "rock_pick",
inputs = {
{template = "rock", count = 1},
{template = "stick", count = 1},
},
output = {template = "rock_pick", count = 1},
name = "Stone Pick",
description = "A pick for mining stone.",
}
crafting.define_recipe{
id = "rock_pile",
inputs = {
{template = "rock", count = 3},
},
output = {template = "rock_pile", count = 1},
name = "Rock Pile",
description = "Stack rocks for storage.",
}
-- Inventory-UI Setup
panel.set_theme{ panel_width_frac = 0.4, font_size_title = 20 }
local backpack_widget = inv_display.create(state.backpack, {
title = "Backpack",
widget_id = "backpack",
pause_on_open = false,
})
-- Default label_resolver reads `name` property from the item-template
-- (set on the rock template above). No override needed in v0.9.1.
-- Drop action: knows world-reparenting + player position
inv_display.register_action(backpack_widget, "Drop", function(item, c)
inventory.remove(state.backpack, item)
local px = state.actor:get_property("position.x")
local py = state.actor:get_property("position.y")
item:set_property("position.x", px + 30)
item:set_property("position.y", py)
register_pickup(item)
c.close_menu()
if CI_FRAME_PERF then engine.print("vagrant: event=drop_via_menu") end
notify.post{
tags = {"drop", "inventory"},
text = string.format("Dropped %s",
item:get_property("name") or "item"),
}
end)
-- Inspect action: routes to a modal detail-panel via notify.post.
-- engine.print mirror kept for dev-tool compatibility.
inv_display.register_action(backpack_widget, "Inspect", function(item, c)
-- Collect composition.<material>.* properties for the detail payload
local props = item:get_properties()
local comp = {}
for k, v in pairs(props) do
local mat = k:match("^composition%.(.+)$")
if mat and mat ~= "reg_id" then comp[mat] = v end
end
notify.post{
tags = {"inspect"},
text = item:get_property("name") or "(unnamed)",
data = {
description = item:get_property("description"),
weight = item:get_property("weight"),
volume = item:get_property("volume"),
composition = comp,
},
}
c.close_menu()
end)
panel.register("backpack", backpack_widget)
panel.bind_default_trigger("tab", "backpack")
-- ====== Crafting Widget ======
state.crafting_widget = crafting_display.create(state.backpack, {
title = "Crafting",
widget_id = "crafting",
pause_on_open = false,
ctx_factory = function()
return { actor = state.actor }
end,
})
-- Craft action: invokes crafting.craft on the backpack, posts a
-- notify-toast on success or a warn-toast on missing inputs.
crafting_display.register_action(state.crafting_widget, "Craft",
function(recipe_id, c)
local r = crafting.craft(recipe_id, c.container,
{ actor = state.actor })
if r.ok then
local recipe = crafting.get_recipe(recipe_id)
notify.post{
tags = {"craft", "event"},
text = "Crafted " .. (recipe.name or recipe_id),
severity = "info",
}
if CI_FRAME_PERF then
engine.print("vagrant: event=craft_ok recipe=" .. recipe_id)
end
else
local msg
if r.error == "missing_inputs" then
msg = "Missing inputs"
else
msg = "Cannot craft: " .. tostring(r.error)
end
notify.warn({"craft", "event"}, msg)
if CI_FRAME_PERF then
engine.print("vagrant: event=craft_fail recipe=" .. recipe_id
.. " error=" .. tostring(r.error))
end
end
c.close_menu()
end)
-- Inspect action: routes recipe details to the notify detail-channel.
crafting_display.register_action(state.crafting_widget, "Inspect",
function(recipe_id, c)
local recipe = crafting.get_recipe(recipe_id)
local lines = {
recipe.name or recipe.id,
"",
recipe.description or "",
"",
"Inputs:",
}
for _, inp in ipairs(recipe.inputs) do
lines[#lines + 1] = " " .. inp.template .. " \xc3\x97 " .. inp.count
end
lines[#lines + 1] = ""
lines[#lines + 1] = "Output: " .. recipe.output.template
.. " \xc3\x97 " .. recipe.output.count
notify.post{
tags = {"craft", "inspect", "detail"},
text = table.concat(lines, "\n"),
severity = "info",
}
c.close_menu()
end)
panel.register("crafting", state.crafting_widget)
panel.bind_default_trigger("c", "crafting")
-- ====== Notification System Setup ======
-- Channels (created BEFORE display attach + widget register)
notify.create_channel{ id = "inspect", filter = {"inspect"},
display_mode = "detail" }
notify.create_channel{ id = "events", filter = {"pickup", "drop", "crafting", "craft"},
display_mode = "toast" }
notify.create_channel{ id = "log", filter = {}, -- catch-all
display_mode = "log" }
-- Widgets MUST be registered BEFORE attach_mode("detail"/"log") because
-- attach_mode auto-opens detail-widget on first message (requires registered).
panel.register("inspect-detail",
notify_display.create_detail_widget{
widget_id = "inspect-detail", title = "Item Details" })
panel.register("game-log",
notify_display.create_log_widget{
widget_id = "game-log", title = "Game Log" })
-- Attach display-layer modes
notify_display.attach_mode("detail", { panel_widget_id = "inspect-detail" })
notify_display.attach_mode("toast", { position = "top-right", max_visible = 5 })
notify_display.attach_mode("log", { panel_widget_id = "game-log" })
-- L opens game-log (inspect-detail auto-opens on notify.post; no hotkey)
panel.bind_default_trigger("l", "game-log")
-- 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("drop", { "q" }) -- drop most-recent item from backpack
input.bind("quit_to_launcher", { "escape" })
-- Note: no backpack proximity-trigger registered here. The backpack is now
-- a container; interaction is driven by the rock's register_pickup
-- trigger. The old backpack-print callback has been removed.
-- CI trace: assets loaded count (4 atlas textures: player + tiles + world + stone).
if CI_FRAME_PERF and not ci_assets_traced then
engine.print("vagrant: assets_loaded=4")
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
-- ESC: close any open panel first; only quit to launcher when no panel is open.
if panel.is_open() then
panel.close()
else
engine.switch_module("lib-management.launcher")
return
end
end
-- Inventory panel input handling
panel.update(dt)
if panel.is_pausing() then return end
notify_display.update(dt)
world_overlay.update(dt)
-- Drop: Q pops the most-recently-added item from backpack, relocates
-- it to world at player position + 30px right offset, and re-registers
-- a pickup-trigger. inventory.remove restores the renderable tag.
if input.was_action_pressed("drop") then
local items = inventory.contents(state.backpack)
if #items > 0 then
local item = inventory.remove(state.backpack, items[#items])
local px = state.actor:get_property("position.x")
local py = state.actor:get_property("position.y")
item:set_property("position.x", px + 30)
item:set_property("position.y", py)
register_pickup(item)
if CI_FRAME_PERF then engine.print("vagrant: event=drop") end
notify.post{
tags = {"drop", "inventory"},
text = string.format("Dropped %s",
item:get_property("name") or "item"),
}
-- Inventory inspection trace (unconditional — user-facing demo output).
local count = inventory.count(state.backpack)
local remaining_items = inventory.contents(state.backpack) -- re-read post-remove
local ids = {}
for _, it in ipairs(remaining_items) do
table.insert(ids, tostring(it:get_property("composition.reg_id")))
end
engine.print(string.format("vagrant: backpack count=%d items=[%s]",
count, table.concat(ids, ",")))
-- Tree-check (CI-gated): verify the backpack has no item.* children
-- after drop (item was re-parented to world).
if CI_FRAME_PERF then
local found = false
for slot_name, _ in pairs(state.backpack:get_children()) do
if slot_name:match("^item%.%d+$") then found = true; break end
end
engine.print(string.format("vagrant: tree_check_after_drop=%s",
found and "MISSING" or "ok"))
end
end
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.
if not panel.is_open() then
interaction.update(actor.position(state.actor))
end
-- 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
-- Camera handle for world_overlay (built from lib-core.camera state)
local target = camera.target() -- returns {x, y}
local w, h = engine.window.size()
local camera_handle = {
target_x = target.x,
target_y = target.y,
zoom = camera.zoom(),
offset_x = w / 2,
offset_y = h / 2,
}
world_overlay.render(camera_handle)
-- Inventory panel overlay (z-top: drawn after all world content).
panel.render()
-- Toast overlay on top of everything.
notify_display.render()
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