Files
sporel-module-vagrant-skeleton/init.lua

1416 lines
61 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.14.0
-- Sprite-mode test-chamber: 13-bone humanoid puppet + sprite-tilemap +
-- TC_Basics furniture (bed/bench/backpack). 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.
--
-- v0.12.0: Workbench-Entity added at (350, 380). Right-Click on the
-- workbench opens a Workbench-Crafting panel + Workbench-Inventory
-- panel. The crafting panel uses a multi-source locale {backpack,
-- workbench} via crafting v0.2.0's locale-factory API; output lands
-- in the workbench. Take-action in workbench-inv moves an item back
-- to the backpack. C-key Backpack-Crafting remains via the bw-compat
-- shim in crafting v0.2.0 (Form-1 bare entity-handle stays valid).
--
-- v0.13.0: lib-core.panel bumped to 0.2.0 (multi-active + layout-slots).
-- Workbench-Crafting registers with layout="left-half", Workbench-
-- Inventory with layout="right-half"; right-click opens both panels
-- simultaneously side-by-side. Replaces the v0.12.0 single-active
-- caveat where workbench_inv covered workbench_crafting.
--
-- v0.14.0: Vagrant migrates Bed + Bench + Backpack visuals off the
-- proprietary Subterrain world-atlas onto TC_Basics sprites
-- (single_bed1, bench1, sack1) — first concrete consumer of the
-- ADR-0054 Item-Visual-Identity weiche. Bed + Bench promoted to
-- composition-templates with `renderable` tag, rendered via the
-- existing draw_entities path; no more hardcoded draw_sprite_transform
-- calls for furniture. lib-asset.prototype-subterrain dep retained
-- for the puppet sprite atlas (subterrain_player); the world-atlas
-- (subterrain_world) drop closes the last furniture dependency on
-- the Subterrain world-tiles.
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
-- World-furniture positions (all three now composition-entities with
-- renderable tag; rendered via draw_entities). Positions are visual
-- CENTER per lib-core.render v0.2.0 anchor convention.
local BACKPACK_X, BACKPACK_Y = 350, 400
local BED_X, BED_Y = 200, 150
local BENCH_X, BENCH_Y = 400, 300
local INTERACTION_RANGE = 40 -- pixels
-- World-overlay float colors (pickup confirmation)
local COLOR_PICKUP_FLOAT = 0xFFFF80FF
-- Project canonical pixels-per-meter, anchored to the puppet shoulder
-- width (64 px = 0.5 m per ~/sporel_tile_scale.md). All sprite atlases
-- declare their own `atlas_meta.pixels_per_meter` in their sprite-mode
-- JSON output (atlas-baker v0.4.0+); the per-entity scale-factor is
-- derived as PROJECT_PIXELS_PER_METER / atlas_ppm at template-load time
-- and applied via lib-core.render v0.3.0's `sprite_scale` property.
-- Atlases authored at canonical ppm get scale 1.0 → no-op.
local PROJECT_PIXELS_PER_METER = 128
-- 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).
-- v0.14.0: sprite source migrated to TC_Basics `sack1`.
-- 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},
sprite_scale = 1.0,
position = {x = 0, y = 0},
},
tags = {"renderable"},
container = { kind = "list" }, -- receives items via inventory.add
}
-- Bed + Bench-Templates (v0.14.0 — ADR-0054 first-consumer slice).
-- Static world-furniture, no interaction-trigger, no container-block:
-- just sprite + position + `renderable` tag so draw_entities renders
-- them via the Atlas-UV path. Migrates off the proprietary Subterrain
-- world-atlas onto TC_Basics `single_bed1` + `bench1`. Real
-- sprite_atlas + sprite_uv values are filled in M.init after the
-- TC_Basics sprite-mode atlas JSON is parsed.
composition.define_template{
id = "bed",
properties = {
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
sprite_scale = 1.0,
position = {x = 0, y = 0},
},
tags = {"renderable"},
}
composition.define_template{
id = "bench",
properties = {
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
sprite_scale = 1.0,
position = {x = 0, y = 0},
},
tags = {"renderable"},
}
-- 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-Age crafting slice (crafting v0.3 / ADR-0055 consumer). `log` is a
-- heavy wood item; the `saw_planks` recipe matches it by property-constraint
-- (composition.wood > 0.5 AND weight > 5) rather than by template-id, so a
-- light `plank` (weight 2) is correctly NOT sawable into more planks.
composition.define_template{
id = "log",
properties = {
stack_mode = "individual",
name = "Log",
description = "A heavy wooden log. Saw it into planks with a cutting edge.",
weight = 20, -- kg — heavy (> saw threshold)
volume = 0.05,
["composition.wood"] = 1.0,
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- `sharp_stone` is a NON-consumed cutting TOOL: it carries the derived
-- affordance `affordance.cutting`, which `saw_planks` presence-checks.
composition.define_template{
id = "sharp_stone",
properties = {
stack_mode = "individual",
name = "Sharp Stone",
description = "A knapped stone with a cutting edge. Used as a tool, not consumed.",
weight = 1,
volume = 0.0004,
["composition.stone"] = 1.0,
["affordance.cutting"] = true, -- tool affordance (crafting v0.3)
sprite_atlas = "",
sprite_uv = {x = 0, y = 0, w = 1, h = 1},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- NOTE: Crafted-output templates (rock_pick, rock_pile) are defined inside
-- M.init rather than at top-level, because their sprite_atlas defaults need
-- the resolved blob_rect_stone PNG path. Crafting calls composition.create
-- with no extra properties, so the template defaults are the only place to
-- carry sprite_atlas / sprite_uv onto crafted items. See M.init for the
-- actual define_template calls.
-- 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,
backpack = nil, -- composition-entity, created in M.init after atlas load
bed = nil, -- composition-entity (v0.14.0 — TC_Basics single_bed1)
bench = nil, -- composition-entity (v0.14.0 — TC_Basics bench1)
rock = nil, -- composition-entity (item), created in M.init after blob_rect_stone load
stick = nil, -- composition-entity (item), placeholder for crafting demo
workbench = nil, -- composition-entity (container), Phase D Workbench
crafting_widget = nil, -- crafting-display widget handle (Backpack)
workbench_crafting_widget = nil, -- crafting-display widget handle (Workbench-context)
workbench_inv_widget = nil, -- inventory-list-display widget handle (Workbench-Inv)
-- 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
-- register_interaction_use(entity)
-- Phase D Workbench: registers a right-click ("use") interaction-trigger at
-- the entity's current world position. When the player fires "use" within
-- INTERACTION_RANGE, the callback opens both the Workbench-Crafting and
-- Workbench-Inventory panels.
--
-- lib-core.interaction v0.1.0 is XY-based (not entity-bound); the trigger
-- snapshots the position at registration time. Workbench is static so a
-- single registration suffices. Action-name "use" is bound to mouse_right
-- in M.init's input.bind block.
--
-- panel v0.2.0 supports multi-active panels with layout-slots; the two
-- panels (workbench_crafting=left-half, workbench_inv=right-half) are
-- visible simultaneously side-by-side after both panel.open calls.
local function register_interaction_use(entity)
local x = entity:get_property("position.x")
local y = entity:get_property("position.y")
interaction.register(x, y, INTERACTION_RANGE, "use", function()
panel.open("workbench_crafting")
panel.open("workbench_inv")
if CI_FRAME_PERF then engine.print("vagrant: event=workbench_use") end
end)
end
-- discover_sources(workbench) → array of container entities
-- Phase D v0.2 source-discovery policy: Player-Backpack is always a source
-- (independent of workbench position; player carries it everywhere), and
-- the workbench itself is always a source. Forward-compat stub below
-- documents the intended adjacent-container scan (deferred to a future
-- slice with multi-container demos).
local function discover_sources(workbench)
local sources = { state.backpack, workbench }
-- Forward-compat stub for adjacent-container scan (deferred):
-- for _, ent in ipairs(composition.list_by_tag("container")) do
-- if ent ~= workbench and ent ~= state.backpack then
-- local wx = workbench:get_property("position.x")
-- local wy = workbench:get_property("position.y")
-- local ex = ent:get_property("position.x")
-- local ey = ent:get_property("position.y")
-- if ex and ey and wx and wy then
-- local dx, dy = math.abs(ex - wx), math.abs(ey - wy)
-- if math.max(dx, dy) <= 1 then -- Chebyshev radius 1
-- sources[#sources + 1] = ent
-- end
-- end
-- end
-- end
return sources
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 (TC_Basics sprite-mode): load JSON + diffuse atlas.
-- atlas-baker v0.4.0 sprite-mode output layout — sprites table is
-- a map of alias → {x, y, w, h, source_file}; rendering reads
-- x/y/w/h via render.draw_entities' Atlas-UV path. atlas_meta.
-- pixels_per_meter declares the atlas's native resolution; scale
-- against the project canonical to get the per-entity sprite_scale.
local tc_lib = aliases.tc_basics
local tc_base = tc_lib .. "/assets/atlases/tcbasics"
local tc_atlas_png = tc_base .. "/sprites.diffuse.atlas.png"
local tc_meta = engine.asset.load_json(tc_base .. "/sprites.uv.json")
local tc_sprites = tc_meta.sprites
local tc_atlas_ppm = (tc_meta.atlas_meta
and tc_meta.atlas_meta.pixels_per_meter)
or PROJECT_PIXELS_PER_METER
local tc_scale = PROJECT_PIXELS_PER_METER / tc_atlas_ppm
-- Spawn Backpack-Entity with TC_Basics `sack1` sprite. lib-core.render's
-- atlas-cache lazy-loads the texture on first draw_entities call.
local sack_uv = tc_sprites.sack1
state.backpack = composition.create{
template = "backpack",
properties = {
sprite_atlas = tc_atlas_png,
sprite_uv = {x = sack_uv.x, y = sack_uv.y,
w = sack_uv.w, h = sack_uv.h},
sprite_scale = tc_scale,
position = {x = BACKPACK_X, y = BACKPACK_Y},
},
}
-- Spawn Bed + Bench entities (v0.14.0). Static world-furniture
-- rendered via the same Atlas-UV path as the backpack.
local bed_uv = tc_sprites.single_bed1
state.bed = composition.create{
template = "bed",
properties = {
sprite_atlas = tc_atlas_png,
sprite_uv = {x = bed_uv.x, y = bed_uv.y,
w = bed_uv.w, h = bed_uv.h},
sprite_scale = tc_scale,
position = {x = BED_X, y = BED_Y},
},
}
local bench_uv = tc_sprites.bench1
state.bench = composition.create{
template = "bench",
properties = {
sprite_atlas = tc_atlas_png,
sprite_uv = {x = bench_uv.x, y = bench_uv.y,
w = bench_uv.w, h = bench_uv.h},
sprite_scale = tc_scale,
position = {x = BENCH_X, y = BENCH_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
-- Crafted-output templates: defined here (not top-level) so their
-- sprite_atlas defaults can carry the resolved atlas PNG path.
-- crafting.craft calls composition.create with no overrides, so the
-- template defaults are the only carrier for sprite_atlas / sprite_uv
-- on the crafted instance. Without this, crafted items render as a
-- "?" placeholder in the inventory and a magenta-fallback square on
-- drop (lib-core.render path-3).
-- v0.14.0: rock_pick visual switched to TC_Basics `spade`; rock_pile
-- stays on blob_rect_stone (no TC_Basics rock-stack equivalent).
local spade_uv = tc_sprites.spade
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 = tc_atlas_png,
sprite_uv = {x = spade_uv.x, y = spade_uv.y,
w = spade_uv.w, h = spade_uv.h},
sprite_scale = tc_scale,
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
local rock_pile_uv = stone_uvs["slot_13_solid"]
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 = stone_atlas_png,
sprite_uv = {x = rock_pile_uv.x, y = rock_pile_uv.y,
w = rock_pile_uv.w, h = rock_pile_uv.h},
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- plank: crafted output of `saw_planks` (crafting v0.3, ×4 multi-output).
-- Placeholder visual reuses the TC_Basics bench sprite until a dedicated
-- plank decal lands.
local plank_uv = tc_sprites.bench1
composition.define_template{
id = "plank",
properties = {
stack_mode = "individual",
name = "Plank",
description = "A sawn wooden plank.",
weight = 2, -- light: NOT re-sawable (< 5)
volume = 0.004,
["composition.wood"] = 1.0,
sprite_atlas = tc_atlas_png,
sprite_uv = {x = plank_uv.x, y = plank_uv.y,
w = plank_uv.w, h = plank_uv.h},
sprite_scale = tc_scale,
position = {x = 0, y = 0},
},
tags = {"renderable", "item"},
}
-- Workbench-Template (Phase D v0.2). Container-kind=list so it can
-- accept items via inventory.add (crafting output landing spot).
-- Placeholder visual re-uses the blob_rect_stone atlas with
-- slot_07_tee_full — visually distinct from the slots already taken
-- (00=rock, 04=stick, 05=rock_pick, 13=rock_pile). slot_07 is a
-- filled T-junction shape — dense + asymmetric, reads as a sturdy
-- piece of furniture against the rest of the in-world items. A
-- proper sprite would come from atlas-baker-decals in a future slice.
local workbench_uv = stone_uvs["slot_07_tee_full"]
composition.define_template{
id = "workbench",
properties = {
stack_mode = "individual",
name = "Workbench",
description = "A crafting bench.",
weight = 50,
volume = 100,
["composition.wood"] = 0.7,
["composition.stone"] = 0.3,
sprite_atlas = stone_atlas_png,
sprite_uv = {x = workbench_uv.x, y = workbench_uv.y,
w = workbench_uv.w, h = workbench_uv.h},
position = {x = 0, y = 0},
},
container = { kind = "list" },
tags = { "renderable", "container", "workbench", "interactable" },
}
-- 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).
-- v0.14.0: stick visual switched to TC_Basics `log_pile1`. Sprite_scale
-- via property-override since the stick template default (1.0) was set
-- before tc_scale was known.
local STICK_X, STICK_Y = BACKPACK_X - 160, BACKPACK_Y
local log_pile_uv = tc_sprites.log_pile1
state.stick = composition.create{
template = "stick",
properties = {
sprite_atlas = tc_atlas_png,
sprite_uv = {x = log_pile_uv.x, y = log_pile_uv.y,
w = log_pile_uv.w, h = log_pile_uv.h},
sprite_scale = tc_scale,
position = {x = STICK_X, y = STICK_Y},
},
}
register_pickup(state.stick)
if CI_FRAME_PERF then engine.print("vagrant: stick_spawned") end
-- Stone-Age slice: a Log (saw-able) + a Sharp Stone (cutting tool) in the
-- world, so `saw_planks` (crafting v0.3 property-constraint recipe) is
-- demonstrable end-to-end: pick both up, craft at the workbench → 4 planks.
local LOG_X, LOG_Y = BACKPACK_X - 240, BACKPACK_Y
local log_uv = tc_sprites.log_pile1
state.log = composition.create{
template = "log",
properties = {
sprite_atlas = tc_atlas_png,
sprite_uv = {x = log_uv.x, y = log_uv.y, w = log_uv.w, h = log_uv.h},
sprite_scale = tc_scale,
position = {x = LOG_X, y = LOG_Y},
},
}
register_pickup(state.log)
local SAW_X, SAW_Y = BACKPACK_X - 240, BACKPACK_Y - 70
local saw_uv = tc_sprites.spade
state.sharp_stone = composition.create{
template = "sharp_stone",
properties = {
sprite_atlas = tc_atlas_png,
sprite_uv = {x = saw_uv.x, y = saw_uv.y, w = saw_uv.w, h = saw_uv.h},
sprite_scale = tc_scale,
position = {x = SAW_X, y = SAW_Y},
},
}
register_pickup(state.sharp_stone)
if CI_FRAME_PERF then engine.print("vagrant: stoneage_spawned") end
-- Workbench spawn (Phase D v0.2). Placed at (350, 380) — 20px above
-- the backpack (at 350, 400) so they are visually distinct but still
-- on the visible-on-spawn area. register_interaction_use registers a
-- right-click ("use") proximity-trigger at the workbench position.
state.workbench = composition.create{
template = "workbench",
properties = {
position = {x = 350, y = 380},
},
}
if CI_FRAME_PERF then engine.print("vagrant: workbench_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.",
}
-- saw_planks: first property-constraint recipe (crafting v0.3 / ADR-0055).
-- Input matched by MATERIAL, not template-id: any item that is mostly wood
-- and heavy enough (a log qualifies, a plank does not). Requires a cutting
-- TOOL (sharp_stone) that is checked for presence but NOT consumed.
-- Multi-output: one log yields four planks.
crafting.define_recipe{
id = "saw_planks",
inputs = {
{match = {["composition.wood"] = ">0.5", weight = ">5"}, count = 1},
},
tools = {
{match = {["affordance.cutting"] = true}},
},
outputs = {
{template = "plank", count = 4},
},
name = "Saw Planks",
description = "Saw a wooden log into four planks (needs a cutting edge).",
}
-- v0.14.0: per-recipe icon resolver for the Crafting + Workbench-Crafting
-- widgets. Maps recipe.output.template → {atlas, uv} for the icon
-- column added in crafting-display v0.3.0. composition has no
-- template-property-getter today (ADR-0001 keeps the read-side
-- domain-free), so the module-side that DEFINES the templates is the
-- canonical place to know each output's sprite.
local craft_icons = {
rock_pick = { atlas = tc_atlas_png, uv = tc_sprites.spade },
rock_pile = { atlas = stone_atlas_png, uv = stone_uvs.slot_13_solid },
plank = { atlas = tc_atlas_png, uv = tc_sprites.bench1 },
}
local function craft_icon_for(recipe)
if recipe and recipe.output and recipe.output.template then
return craft_icons[recipe.output.template]
end
return nil
end
-- 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,
icon_resolver = craft_icon_for,
})
-- 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 = {"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")
-- ====== Workbench Crafting Widget (Phase D v0.2) ======
-- Locale-Factory returns Form-2 locale {sources, sink}: sources come
-- from discover_sources (player-backpack + workbench), sink is the
-- workbench. Crafting v0.2.0 greedy-drains sources in array-order
-- (backpack first, then workbench) before adding the output to sink.
state.workbench_crafting_widget = crafting_display.create(
function()
return {
sources = discover_sources(state.workbench),
sink = state.workbench,
}
end,
{
title = "Workbench Crafting",
widget_id = "workbench_crafting",
pause_on_open = false,
ctx_factory = function()
return { actor = state.actor, at_workbench = true }
end,
icon_resolver = craft_icon_for,
})
-- Craft action: invokes crafting.craft on the resolved locale (multi-
-- source), posts a success or warning toast. ctx_inner.locale carries
-- the per-frame-resolved Form-2 locale; we forward it verbatim.
crafting_display.register_action(state.workbench_crafting_widget,
"Craft", function(recipe_id, ctx_inner)
local r = crafting.craft(recipe_id, ctx_inner.locale,
{ actor = state.actor, at_workbench = true })
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
.. " at_workbench=1")
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)
.. " at_workbench=1")
end
end
ctx_inner.close_menu()
end)
-- Inspect action: routes recipe details to the notify detail-channel
-- (same pattern as backpack-crafting Inspect).
crafting_display.register_action(state.workbench_crafting_widget,
"Inspect", function(recipe_id, ctx_inner)
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 = {"inspect", "detail"},
text = table.concat(lines, "\n"),
severity = "info",
}
ctx_inner.close_menu()
end)
panel.register("workbench_crafting", state.workbench_crafting_widget,
{layout = "left-half"})
-- KEIN bind_default_trigger: trigger is the workbench right-click via
-- register_interaction_use(state.workbench) below.
-- ====== Workbench Inventory Widget (Phase D v0.2) ======
state.workbench_inv_widget = inv_display.create(state.workbench, {
title = "Workbench Inventory",
widget_id = "workbench_inv",
pause_on_open = false,
})
-- Take action: moves an item from workbench → backpack via
-- inventory.remove + inventory.add. Posts a transfer-event toast.
inv_display.register_action(state.workbench_inv_widget, "Take",
function(item, c)
inventory.remove(state.workbench, item)
inventory.add(state.backpack, item)
notify.post{
tags = {"transfer", "event"},
text = "Took " .. (item:get_property("name")
or composition.template_of(item) or "item"),
severity = "info",
}
c.close_menu()
if CI_FRAME_PERF then engine.print("vagrant: event=take") end
if c.refresh then c.refresh() end
end)
-- Inspect action: mirrors the backpack-inv Inspect so that workbench
-- contents have the same detail-modal payload (name + description +
-- weight + volume + composition.* materials).
inv_display.register_action(state.workbench_inv_widget, "Inspect",
function(item, c)
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("workbench_inv", state.workbench_inv_widget,
{layout = "right-half"})
-- Right-click trigger registered AFTER both workbench widgets are
-- panel.register'd; the callback panel.open()s them by id, so they
-- must already be in the panel registry.
register_interaction_use(state.workbench)
-- ====== 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", "craft", "transfer"},
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("use", { "mouse_right" }) -- Phase D right-click "use" trigger (workbench)
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()
-- All renderable furniture + items via Phase-A.2 tag-based render
-- (v0.14.0: Bed + Bench joined Backpack as composition-entities).
-- lib-core.render's atlas-cache lazy-loads each sprite_atlas path.
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
-- Debug-drive hook (headless/agent testing): when SPOREL_DEBUG_DRIVE is set,
-- expose the module state + the crafting/inventory/composition lib instances as
-- a global so lua.eval scenarios can reach the backpack, world items, and the
-- crafting API. `state` is a live table reference — its handles get populated in
-- M.init, so reads after init see the real entities. No-op in normal runs.
if os.getenv("SPOREL_DEBUG_DRIVE") then
_G.__vagrant = {
state = state,
crafting = crafting,
inventory = inventory,
composition = composition,
}
end
return M