-- 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 " 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..* 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