-- sporel-module-vagrant-skeleton v0.9.1 -- Sprite-mode test-chamber: 13-bone humanoid puppet + sprite-tilemap + -- hardcoded furniture. Subterrain-style puppet control model: -- multi-look-target (aim/soft), slerp body rotation, hip-static legs, -- foot-body-orientation. Sprint (shift+wasd) + interaction (E near -- pickup-item) added v0.4.4. -- -- v0.6.0 (Phase A.4): Backpack promoted to composition-Entity (Atlas-UV -- render-path via render.draw_entities{tag="renderable"}). Bed + Bench -- remain hardcoded atlas-furniture in v0.6.0 (no interaction-trigger, -- no demo-pflicht to be entities yet). -- -- v0.7.0 (Phase A.6): Player split — actor-entity (state.actor) owns -- canonical position; puppet (state.player) syncs from actor each frame -- via puppet.move_to. Movement-Logic moves the actor; puppet renders. -- Untagged actor — render.draw_entities doesn't see it; puppet handles -- the render via puppet.render(state.player) unchanged. -- -- v0.8.0: Backpack template gains container={kind="list"}. -- End-to-end pickup/drop loop: E on rock → inventory.add(backpack, rock) -- → rock disappears from draw_entities; Q → rock reappears at player -- position. engine.print shows inventory count + item-IDs after each -- add/remove. Tree-check (CI-gated) verifies child placement in -- composition tree. -- -- v0.9.0: Inventory panel UI wired in. Tab toggles a panel overlay -- showing backpack contents. Right-click on an item row opens a context -- menu with Drop and Inspect actions. Q-drop (LIFO quick-drop) remains -- available in parallel. -- -- v0.9.1: Rock template gains player-facing properties (name, -- description, weight, volume, composition.stone). Inspect prints the -- name+description+weight+composition block to the console; future -- notification system will route this to an in-game text panel. Module- -- side label_resolver override removed — default_label_resolver reads -- the `name` property. 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 M = {} -- Walk + sprint speed tunings. local WALK_SPEED_NORMAL = 120 local WALK_SPEED_SPRINT = 240 -- 2x local ANIM_SPEED_NORMAL = 0.45 local ANIM_SPEED_SPRINT = 0.9 -- 2x -- Furniture positions (Bed/Bench remain hardcoded for v0.1; Backpack -- promoted to Inactive-Entity via composition. Bed/Bench have no -- interaction-trigger and no demo-pflicht to be entities — they stay -- atlas-furniture until a container-on-bed feature or similar triggers). local BACKPACK_X, BACKPACK_Y = 350, 400 local INTERACTION_RANGE = 40 -- pixels -- Backpack-Template (Phase A.4 — Atlas-UV render-path). -- v0.8.0: container={kind="list"} declared so the backpack receives -- items via inventory.add (capability-by-declaration). -- Real sprite_atlas + sprite_uv values are filled in M.init after the -- atlas-JSON is parsed; defaults here are placeholders so the inert- -- declaration types are correct (string + numbers). composition.define_template{ id = "backpack", properties = { sprite_atlas = "", sprite_uv = {x = 0, y = 0, w = 1, h = 1}, position = {x = 0, y = 0}, }, tags = {"renderable"}, container = { kind = "list" }, -- receives items via inventory.add } -- Rock-Template: an item sitting in the world that the player can pick up. -- sprite_atlas + sprite_uv are filled in M.init after the blob_rect_stone atlas -- is loaded; placeholder defaults here keep the inert-declaration types correct. -- slot_00_isolated is a standalone isolated stone tile — visually reads as a -- single rock on the ground. composition.define_template{ id = "rock", properties = { stack_mode = "individual", -- required by inventory-list -- Player-facing properties (item-template-convention v0.x): name = "Rock", -- shown by inventory display description = "A heavy stone. Could be useful for crafting.", weight = 2.5, -- kg (real units) volume = 0.001, -- L (real units) -- Material composition (composition-model.md §4 — 0-1 platonic; -- 1.0 = pure stone). Phase E (property-driven recipes) reads this. ["composition.stone"] = 1.0, sprite_atlas = "", sprite_uv = {x = 0, y = 0, w = 1, h = 1}, position = {x = 0, y = 0}, }, tags = {"renderable", "item"}, } -- Player-Actor-Template (Phase A.6). Vagrant's player is an actor whose -- position drives the puppet (sync each frame). NOT tagged renderable — -- the puppet handles rendering, render.draw_entities doesn't see this -- entity. movement_speed is informational; vagrant's sprint-logic uses -- its own WALK_SPEED constants directly for the per-frame move. composition.define_template{ id = "vagrant_player", properties = { position = {x = 0, y = 0}, movement_speed = 120, -- WALK_SPEED_NORMAL }, tags = {}, -- intentionally untagged: not renderable } -- Item-Template-Convention: -- An "item" is a composition-template with: -- - properties.stack_mode = "individual" (required by inventory-list) -- - properties.sprite_atlas + sprite_uv (Atlas-UV render path) -- - properties.position = {x = 0, y = 0} (world anchor) -- - tags = {"renderable", "item"} ("renderable" → drawn by -- render.draw_entities; -- "item" → reserved/visible to -- future inventory queries) -- Pickup: register_pickup(item) registers an interaction-trigger at the -- item's current position; on interact, the item moves into state.backpack -- via inventory.add (renderable-tag flips off → world stops drawing it). -- Drop: input-action "drop" pops the last item out of state.backpack, -- re-parents it to the world at the player position, and re-registers a -- pickup-trigger at the new location. -- -- Rock template (v0.7.3) and container block on backpack (v0.8.0) activate this -- convention end-to-end. The pickup/drop glue (v0.7.2) sets up the wiring. local state = { player = nil, -- puppet handle (state-bearing for animation) actor = nil, -- composition-entity owning position map = nil, world_tex = nil, -- subterrain_world atlas diffuse handle (used for bed/bench) furniture_uvs = nil, -- name -> { x, y, w, h } from atlas JSON backpack = nil, -- composition-entity, created in M.init after atlas load rock = nil, -- composition-entity (item), created in M.init after blob_rect_stone load -- 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) interaction.unregister(trigger_id) if CI_FRAME_PERF then engine.print("vagrant: event=pickup") end -- Inventory inspection trace (unconditional — user-facing demo output). local count = inventory.count(state.backpack) local items = inventory.contents(state.backpack) local ids = {} for _, it in ipairs(items) do table.insert(ids, tostring(it:get_property("composition.reg_id"))) end engine.print(string.format("vagrant: backpack count=%d items=[%s]", count, table.concat(ids, ","))) -- Tree-check (CI-gated): verify the item was actually placed as a -- child of the backpack entity in the composition tree. if CI_FRAME_PERF then local found = false for slot_name, _ in pairs(state.backpack:get_children()) do if slot_name:match("^item%.%d+$") then found = true; break end end engine.print(string.format("vagrant: tree_check_after_pickup=%s", found and "ok" or "MISSING")) end end) end -- Signed angle between two 2D vectors (in degrees, range [-180, 180]). -- Sporel uses Y-down screen-coords. Substrate's math assumes Y-up; the cross -- component is flipped so "positive num = move-direction is CCW from body-fwd -- in screen-coords" matches substrate's "positive num = walk_left sector". local function signed_angle_2d(v1x, v1y, v2x, v2y) local cross = v1y * v2x - v1x * v2y -- Y-down flip local dot = v1x * v2x + v1y * v2y return math.deg(math.atan(cross, dot)) end -- Given signed angle num_deg from body-forward to move-direction, pick the -- appropriate walk animation and compute the lower_control offset rotation (degrees). -- Substrate convention: body-forward = sprite top = -Y at rot=0. local function pick_walk_anim(num_deg) if num_deg >= -45 and num_deg <= 45 then -- Movement roughly aligns with body-forward = backpedaling return "walk_back_lower", math.max(-30, math.min(30, num_deg)) elseif num_deg > 45 and num_deg < 135 then -- Movement ~90° left of body-forward = strafe left return "walk_left_lower", math.max(60, math.min(120, num_deg)) - 90 elseif num_deg > -135 and num_deg < -45 then -- Movement ~90° right of body-forward = strafe right return "walk_right_lower", math.max(-120, math.min(-60, num_deg)) + 90 elseif num_deg > 0 then -- Movement roughly opposite body-forward, positive side = walk forward return "walk_fwd_lower", -(30 - (math.max(150, math.min(180, num_deg)) - 150)) else -- Movement roughly opposite body-forward, negative side = walk forward return "walk_fwd_lower", 30 + (math.max(-180, math.min(-150, num_deg)) + 150) end end -- Lower-body controller: legs rotate to walk direction (NOT Substrate's -- body-relative wedge). User preference: leg stride visually aligns with -- movement axis. lower_control.rot = atan(move_x, -move_y) so leg sprites -- (which oscillate scl_y along their local Y) cycle along the walk axis. -- One generic walk_fwd_lower anim suffices since the rotation handles -- the directional change (4-direction anims kept on disk for future -- gait-style differentiation). local function update_lower_anim(moving, move_x, move_y) if not moving then -- Idle: stop walk anims, resume idle_lower. Preserve last -- lower_control_rot_deg so legs keep their last orientation -- (no snap-back to north on stop). if state.walk_anim_current ~= nil then for _, a in ipairs(WALK_ANIMS) do puppet.stop(state.player, a) end state.walk_anim_current = nil puppet.play(state.player, "idle_lower", { loop = true }) puppet.stop(state.player, "walk_upper") if not puppet.is_playing(state.player, "idle") then puppet.play(state.player, "idle", { loop = true }) end if CI_FRAME_PERF and ci_last_state ~= "idle" then engine.print("vagrant: state=idle") ci_last_state = "idle" end end return end -- Walking: legs face walk direction (sprite-top convention: rot=0 = -Y). state.lower_control_rot_deg = math.deg(math.atan(move_x, -move_y)) -- Single generic walk anim (4-direction-picker deferred until we have -- differentiated gait styles per direction). local anim_id = "walk_fwd_lower" if state.walk_anim_current ~= anim_id then for _, a in ipairs(WALK_ANIMS) do if a ~= anim_id then puppet.stop(state.player, a) end end puppet.stop(state.player, "idle_lower") puppet.stop(state.player, "idle") -- Play with current speed (normal or sprint). local anim_speed = state.sprinting and ANIM_SPEED_SPRINT or ANIM_SPEED_NORMAL puppet.play(state.player, anim_id, { loop = true, speed = anim_speed }) if not puppet.is_playing(state.player, "walk_upper") then puppet.play(state.player, "walk_upper", { loop = true, speed = anim_speed }) end state.walk_anim_current = anim_id if CI_FRAME_PERF and ci_last_state ~= "walk" then engine.print("vagrant: state=walk") ci_last_state = "walk" end end end function M.init(ctx) local aliases = engine.module.asset_aliases() -- Map + sprite-tilemap. state.map = maps.load("maps/vagrant_test.map.json") maps.set_current(state.map) maps.load_textures(aliases) -- Player-Actor owns canonical position (Phase A.6). Puppet is spawned -- at the same coords and stays in sync via puppet.move_to each frame. state.actor = actor.create{ template = "vagrant_player", properties = { position = {x = 320, y = 240}, movement_speed = 120, -- WALK_SPEED_NORMAL }, } -- Puppet rig + sprite textures. local rig = puppet.load_rig("rigs/humanoid.rig.json", aliases) state.player = puppet.spawn(rig, { x = 320, y = 240 }) -- Load all animations. local walk_fwd = puppet.load_animation("animations/walk_fwd_lower.anim.json", rig) local walk_back = puppet.load_animation("animations/walk_back_lower.anim.json", rig) local walk_left = puppet.load_animation("animations/walk_left_lower.anim.json", rig) local walk_right = puppet.load_animation("animations/walk_right_lower.anim.json", rig) local walk_upper = puppet.load_animation("animations/walk_upper.anim.json", rig) local idle = puppet.load_animation("animations/idle.anim.json", rig) local idle_lower = puppet.load_animation("animations/idle_lower.anim.json", rig) for _, a in ipairs({walk_fwd, walk_back, walk_left, walk_right, walk_upper, idle, idle_lower}) do puppet.register_animation(state.player, a) end -- Start in idle state. puppet.play(state.player, "idle", { loop = true }) puppet.play(state.player, "idle_lower", { loop = true }) -- Procedural leg+foot orientation: -- * Hips are body-relative (legs.parent=body, position cascade follows body). -- * leg.world.rot = walk_dir (independent of body). Set leg.local.rot = -- walk_dir - body.world.rot so cascade gives correct world rot. -- * foot.world.rot = body.world.rot (user pref: feet stay body-oriented -- even when legs swing to walk-direction = "tactical twist"). Cascade: -- foot.world.rot = leg.world.rot + foot.local.rot = body.rot. -- → foot.local.rot = body.rot - leg.world.rot = body.rot - walk_dir -- = -leg_local_deg. -- Per-channel guard v0.4.2 allows leg.rot + foot.rot writes alongside -- walk_fwd_lower (which only writes leg.scl). puppet.set_procedural(state.player, "leg_foot_orientation", function(handle, dt) local _, _, body_rot = puppet.bone_world_transform(handle, "body") local leg_local_deg = state.lower_control_rot_deg - math.deg(body_rot) local foot_local_deg = -leg_local_deg puppet.write_bone(handle, "leg_l", { rot = leg_local_deg }) puppet.write_bone(handle, "leg_r", { rot = leg_local_deg }) puppet.write_bone(handle, "foot_l", { rot = foot_local_deg }) puppet.write_bone(handle, "foot_r", { rot = foot_local_deg }) end) -- Furniture atlas (subterrain_world): load JSON + diffuse texture once. local world_lib = aliases.subterrain_world local world_base = world_lib .. "/assets/atlases/subterrain_world" local world_atlas_png = world_base .. "/tiles.diffuse.atlas.png" local world_meta = engine.asset.load_json(world_base .. "/tiles.atlas.json") state.world_tex = engine.asset.load_texture(world_atlas_png) state.furniture_uvs = {} for _, t in ipairs(world_meta.tiles) do state.furniture_uvs[t.name] = { x = t.uv[1], y = t.uv[2], w = t.uv[3], h = t.uv[4] } end -- Spawn Backpack-Entity with real Atlas-UV values now that the -- atlas-json is parsed. lib-core.render's atlas-cache will load -- the texture lazy-on-first-draw (separate from state.world_tex -- which serves bed/bench's direct draw_sprite_transform call; -- ineffizient aber kein-Schaden, single-texture-handle-merge ist -- ein späterer Slice). local backpack_uv = state.furniture_uvs["backpack_001"] state.backpack = composition.create{ template = "backpack", properties = { sprite_atlas = world_atlas_png, sprite_uv = {x = backpack_uv.x, y = backpack_uv.y, w = backpack_uv.w, h = backpack_uv.h}, position = {x = BACKPACK_X, y = BACKPACK_Y}, }, } -- Blob-rect-stone atlas: load JSON + diffuse texture for the rock sprite. -- blob_rect_stone alias resolves to lib-asset.prototype-blob-geom. The -- "slot_00_isolated" tile is the standalone isolated stone shape — reads as -- a single rock on the ground. Separate atlas/texture from world atlas -- (lib-core.render's atlas-cache de-duplicates on path, no wasted load). local stone_lib = aliases.blob_rect_stone local stone_base = stone_lib .. "/assets/atlases/blob_rect_stone" local stone_atlas_png = stone_base .. "/tiles.diffuse.atlas.png" local stone_meta = engine.asset.load_json(stone_base .. "/tiles.atlas.json") local stone_uvs = {} for _, t in ipairs(stone_meta.tiles) do stone_uvs[t.name] = { x = t.uv[1], y = t.uv[2], w = t.uv[3], h = t.uv[4] } end -- Spawn Rock-Entity 80px to the left of the backpack so it sits in a -- distinct position: visible to the player on first load, not overlapping -- the backpack, reachable by walking left from spawn. local ROCK_X, ROCK_Y = BACKPACK_X - 80, BACKPACK_Y local rock_uv = stone_uvs["slot_00_isolated"] state.rock = composition.create{ template = "rock", properties = { sprite_atlas = stone_atlas_png, sprite_uv = {x = rock_uv.x, y = rock_uv.y, w = rock_uv.w, h = rock_uv.h}, position = {x = ROCK_X, y = ROCK_Y}, }, } register_pickup(state.rock) if CI_FRAME_PERF then engine.print("vagrant: rock_spawned") end -- 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 end) -- Inspect action: prints player-facing item info to console. Future -- notification-system (lib-core.toast or similar) will route this to -- an in-game text panel instead of engine.print. inv_display.register_action(backpack_widget, "Inspect", function(item, c) local name = item:get_property("name") or "(unnamed)" local description = item:get_property("description") or "" local weight = item:get_property("weight") or 0 local volume = item:get_property("volume") or 0 engine.print(string.format("=== %s ===", name)) if description ~= "" then engine.print(description) end engine.print(string.format("weight=%.2fkg volume=%.3fL", weight, volume)) -- Surface material composition by iterating composition.* properties. local props = item:get_properties() local mats = {} for k, v in pairs(props) do local mat = k:match("^composition%.(.+)$") if mat and mat ~= "reg_id" then table.insert(mats, string.format("%s=%.2f", mat, v)) end end if #mats > 0 then engine.print("composition: " .. table.concat(mats, " ")) end engine.print(string.format("(reg_id=%s)", tostring(item:get_property("composition.reg_id")))) c.close_menu() end) panel.register("backpack", backpack_widget) panel.bind_default_trigger("tab", "backpack") -- Input bindings. input.bind("walk_left", { "a" }) input.bind("walk_right", { "d" }) input.bind("walk_up", { "w" }) input.bind("walk_down", { "s" }) input.bind("sprint", { "shift" }) -- held while walking → 2x speed input.bind("interact", { "e" }) -- fires nearest proximity-trigger input.bind("drop", { "q" }) -- drop most-recent item from backpack input.bind("quit_to_launcher", { "escape" }) -- Note: no backpack proximity-trigger registered here. The backpack is now -- a container; interaction is driven by the rock's register_pickup -- trigger. The old backpack-print callback has been removed. -- CI trace: assets loaded count (4 atlas textures: player + tiles + world + stone). if CI_FRAME_PERF and not ci_assets_traced then engine.print("vagrant: assets_loaded=4") ci_assets_traced = true end if CI_FRAME_PERF then ci_t_start = engine.time.now() end end function M.update(ctx, dt) if input.was_action_pressed("quit_to_launcher") then engine.switch_module("lib-management.launcher") return end -- Inventory panel input handling panel.update(dt) if panel.is_pausing() then return end -- 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 -- Inventory inspection trace (unconditional — user-facing demo output). local count = inventory.count(state.backpack) local remaining_items = inventory.contents(state.backpack) -- re-read post-remove local ids = {} for _, it in ipairs(remaining_items) do table.insert(ids, tostring(it:get_property("composition.reg_id"))) end engine.print(string.format("vagrant: backpack count=%d items=[%s]", count, table.concat(ids, ","))) -- Tree-check (CI-gated): verify the backpack has no item.* children -- after drop (item was re-parented to world). if CI_FRAME_PERF then local found = false for slot_name, _ in pairs(state.backpack:get_children()) do if slot_name:match("^item%.%d+$") then found = true; break end end engine.print(string.format("vagrant: tree_check_after_drop=%s", found and "MISSING" or "ok")) end end end -- WASD input → normalized move vector. local dx, dy = 0, 0 if input.is_action_down("walk_left") then dx = dx - 1 end if input.is_action_down("walk_right") then dx = dx + 1 end if input.is_action_down("walk_up") then dy = dy - 1 end if input.is_action_down("walk_down") then dy = dy + 1 end local moving = (dx ~= 0 or dy ~= 0) -- Sprint = shift held + actually moving. Updated BEFORE move so the -- selected speed matches; also feeds update_lower_anim's anim-speed -- pick at walk-start transitions. local sprint_held = input.is_action_down("sprint") state.sprinting = moving and sprint_held local current_walk_speed = state.sprinting and WALK_SPEED_SPRINT or WALK_SPEED_NORMAL -- CI_DRIVE: synthetic scripted move (preserve existing P0-S25b semantics). if CI_DRIVE then ci_drive_frame = ci_drive_frame + 1 if ci_drive_frame > 20 and ci_drive_frame <= 40 then dx = 1; dy = 0 moving = true actor.move(state.actor, dx * WALK_SPEED_NORMAL * dt, 0) else moving = false end end -- Move actor (position-owner). Puppet syncs from actor below. if moving and not CI_DRIVE then local len = math.sqrt(dx * dx + dy * dy) state.move_x = dx / len state.move_y = dy / len actor.move(state.actor, state.move_x * current_walk_speed * dt, state.move_y * current_walk_speed * dt) elseif not moving then state.move_x = 0 state.move_y = 0 end -- Sync puppet position from actor (Phase A.6: actor is source-of-truth). -- Done once per frame after movement, before puppet.update consumes -- position for animation. puppet.move_to(state.player, actor.position(state.actor)) -- CI_DRIVE move_x/move_y for anim selection. if CI_DRIVE and moving then state.move_x = dx state.move_y = dy end update_lower_anim(moving, state.move_x, state.move_y) -- Sprint toggle mid-walk: adjust play-speed of running anims without -- restarting their cycle (puppet v0.4.4 set_play_speed). Silent no-op -- if anim not currently playing. local anim_speed = state.sprinting and ANIM_SPEED_SPRINT or ANIM_SPEED_NORMAL puppet.set_play_speed(state.player, "walk_fwd_lower", anim_speed) puppet.set_play_speed(state.player, "walk_upper", anim_speed) -- Look-targets: head tracks raw mouse (aim), body tracks same for now (soft). local mx, my = engine.input.get_mouse_pos() local wx, wy = camera.screen_to_world(mx, my) puppet.set_look_target(state.player, "aim", wx, wy) puppet.set_look_target(state.player, "soft", wx, wy) -- Interaction: dispatches the nearest matching proximity-trigger when -- 'interact' was pressed this frame. Uses actor.position as anchor. if not panel.is_open() then interaction.update(actor.position(state.actor)) end -- Camera follows actor position each frame. camera.set_target(actor.position(state.actor)) camera.update(dt) puppet.update(dt) if CI_FRAME_PERF and ci_t_start then ci_frame_count = ci_frame_count + 1 if ci_frame_count == 60 then local elapsed_ms = (engine.time.now() - ci_t_start) * 1000.0 local avg = elapsed_ms / 60.0 print(string.format("vagrant: frame_avg_ms=%.2f", avg)) engine.exit(0) end end end function M.draw(ctx) camera.begin() maps.draw_map_pre_entities() -- Bed + Bench remain hardcoded furniture (no interaction-trigger → -- no entity-Pflicht in Phase A; see Backpack-Template comment). local bed_uv = state.furniture_uvs["bed_001"] local bench_uv = state.furniture_uvs["bench_001"] engine.render.draw_sprite_transform(state.world_tex, 200, 150, 0, 1, 1, bed_uv.w / 2, bed_uv.h / 2, 0xFFFFFFFF, bed_uv.x, bed_uv.y, bed_uv.w, bed_uv.h) engine.render.draw_sprite_transform(state.world_tex, 400, 300, 0, 1, 1, bench_uv.w / 2, bench_uv.h / 2, 0xFFFFFFFF, bench_uv.x, bench_uv.y, bench_uv.w, bench_uv.h) -- Backpack (and any other renderable entity) via Phase-A.2 tag-based -- render. The atlas-cache loads tiles.diffuse.atlas.png lazy-on-first -- call (separate handle from state.world_tex — see M.init note). r_lib.draw_entities{ tag = "renderable" } -- Puppet (sprite-mode via texture_handles populated by load_rig+load_textures). puppet.render(state.player) maps.draw_map_post_entities() camera.finish() if CI_FRAME_PERF then engine.print("vagrant: render_frame_ok") if not ci_sprite_traced then engine.print("vagrant: sprite_mode=on") ci_sprite_traced = true end end -- Inventory panel overlay (z-top: drawn after all world content). panel.render() end -- Engine lifecycle globals (engine looks up by name; module-table is bridged). function init(ctx) M.init(ctx) end function update(ctx, dt) M.update(ctx, dt) end function render(ctx) M.draw(ctx) end return M