commit 2817613490692a7905781af1bb9269139d1406f9 Author: Axel Meyer Date: Sun May 17 20:21:31 2026 +0200 feat: initial implementation v0.1.0 — single player puppet + WASD + map Composer module: single-player character as puppet (humanoid 4-bone rig: torso, head, leg_l, leg_r) at world (320, 240) on a 20x20 v0.1-format tilemap. - WASD direct movement, normalized for diagonals, 120 px/s. - Camera-chase via lib-core.camera target-provider (camera follows puppet position frame-to-frame). - Idle and walk keyframe animations: idle is a 2s torso-sway on the upper track; walk is a 0.6s leg-cycle on the lower track. - Animation-state transitions driven by movement input: walk while WASD-active, idle when released. - Headtrack: head bone (marked look_at: true in rig) rotates each frame to face the mouse cursor's world position via puppet.set_look_target. - ESC -> launcher. CI hooks (env-var-gated, no-op in normal play): - SPOREL_CI=1: emits 'vagrant: frame_avg_ms=N' after 60 frames and 'vagrant: render_frame_ok' per frame from the render hook, then engine.exit(0). Smoke stages consume these markers. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81c9d65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2026 Calic. All rights reserved. + +This software is part of the Sporel platform — **Tier 1 (Official / +Proprietary)** content per the Three-Tier Licensing Model documented in +`meta/docs/archive/design/vision.md §Licensing Model` (current source; +migration to `meta/docs/architecture/licensing-model.md` pending). + +⚠ **WIP — Legal review required before public launch.** The terms below +reflect design intent only; the formalized license framework will be +finalized through legal counsel before the first public release. Until +then, this notice serves as a placeholder defending the platform owner's +rights against unintentional re-licensing. + +No license is granted to copy, modify, distribute, sublicense, or otherwise +use this software in any form without prior written permission from the +copyright holder. + +References: +- Tier 1 (this file): all rights reserved, proprietary, sold/distributed + via official channels (Steam, etc.) +- Tier 2 (Semi-Commercial Co-Development): bilateral contracts, revenue- + share — see vision.md §Licensing Model +- Tier 3 (Community Content): CC BY-NC-SA 4.0 + asymmetric CLA — applies + to community-uploaded libs/modules/assets, not this repo diff --git a/README.md b/README.md new file mode 100644 index 0000000..bea1de5 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# sporel-module-vagrant-skeleton + +F.vagrant Tier-1 test-chamber: single player as puppet, WASD direct +movement, camera-chase, headtrack to mouse-cursor. Phase 1 stage. + +**Version:** 0.1.0 +**Module-ID:** vagrant-skeleton +**Requires:** lib-core.input >=0.4.0, lib-core.camera >=0.3.0, lib-core.render >=0.1.0, lib-core.maps >=0.1.1, lib-core.puppet >=0.1.0 +**Tags:** test-chamber, puppet, walking-sim + +## Topology + + + + +## Controls + +- **W A S D** — direct movement (4-way, normalized for diagonals) +- **Mouse** — head/torso look-at target +- **ESC** — return to launcher + +## Demonstrates + +- lib-core.puppet integration (skeleton, keyframe animation, procedural-ready, look-at constraint) +- idle <-> walk animation-state transition driven by movement input +- Camera-chase via lib-core.camera target-provider +- Throwaway-content test-chamber stance: existing maps v0.1 format, + hand-painted humanoid rig + 2 keyframe animations. + +## Interactions + +- WASD active -> walk animation on lower track +- WASD released -> idle animation on upper track (subtle torso sway) +- Mouse moved -> head-bone angle updates each frame to face cursor's + world-position (look-at constraint on bones marked `look_at: true`) + +## CI Hooks + +- `SPOREL_CI=1` -> 60-frame perf canary. Emits `vagrant: frame_avg_ms=N` + and `vagrant: render_frame_ok` (per-frame, from render hook), then exits. + Used by smoke stages P0-S25a + P0-S25b. + +## References + +- ADR-0028 (Launcher-Flow) +- ADR-0036 (Lib-Override for multi-module reuse) +- ADR-0037 (Tests-as-Libs) +- ADR-0038 (API-Doc-Convention) diff --git a/animations/idle.anim.json b/animations/idle.anim.json new file mode 100644 index 0000000..247ec07 --- /dev/null +++ b/animations/idle.anim.json @@ -0,0 +1,11 @@ +{ + "id": "idle", + "track": "upper", + "duration": 2.0, + "loop": true, + "keyframes": [ + { "t": 0.0, "torso": { "angle": 0 } }, + { "t": 1.0, "torso": { "angle": 3 } }, + { "t": 2.0, "torso": { "angle": 0 } } + ] +} diff --git a/animations/walk.anim.json b/animations/walk.anim.json new file mode 100644 index 0000000..9ffb729 --- /dev/null +++ b/animations/walk.anim.json @@ -0,0 +1,11 @@ +{ + "id": "walk", + "track": "lower", + "duration": 0.6, + "loop": true, + "keyframes": [ + { "t": 0.0, "leg_l": { "angle": -15 }, "leg_r": { "angle": 15 } }, + { "t": 0.3, "leg_l": { "angle": 15 }, "leg_r": { "angle": -15 } }, + { "t": 0.6, "leg_l": { "angle": -15 }, "leg_r": { "angle": 15 } } + ] +} diff --git a/assets/tiles/vagrant_test.tilemap.json b/assets/tiles/vagrant_test.tilemap.json new file mode 100644 index 0000000..495fdb5 --- /dev/null +++ b/assets/tiles/vagrant_test.tilemap.json @@ -0,0 +1,8 @@ +{ + "id": "vagrant_test", + "tile_size": 32, + "tiles": [ + { "id": "grass", "walkable": true, "color": [110, 170, 80] }, + { "id": "stone", "walkable": false, "color": [120, 120, 120] } + ] +} diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..44ff65a --- /dev/null +++ b/init.lua @@ -0,0 +1,130 @@ +-- sporel-module-vagrant-skeleton v0.1.0 +-- F.vagrant Tier-1 Phase 1: single-player + puppet + WASD + maps v0.1. + +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 M = {} + +local state = { + player = nil, + player_speed = 120, + map = nil, +} + +-- CI hook gating (mirrors squad-prototype convention) +local CI_FRAME_PERF = (os.getenv("SPOREL_CI") == "1") +local ci_frame_count = 0 +local ci_t_start = nil + +local function set_walk_or_idle(moving) + if moving then + if not puppet.is_playing(state.player, "walk") then + puppet.stop(state.player, "idle") + puppet.play(state.player, "walk", { loop = true }) + end + else + if not puppet.is_playing(state.player, "idle") then + puppet.stop(state.player, "walk") + puppet.play(state.player, "idle", { loop = true }) + end + end +end + +function M.init(ctx) + state.map = maps.load("maps/vagrant_test.map.json") + maps.set_current(state.map) + + -- Load puppet rig + animations + local rig = puppet.load_rig("rigs/humanoid.rig.json") + state.player = puppet.spawn(rig, { x = 320, y = 240 }) + + local idle = puppet.load_animation("animations/idle.anim.json", rig) + local walk = puppet.load_animation("animations/walk.anim.json", rig) + puppet.register_animation(state.player, idle) + puppet.register_animation(state.player, walk) + puppet.play(state.player, "idle", { loop = true }) + + -- Input bindings (WASD direct movement + ESC) + input.bind("walk_left", { "a" }) + input.bind("walk_right", { "d" }) + input.bind("walk_up", { "w" }) + input.bind("walk_down", { "s" }) + input.bind("quit_to_launcher", { "escape" }) + + -- Camera follows player position (no manual pan in vagrant-style) + camera.set_target_provider(function() + return puppet.position(state.player) + 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-sporel.launcher") + return + end + + 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) + if moving then + local len = math.sqrt(dx * dx + dy * dy) + local pos = puppet.position(state.player) + puppet.move_to(state.player, + pos.x + dx / len * state.player_speed * dt, + pos.y + dy / len * state.player_speed * dt) + end + set_walk_or_idle(moving) + + -- Headtrack: head looks at mouse world-position + local mx, my = engine.input.get_mouse_pos() + local wx, wy = camera.screen_to_world(mx, my) + puppet.set_look_target(state.player, wx, wy) + + camera.update(dt) + puppet.update(dt) + + -- CI: perf canary. After 60 frames emit avg ms, exit. + 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) + r_lib.draw_map() + puppet.render(state.player) + -- Render-hook trace for smoke (P0-S25a). MUST emit from render(), not update(). + -- Lesson from P.4.c: smoke stages that exit in update never prove the render + -- hook works. + if CI_FRAME_PERF then + engine.print("vagrant: render_frame_ok") + end +end + +-- ==================================================================== +-- Engine lifecycle hooks (global functions; engine calls these by name) +-- See P.4.c discovery: engine looks up lua_getglobal "init", "update", +-- "render" — not M.init etc. The module-table pattern is bridged here. +-- ==================================================================== +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 diff --git a/manifest.core b/manifest.core new file mode 100644 index 0000000..1fad734 --- /dev/null +++ b/manifest.core @@ -0,0 +1,6 @@ +{ + "id": "vagrant-skeleton.core", + "kind": "lib", + "version": "0.1.0", + "license": "Proprietary" +} diff --git a/manifest.module b/manifest.module new file mode 100644 index 0000000..c97e0a6 --- /dev/null +++ b/manifest.module @@ -0,0 +1,15 @@ +{ + "id": "vagrant-skeleton", + "version": "0.1.0", + "kind": "module", + "api": "^0.1", + "ci_frames": 60, + "entry_point": "init.lua", + "deps": [ + {"id": "lib-core.input", "version": "0.4.0"}, + {"id": "lib-core.camera", "version": "0.3.0"}, + {"id": "lib-core.render", "version": "0.1.0"}, + {"id": "lib-core.maps", "version": "0.1.1"}, + {"id": "lib-core.puppet", "version": "0.1.0"} + ] +} diff --git a/maps/vagrant_test.map.json b/maps/vagrant_test.map.json new file mode 100644 index 0000000..a9bb6e8 --- /dev/null +++ b/maps/vagrant_test.map.json @@ -0,0 +1,27 @@ +{ + "id": "vagrant_test", + "tilemap": "vagrant_test", + "size": { "w": 20, "h": 20 }, + "tiles": [ + 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2, + 2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2 + ] +} diff --git a/rigs/humanoid.rig.json b/rigs/humanoid.rig.json new file mode 100644 index 0000000..fff6323 --- /dev/null +++ b/rigs/humanoid.rig.json @@ -0,0 +1,13 @@ +{ + "id": "humanoid", + "bones": [ + { "id": "torso", "parent": null, "rest": { "x": 0, "y": 0, "angle": 0 }, "color": [180, 140, 100] }, + { "id": "head", "parent": "torso", "rest": { "x": 0, "y": -16, "angle": 0 }, "look_at": true, "color": [220, 180, 140] }, + { "id": "leg_l", "parent": "torso", "rest": { "x": -4, "y": 8, "angle": 0 }, "color": [80, 60, 40] }, + { "id": "leg_r", "parent": "torso", "rest": { "x": 4, "y": 8, "angle": 0 }, "color": [80, 60, 40] } + ], + "tracks": [ + { "id": "lower", "bones": ["leg_l", "leg_r"] }, + { "id": "upper", "bones": ["torso", "head"] } + ] +}