Commit Graph

23 Commits

Author SHA1 Message Date
Axel Meyer
4448fc7a29 feat(puppet): v0.4.3 — inherit_scale=false flag (decouple world.scl)
New optional bone field 'inherit_scale' (default true). When set false,
world.scl = local.scl (no parent.scl multiplier). Position cascade still
uses parent.scl (so child follows parent's stretch positionally).

Use case: feet that move with leg-end as leg.scl_y cycles +1/0/-1, but
foot sprite stays at constant scale (no Y-flip when leg flips, no
collapse when leg collapses).
2026-05-18 22:31:39 +02:00
Axel Meyer
0b41e8efb7 fix(puppet): v0.4.2 — per-channel write_bone conflict (was per-track)
Allow procedural callbacks to write channels that no active anim
writes on the same bone. Example: walk_anim writes leg.scl; procedural
can now write leg.rot without track-conflict error. Previously the
guard was track-level (any anim on the track blocked ALL writes).

Enables hip-anchor-stable + leg-orientation-procedural pattern in
vagrant-skeleton (legs parent=body for body-relative hips, leg.rot
overridden per-frame to walk-direction while walk_fwd_lower drives
leg.scl).

Existing Group 4 track-conflict test still passes: walk_anim writes
leg.rot in keyframes; procedural-write of leg.rot still errors (same
channel). 42/42 assertions GREEN.
2026-05-18 21:57:01 +02:00
Axel Meyer
fadaf3b8a3 fix(puppet): v0.4.1 — normalize target_local before clamp (south-flick fix)
When body's world.rot crosses ±π (e.g., body facing south), target_local
in the look-at-block could momentarily be ≈±2π. Without normalization,
the snap-branch wrote that raw value to state.angle, then rot_min/rot_max
clamp produced a discontinuous jump (head 'flicked sideways' when body
faced south, per manual interactive test).

Fix: normalize target_local to (-π, π] BEFORE the snap/slerp/rate-limit
branch and BEFORE clamp. Symmetric handling regardless of which smoothing
mode the bone uses.
2026-05-18 21:47:34 +02:00
Axel Meyer
cd639668c5 feat(puppet): v0.4.0 — Subterrain control model
Multi-look-target API: set_look_target(handle, target_id, x, y),
clear_look_target(handle, target_id). Bones declare look_at as string
target-id; legacy look_at=true maps to target_id="default". Slerp-style
smoothing: look_at_slerp field (exponential approach, clamped to [0,1]
per frame). Linear look_at_speed kept for backward-compat. Virtual bone
flag: virtual=true skips render pass but participates in compute_world.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 21:18:52 +02:00
Axel Meyer
6e90bfa8fe feat(puppet): v0.3.3 — sprite_rot per-bone render offset
Adds optional 'sprite_rot' field (degrees in JSON, radians stored) to
bone schema. Applied at render: world.rot + sprite_rot. Substrate
convention (puppet.c:295, 2101). Use case: head_001.png has face-right
at rot=0 (asymmetric sprite); without sprite_rot=-90 the look-at result
appears 90° off because math computes rotation assuming sprite-top=
forward but the sprite's natural forward is sprite-right.

Default sprite_rot=0 keeps body, shoulder, arm bones unchanged.
2026-05-18 20:42:01 +02:00
Axel Meyer
a64055ca2a fix(puppet): v0.3.2 — persistent look_at_state across frames
Previously the look-at-rate-limit read p.bone_state[bid].rot as 'current'
and advanced from it. But bone_state.rot is reset-to-rest + keyframe-
sampled at frame start, so 'current' was always near rest (~0) and the
rate-limit only advanced one frame's worth (~7deg) per frame. Visual
result: head/body rotation appeared capped at ~10deg.

Fix: persistent p.look_at_state[bid] table carries angle across frames.
Rate-limit operates on look_at_state.angle (not bone_state.rot). After
rate-limit + clamp, value is written to bone_state.rot.

Init: look_at_state[bid].angle = b.rest.rot on first encounter, so the
first frame advances from rest pose toward target (matches substrate
LookAtState init convention).
2026-05-18 20:13:22 +02:00
Axel Meyer
b8b8bf1a76 fix(puppet): v0.3.1 — atan2 sprite-top + rot limits + speed + write_bone channels
- Look-at uses math.atan(dx, -dy) (substrate sprite-top convention):
  rot=0 means sprite top points up (in -Y), rot=pi/2 means right.
- New rest.rot_min/rot_max fields (degrees in JSON; clamped after look-at)
- New look_at_speed field (deg/s; rate-limits look-at via dt)
- write_bone now accepts rot/pos/scl channels (legacy 'angle' rejected)
- Bones not in any track are allowed; write_bone bypasses track-guard
  for them. Enables procedural root-rotation while keyframe-anims play.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:51:37 +02:00
Axel Meyer
96ccb410d3 feat(puppet): v0.3.0 — idle-return locomotion + version bump
- apply_locomotion idle-return: after (now - last_move_time) >
  idle_return_delay, the most-displaced planted leg steps back to its
  current hip position (over idle_step_duration). Group-stagger via
  bumping last_move_time forward by idle_return_stagger.
- M.move_to tracks p.last_move_time when position actually changes.
- README v0.3.0 changelog entry.
2026-05-18 18:34:42 +02:00
Axel Meyer
9882388097 feat(puppet): add test-only APIs for locomotion + animated-flag
- set_foot_sprite_reach_test_only(handle, leg_index, value)
- foot_state_test_only(handle, leg_index)
- bone_animated_test_only(handle, bone_id)

Test-only helpers (not for production use) to enable headless unit tests
of the procedural-locomotion-layer and bone_animated keyframe-priority
flag. Used by puppet-test Groups 9-11.
2026-05-18 18:31:59 +02:00
Axel Meyer
9519897ba2 feat(puppet): procedural-locomotion layer (substrate port)
- build_rig parses optional rig.locomotion block (mode/step_trigger/
  step_placement/step_duration/step_height/idle_return_delay/legs[])
- spawn initializes per-puppet foot_state[] table (planted/stepping
  state, anchor/cur world-positions, sprite_reach from leg-texture)
- apply_locomotion (substrate puppet.c:1000-1156 port): step-trigger
  on hip-displacement > step_trigger; smoothstep step-progression with
  arc-height; leg-aim via atan2(-dx, dy); leg-stretch via scl_y clamp
  [0.2, 2.5]; foot world-pos override
- Skips legs where bone_animated is set on leg or foot (keyframe priority)
- Integrated as step 4 in M.update post-cascade
- load_textures caches texture_width/height per bone for sprite_reach

No new tests yet (added in Phase 3e). Existing 24 assertions remain
GREEN (locomotion is no-op for rigs without locomotion-block).
2026-05-18 18:29:00 +02:00
Axel Meyer
7b6d3b3b70 feat(puppet): add bone_world_scale public API
Returns cascaded world.scl_x and scl_y for a bone. Used by puppet-test
Group 8 scale-cascade assertions; future: vagrant-skeleton or other
modules can query world-scale for compositing/effects.
2026-05-18 18:25:47 +02:00
Axel Meyer
dbfd65fa2a feat(puppet): render-path uses cascaded world.scl + world.rot
Previously the render path read per-bone constant `b.scale` and
`ws.angle`. With multi-channel keyframes + scale-cascade, the rendered
sprite-scale comes from the cascaded `world.scl_x/y` and angle from
`world.rot`. No new tests — visual verification at Phase-4 manual test.
2026-05-18 18:21:13 +02:00
Axel Meyer
8a293f64c6 feat(puppet): cascaded world-transform + multi-channel keyframes
- BoneState extended with pos/scl (per-frame override channels) and
  world.scl_x/y (cascaded world-scale)
- compute_world() ports substrate puppet.c:692-718 cascade: local.pos
  multiplied by parent.world.scl before rotation+translation;
  world.scl = parent.world.scl * local.scl
- build_animation parses rot/pos/scl channels; rejects legacy 'angle'
- sample_animation interpolates each channel independently;
  single-side-keyframe holds the present value
- look_at math now scale-aware (uses parent.world.scl in bone-world-pos)
- build_rig rejects legacy rest.angle; requires rest.rot + optional rest.scl
- bone-rig top-level 'scale' field removed; per-frame state replaces it

17 existing puppet-test assertions remain GREEN after fixture migration.
2026-05-18 18:19:59 +02:00
Axel Meyer
cff7986cde feat: add load_textures + extend load_rig with asset_aliases
puppet.load_textures(rig, asset_aliases):
- Resolves bone.texture atlas-ids to texture-handles via the asset-
  lib indirection. Reads asset_aliases[rig.asset_pack] to get the
  asset-lib id, loads the lib's atlas.json, looks up the atlas-entry
  by id, then calls engine.asset.load_texture on the resolved file
  path.
- Resolves bone.anchor from the atlas entry unless the bone has an
  explicit anchor override.
- Raises clear errors when alias/atlas-pack/atlas-id is missing.

puppet.load_rig(path, asset_aliases?) now optionally calls
load_textures after build_rig if asset_aliases is supplied.

README updated with new API entries (load_rig signature, load_textures,
bone_world_transform, write_bone_test_only) and CHANGELOG v0.2.0 entry.
2026-05-18 03:09:26 +02:00
Axel Meyer
7061ea079e feat: sprite-mode render with z_order sort and color-rect fallback
Walks bones in z-sorted order (rig.bones_z_sorted, lowest first =
back). For each bone:
- If texture_handle present: draw via engine.render.draw_sprite_transform
  using bone.scale and bone.anchor (negative scale mirrors the sprite,
  anchor is the in-texture pivot).
- Else: fall back to colored rotated 16x4 rect via draw_rect_rotated
  (Phase 1 behavior preserved).

bone_state[bid].world (cached by M.update's depth-first pipeline) is
the source of (x, y, angle).
2026-05-18 03:07:00 +02:00
Axel Meyer
a244f1585d feat: multi-level parent-chain transform with depth-first pipeline
Rewrites M.update to traverse the rig depth-first, computing each
bone's world-transform from its parent's already-cascaded world-pose
plus its own rest-offset and animated angle. Replaces the previous
single-level direct-from-origin transform.

Per-frame per-bone steps inside the traversal:
1. Sample keyframe from any playing anim on the bone's track
2. Apply look-at (uses parent's world from cascade)
3. Run procedural callbacks (once per puppet at root visit)
4. Compute and cache world-transform from parent.world + own rest + own angle

bone_state[bid].world = {x, y, angle} is the cache; render reads it.

New public API:
- puppet.bone_world_transform(handle, bone_id) -> (wx, wy, wa)
- puppet.write_bone_test_only(handle, bone_id, angle_rad) for test setup

Look-target previously interpreted as world-coords with bone-world-pos
= puppet origin + bone.rest. New implementation computes bone-world-pos
from cascaded parent transform, then subtracts the parent's accumulated
world-angle to store the LOCAL angle (so subsequent cascade in step 3.d
produces the correct final world-angle for the look-at bone).

spawn now initializes bone_state[bid].world = {x, y, angle} cache.
2026-05-18 03:03:21 +02:00
Axel Meyer
8013516ce8 feat: extend rig-format with texture/z_order/scale/anchor fields
Additive extension to build_rig output, preserving backward-compat
with existing rigs that lack the new fields:

- bone.texture: optional atlas-id (string) for sprite-mode rendering;
  resolved to a texture-handle by puppet.load_textures(). When absent
  the bone falls back to colored-rect render via draw_rect_rotated.
- bone.z_order: render-sort key (lower = back, higher = front);
  default 0.
- bone.scale: [sx, sy] pair; default [1, 1]; negative components
  mirror the sprite.
- bone.anchor: optional [x, y] override of the atlas-supplied anchor.
- rig.asset_pack: alias-key into the consumer module's asset_aliases
  manifest field; lets puppet.load_textures resolve atlas-ids to
  texture-handles via the asset-lib.
- rig.bones_z_sorted: array of bones pre-sorted by z_order; render
  walks this once per frame.

Version bumped to 0.2.0. Subsequent commits add multi-level parent-
chain transform, sprite-mode render, and load_textures.
2026-05-18 02:53:47 +02:00
Axel Meyer
475f60da21 fix(puppet): store all bone angles in radians for render consistency
The pipeline now stores radians uniformly in bone_state, matching the
look-at branch's math.atan output and the render path's draw_rect_rotated
expectations. JSON degrees are converted at the boundary (build_animation,
write_bone). sample_animation returns radians.
2026-05-17 20:17:06 +02:00
Axel Meyer
34c53f9980 fix(puppet): align angle storage with test expectations
Three fixes to make puppet-test 14/14 assertions pass:

1. build_animation/sample_animation: store and interpolate keyframe
   angles in degrees (remove premature math.rad conversion). Previously
   the round-trip math.deg(math.rad(-15)) produced -15.0 which failed
   strict equality against integer -15 in Lua 5.4.

2. write_bone + update keyframe apply: store angles directly without
   math.rad conversion; bone_state now uses degrees for keyframe and
   procedural channels (look_at still stores radians from math.atan).

3. update procedural loop: wrap each callback in pcall, disarm the
   offending procedural on track-conflict error, then re-raise. This
   prevents a failed procedural from one puppet leaving other puppets
   un-updated on subsequent calls.
2026-05-17 20:09:30 +02:00
Axel Meyer
d7102e5b33 feat: add render + load_rig/load_animation convenience wrappers
Render: each bone drawn as a rotated rect of fixed length+thickness
(16x4 px) centered at the bone's world-position with its current
angle. Sprite-based rendering deferred to Phase 2 when tilesets exist.

Load helpers: engine.asset.load_json(path) + build_rig/build_animation.
The engine provides a single-call JSON loader (reads file + parses JSON
-> Lua table) via engine.asset.load_json; there is no separate cjson.decode
API. Confirmed by engine_lua_asset.c and maps lib usage.

v0.1 complete: skeleton + bones + tracks + rest + keyframe + procedural
+ look-at + render + load. Footplant deferred.
2026-05-17 19:58:37 +02:00
Axel Meyer
0b2bd7eab0 feat: add spawn/despawn + animation playback + look-at + procedural
Per-puppet instance state: position, facing, bone-state cache,
look-target, playing animations, registered procedural callbacks.

Per-frame update pipeline:
1. Reset bones to rest pose
2. Apply look-at constraint (bones marked look_at: true rotate to
   face the target's world-position)
3. Advance + sample keyframe animations on their tracks
4. Run procedural callbacks (write_bone validates that the bone's
   track is not currently keyframe-active)

Look-at math: bone world-pos = puppet origin + bone.rest offset
(parent-chain resolution stays simple in v0.1 since the only
look-at bone (head) attaches directly to torso at the origin).
Full parent-chain transform reserved for v0.2 when multi-level
rigs exist.
2026-05-17 19:57:03 +02:00
Axel Meyer
c3bb096b5b feat: add sample_animation with linear interpolation
Samples an animation at time t. Wraps t for loops. Finds the
keyframe segment containing t and linearly interpolates per-bone
angles. Returns frame with angles in degrees (intuitive for tests
and callers; internal storage uses radians).

Edge cases handled:
- t = 0 returns first-keyframe values
- t = duration returns last-keyframe values (no overshoot)
- t > duration with loop=true wraps via modulo
- Keyframes with disjoint bone-sets: each bone interpolated where
  available, snapped where only one side has it.
2026-05-17 19:55:32 +02:00
Axel Meyer
e62671060e feat: initial implementation with build_rig + build_animation
Validates and cooks rig + animation data into internal representations:
- build_rig: parses bones (with parent-resolution to object refs) and
  tracks (with bone-uniqueness validation). Converts rest angles
  from JSON degrees to radians.
- build_animation: validates each keyframe references only bones in
  the animation's declared track. Converts keyframe angles from
  degrees to radians.

Remaining functions (sample_animation, spawn, update, look-at,
procedural, render, lifecycle) follow in subsequent commits.
2026-05-17 19:54:27 +02:00