1416 lines
61 KiB
Lua
1416 lines
61 KiB
Lua
-- 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
|