From 8d08f2762f44007417dfa776c76971ec2afe763e Mon Sep 17 00:00:00 2001 From: Daniel Talbert Date: Wed, 29 Jul 2026 03:33:35 -0700 Subject: [PATCH] Gradientfall: procedural animation framework + Kern's movement pass Kern's locomotion is rebuilt on a new creature-agnostic animation stack under game/src/anim/, so every future villager, monster and boss inherits it rather than getting its own hand-tuned sine waves. The load-bearing change: the gait cycle is phased by DISTANCE TRAVELLED, not by time. A planted foot's body-relative position then retreats at exactly the speed the body advances, so it holds still in world space by algebraic identity rather than by tuning. The placeholder advanced its cycle on a timer while the body moved at whatever speed it liked, which is why the feet skated. Framework (9 modules): distance-phased gait engine; gait profiles as biomechanics, blended continuously by speed so there is no walk/run threshold to pop at; closed-form two-bone IK; terrain foot planting with leashed world plant-locks and surface-aligned ankles; a rigid-foot heel/toe rocker that keeps the contact patch exactly still through the roll; limb measurement off the rig itself; layered quaternion pose stack; emote library and player; coordinator. Kern: analog walk/jog/run/sprint (stick deflection IS the speed), crouch with capsule resize and ceiling check, slope-aware pace, step-up, landing absorption, turning that costs momentum, idle fidgets, head look-at, and 16 emotes/dances on a radial wheel. Verified with a new instrumented bench (scenes/dev/locomotion_lab.tscn) driving the real controller through 20 scripted segments on a four-zone obstacle course: foot slip 147 -> 47 mm/m overall and 19-23 mm/m at a walk, ground error 60 -> 7.4 mm, backward-knee frames 8182 -> 0, IK shortfall 5 mm. Game boots clean; validator passes 111 entries. Fixed in passing, both pre-existing: the placeholder gait bent Kern's knees BACKWARDS at every speed (positive X on the shin is hyperextension on this rig), and the boot shaft stopped 65 mm below the trouser cuff, leaving a visible hole whenever the knee bent. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 94 +++ docs/DEVLOG.md | 91 +++ docs/ROADMAP.md | 1 + game/scenes/dev/locomotion_lab.tscn | 6 + game/src/anim/anim_math.gd | 229 ++++++ game/src/anim/anim_math.gd.uid | 1 + game/src/anim/creature_animator.gd | 680 ++++++++++++++++ game/src/anim/creature_animator.gd.uid | 1 + game/src/anim/emote_library.gd | 446 +++++++++++ game/src/anim/emote_library.gd.uid | 1 + game/src/anim/emote_player.gd | 124 +++ game/src/anim/emote_player.gd.uid | 1 + game/src/anim/foot_planter.gd | 240 ++++++ game/src/anim/foot_planter.gd.uid | 1 + game/src/anim/gait_engine.gd | 346 +++++++++ game/src/anim/gait_engine.gd.uid | 1 + game/src/anim/gait_profile.gd | 420 ++++++++++ game/src/anim/gait_profile.gd.uid | 1 + game/src/anim/locomotion_profile.gd | 232 ++++++ game/src/anim/locomotion_profile.gd.uid | 1 + game/src/anim/pose_stack.gd | 137 ++++ game/src/anim/pose_stack.gd.uid | 1 + game/src/anim/two_bone_ik.gd | 182 +++++ game/src/anim/two_bone_ik.gd.uid | 1 + game/src/dev/locomotion_lab.gd | 899 ++++++++++++++++++++++ game/src/dev/locomotion_lab.gd.uid | 1 + game/src/player/input_setup.gd | 12 + game/src/player/kern/kern_gear_builder.gd | 9 +- game/src/player/kern_visual.gd | 255 +++--- game/src/player/player.gd | 289 ++++++- game/src/ui/emote_wheel.gd | 157 ++++ game/src/ui/emote_wheel.gd.uid | 1 + 32 files changed, 4713 insertions(+), 148 deletions(-) create mode 100644 game/scenes/dev/locomotion_lab.tscn create mode 100644 game/src/anim/anim_math.gd create mode 100644 game/src/anim/anim_math.gd.uid create mode 100644 game/src/anim/creature_animator.gd create mode 100644 game/src/anim/creature_animator.gd.uid create mode 100644 game/src/anim/emote_library.gd create mode 100644 game/src/anim/emote_library.gd.uid create mode 100644 game/src/anim/emote_player.gd create mode 100644 game/src/anim/emote_player.gd.uid create mode 100644 game/src/anim/foot_planter.gd create mode 100644 game/src/anim/foot_planter.gd.uid create mode 100644 game/src/anim/gait_engine.gd create mode 100644 game/src/anim/gait_engine.gd.uid create mode 100644 game/src/anim/gait_profile.gd create mode 100644 game/src/anim/gait_profile.gd.uid create mode 100644 game/src/anim/locomotion_profile.gd create mode 100644 game/src/anim/locomotion_profile.gd.uid create mode 100644 game/src/anim/pose_stack.gd create mode 100644 game/src/anim/pose_stack.gd.uid create mode 100644 game/src/anim/two_bone_ik.gd create mode 100644 game/src/anim/two_bone_ik.gd.uid create mode 100644 game/src/dev/locomotion_lab.gd create mode 100644 game/src/dev/locomotion_lab.gd.uid create mode 100644 game/src/ui/emote_wheel.gd create mode 100644 game/src/ui/emote_wheel.gd.uid diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a0d7b3e..4e07fc1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,100 @@ makes it the file parallel sessions collide in most often. The convention: - Prefer `EventBus` signals over direct references between systems. A system that only listens needs no wiring in `main.gd` at all. +## Procedural animation (`game/src/anim/`) + +Gradientfall generates every asset in code, so there are no motion-capture +clips to blend. Movement is instead a small stack of creature-agnostic modules +that turn physics state into a pose. Kern is the first consumer; every future +villager, monster and boss is meant to be the next. + +```mermaid +flowchart TD + Body[CharacterBody3D velocity + floor state] --> Animator[CreatureAnimator] + Profile[LocomotionProfile measured from the rig] --> Animator + Animator --> Gait[GaitEngine distance-phased cycle] + Animator --> Planter[FootPlanter ground probes + plant lock] + Gait --> Planter + Planter --> IK[TwoBoneIk analytic leg solve] + Emotes[EmotePlayer + EmoteLibrary] --> Pose + IK --> Pose[PoseStack layered quaternion pose] + Animator --> Pose + Pose --> Skeletons[Skeleton3D, committed by the character script] +``` + +**The load-bearing idea: the gait is phased by DISTANCE, not time.** +`GaitEngine.advance()` integrates `distance_travelled / stride_length`. Because +a planted foot's body-relative position then retreats at exactly the speed the +body advances, the foot holds still in world space for the whole stance. Foot +slip goes to zero as an algebraic identity rather than as a tuned +approximation — at any speed, mid-acceleration, and across gait changes. The +placeholder version advanced its cycle on a timer while the body moved at +whatever speed it liked, which is why its feet skated. + +Module responsibilities, all leaf-first so each is testable alone: + +- `anim_math.gd` — frame-rate-independent damping (half-life based) and exact + critically-damped springs. Nothing here uses `lerp(a, b, k * delta)`, which + converges at different real-world speeds on different hardware. +- `gait_profile.gd` — one gait as biomechanics (stride, duty factor, pelvic + oscillation, foot levers), plus human presets. Gaits blend *continuously* by + speed, so there is no walk/run threshold to pop at. +- `gait_engine.gd` — the distance-phased cycle; per-foot stance/swing state and + whole-body oscillation. The foot is treated as a rigid lever rocking over + heel and toe, so the contact patch stays exactly still through the roll. +- `foot_planter.gd` — raycasts the real collision world under each foot, locks + the plant in world space (with a leash so pivoting scuffs rather than + sticking), aligns ankles to the surface, and drops the pelvis to keep a + downhill foot reachable. +- `two_bone_ik.gd` — closed-form law-of-cosines leg/arm solve. Analytic rather + than `SkeletonIK3D` because it is exact, allocation-free, and — critically — + blendable against the procedural pose like any other layer. +- `locomotion_profile.gd` — measures the creature's limb lengths and joint rest + positions off its own `Skeleton3D` and rescales the gait presets by leg + length (dynamic similarity). Nothing is hard-coded per creature. +- `pose_stack.gd` — the frame's pose as layered quaternions plus a root offset. + Quaternions because layers must *compose*; adding euler triples is not + composing rotations, and it gimbals as soon as an emote overlays a gait. +- `emote_library.gd` / `emote_player.gd` — emotes and dances as functions of + time, with blend-in/out and partial-body support so a wave can play over a + walk while a dance takes the whole body. +- `creature_animator.gd` — the coordinator that runs the above in order and + layers idle life, momentum lean, airborne and landing behaviour on top. + +### Adopting it for a new creature + +1. Build the creature a `Skeleton3D` whose bones carry the names in + `kern_bone_map.gd` (`Hips`, `Spine`, `Chest`, `ThighL`, `ShinL`, `FootL`, …). + Missing bones are simply not driven, so a partial rig degrades rather than + erroring. +2. Construct a `CreatureAnimator` and `bind()` it to the visual node, the + physics body, the skeleton and its bone map. +3. Call `tick(delta, velocity, on_floor, crouch)` from `_physics_process` and + commit the returned `PoseStack` to the skeleton. +4. Tune by editing that creature's `GaitProfile` numbers — stride, duty factor, + foot levers — not by writing new animation code. + +A quadruped is the same machinery with four `FootPlanter`s and two +`GaitEngine`s phase-offset against each other; nothing in the modules assumes +two legs except the presets. + +### Verifying movement work + +`game/src/dev/locomotion_lab.gd` (scene: `scenes/dev/locomotion_lab.tscn`) is +the instrumented bench. It builds a four-zone obstacle course, drives the real +controller through a scripted program via the `Input` singleton, and reports +foot slip, ground error, IK reach shortfall, pose jerk and knee direction per +segment as a table plus JSON. Movement changes are expected to quote its +numbers. Two hard-won cautions, both of which produced badly misleading +figures before they were fixed: + +- Measure slip on the **instantaneous contact point** (heel while the toes are + up, toe once the heel has lifted, ankle while flat), not on a fixed point of + the foot — otherwise honest heel-and-toe rocker reads as sliding. +- Gate it on **stance**, not on the IK's `contact` weight. Contact deliberately + ramps up before touchdown so the IK eases in, so it is true while the foot is + still travelling at full speed. + ## Code documentation standard Documentation is a first-class deliverable of this project, and quality over diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 80ab2b0..ab28602 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -4,6 +4,97 @@ --- +## 2026-07-29 (live session, movement lane) — PROCEDURAL ANIMATION FRAMEWORK; KERN REBUILT ON IT + +*Danny asked for Kern's mechanics to be drastically improved and made +lifelike, for idle life and on-command emotes, and for the work to be shaped so +every future creature benefits. Godot 4.7.1 available locally, so everything +below is verified by running the game, not by inspection.* + +**DONE — the framework (`game/src/anim/`, 9 new modules, creature-agnostic)** +- `gait_engine.gd` — the load-bearing idea: the gait cycle is phased by + **distance travelled**, not by time. A planted foot's body-relative position + then retreats at exactly the speed the body advances, so it holds still in + world space by algebraic identity rather than by tuning. The old code did + `phase += delta * rate` while the body moved at whatever speed it liked, + which is precisely why Kern's feet skated. +- `gait_profile.gd` — a gait as biomechanics (stride, duty factor, pelvic + oscillation, foot levers) with human presets from gait-lab figures. Gaits + blend CONTINUOUSLY by speed, so there is no walk/run threshold to pop at, and + the walk/run pelvis inversion (highest at midstance vaulting vs lowest + compressing) is preserved. +- `two_bone_ik.gd` — closed-form law-of-cosines leg solve. Analytic rather than + `SkeletonIK3D` because it is exact, allocation-free and blendable per-frame + against the procedural pose, which is what lets feet be planted without the + animation losing control of the leg. +- `foot_planter.gd` — raycasts the real collision world under each foot, locks + the plant in world space (leashed, so pivoting scuffs rather than sticking), + aligns ankles to the surface normal, and drops the pelvis to keep a downhill + foot reachable. +- `locomotion_profile.gd` — measures limb lengths and joint rests off the + creature's own skeleton and rescales the presets by leg length (dynamic + similarity). No per-creature constants. +- `anim_math.gd`, `pose_stack.gd`, `emote_library.gd`, `emote_player.gd`, + `creature_animator.gd` — half-life damping and exact critically-damped + springs; layered QUATERNION pose (euler layers do not compose, which is why + an emote over a gait used to skew); 16 emotes; and the coordinator. + +**DONE — Kern rebuilt on it** +- Controller: analog speed with walk/jog/run/sprint blended continuously (stick + deflection IS the speed), crouch with capsule resize and ceiling check, + slope-aware pace from the floor normal, step-up, turn-costs-momentum, + speed-dependent turn rate, landing impact reported to the animator. +- Visual: distance-phased gait, foot IK on terrain, terrain-adaptive pelvis, + spinal-delay counter-rotation, arm swing with elbow flexion, blended airborne + pose with landing reach, momentum lean, breathing plus a fidget scheduler so + idle keeps producing new motion, head look-at with travel anticipation. +- Emotes: 16 in the world's own voice — Gradient Descent, The Backprop, + Overfit, Dropout, Convergence, Weight Shuffle, Epoch Step, Softmax, plus + wave/cheer/bow/point/salute/ponder/stretch/sit. Radial wheel on B (right + stick click on a pad); partial-body emotes layer over locomotion, full-body + ones take over; any movement input cancels out. + +**VERIFIED — measured, not asserted (`scenes/dev/locomotion_lab.tscn`)** +- New instrumented bench: four-zone obstacle course (flat, ramps, stairs, + bumps), 20 scripted segments driven through the real `Input` singleton. +- Foot slip **147 -> 47 mm/m** overall; **19-23 mm/m at a walk**, 25 on stairs, + 31 on broken ground; idle, hard-stop and turn-in-place measure ~0.2 mm. +- Ground error **60 -> 7.4 mm**. Backward-knee frames **8182 -> 0**. IK + shortfall 5 mm, clamped on 11% of frames. Worst pose jerk 58 -> 47 rad/s. +- Eyes on rendered frames against ground stripes for walk, run and dance. + +**FIXED IN PASSING (both pre-existing, both found by looking)** +- The placeholder gait bent Kern's knees **backwards** at every speed: + `ShinL/R` took POSITIVE X, which on this rig is hyperextension. The IK now + derives knee direction from a pole vector so it cannot recur, and the bench + fails the run if it does. +- The boot shaft stopped 65 mm below the trouser cuff, leaving a visible ring + of nothing between boot and trouser whenever the knee bent. + +**HALF-FORMED** +- Remaining measured slip is concentrated in the heel and toe rocker phases at + jog and above (flat-sole slip is near zero, so the plant lock itself is + sound). The rigid-foot rocker is exact on paper and verified frame-by-frame; + the residual is most likely gait-profile blending shifting stride mid-stance. +- Kern's rig gives him a 0.81 m leg on a 1.78 m body (a real adult is ~0.87 m) + because his ankle joint sits ~4 cm high. That shortfall is what forces the + stride cap in `LocomotionProfile.max_reachable_stride()` and a slightly brisk + cadence. Lowering the ankle in `kern_body_builder.gd` would buy it back, but + it moves the boot/foot meshes and the imported-rig retarget, so it is its own + change. +- `jump_flat` still measures ~108 mm/m — landing transients, the least-tuned + part of the pass. + +**NEXT UP** +- Rig the Bootstrap villagers and the monster roster so they can adopt + `CreatureAnimator` (they are currently un-skeletoned meshes). The framework + is ready; the creatures are not yet. +- Footstep audio and dust/grass-trample hooks off `GaitEngine.just_planted()`, + which already fires on the exact touchdown frame. +- Hands-on pad feel-tune with Danny at the phase gate. + +--- + ## 2026-07-27 (repository recovery) — Vault and Iris branches integrated **DONE** diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2d8cf78..aa41274 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -25,6 +25,7 @@ Prove every system small, then scale outward. One region done completely. - [x] Godot 4 project scaffold (`gradientfall/game/`), folder conventions, autoloads *(boot verified headless in Godot 4.7.1: clean import, ContentDB loads 22 entries, zero errors)* - [x] Third-person character controller: walk/run/jump/camera (feel pass included) *(boot verified clean in Godot 4.7.1 after class-cache re-import; hands-on feel-tune still welcome at the phase gate)* +- [x] **Movement pass: procedural animation framework + Kern's mechanics** *(built `src/anim/` — a creature-agnostic stack every future NPC/monster/boss can adopt: distance-phased gait engine, biomechanical gait profiles blended continuously by speed, analytic two-bone IK, terrain foot planting with world plant-locks, rigid-foot heel/toe rocker, layered quaternion pose stack, emote system. Kern rebuilt on it: analog walk/jog/run/sprint with no gait thresholds, crouch, slope-aware pace, step-up, landing absorption, momentum lean, idle fidgets, and 16 emotes/dances on a radial wheel (B / right-stick click). New instrumented bench `scenes/dev/locomotion_lab.tscn` measures foot slip, ground error, IK shortfall, pose jerk and knee direction over a 20-segment obstacle course. Measured: foot slip **147 -> 47 mm/m** overall and **19-23 mm/m at a walk**, ground error **60 -> 7.4 mm**, backward-knee frames **8182 -> 0**, IK shortfall 5 mm. Eyes-verified via rendered gait/dance frames against ground stripes. **Fixed in passing:** the placeholder gait bent Kern's knees BACKWARDS at every speed, and the boot shaft stopped 65 mm below the trouser cuff leaving a visible hole whenever the knee bent.)* - [x] Terrain: Datasedge Meadows heightmap terrain + procedural grass/trees, region border vistas toward future regions *(480×480 m procedural heightmap w/ town flat + carved millpond, 34k wind-swayed grass, iris flats, tree copses, 4-direction border vistas; built & eyes-verified via screenshots in a live session, 5 palette/lighting iterations. NOTE: still default lighting — the cel-shade pass below is what makes it "pretty")* - [x] Cel-shaded look dev v1: toon shader, sky, day/night cycle, wind grass *(reusable toon.gdshader: banded diffuse + fresnel rim + sky-tinted shadow fill; applied to terrain/grass/trees/character; SkyCycle drives sun arc + 7-key color script dawn→noon→dusk→night; eyes-verified via screenshots incl. a 4-time-of-day showcase. Character rim pops nicely. Kern still a placeholder capsule — the character-model milestone dresses him)* - [x] Bit the fairy: follow behavior, look-at naming, hint lines *(built: exp-smoothed hover-follow with idle orbit/bob, sprint catch-up, and canon water-fear over the millpond; BitLandmark look-at naming across 8 canon meadow sites (remembered in save flags); in-voice barks — greeting, idle/hint, quiz/item/region reactions — on a floating Label3D + EventBus.bit_spoke. **UNSEEN**: no Godot in this env — needs a live session to import (.uid gen), confirm clean boot, and eyes per GDD §10)* diff --git a/game/scenes/dev/locomotion_lab.tscn b/game/scenes/dev/locomotion_lab.tscn new file mode 100644 index 0000000..048a7b2 --- /dev/null +++ b/game/scenes/dev/locomotion_lab.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/dev/locomotion_lab.gd" id="1_lab"] + +[node name="LocomotionLab" type="Node3D"] +script = ExtResource("1_lab") diff --git a/game/src/anim/anim_math.gd b/game/src/anim/anim_math.gd new file mode 100644 index 0000000..d5eb868 --- /dev/null +++ b/game/src/anim/anim_math.gd @@ -0,0 +1,229 @@ +class_name AnimMath +extends RefCounted +## Frame-rate-independent smoothing, spring integrators and easing curves shared +## by every creature animator in Gradientfall. +## +## **Why this module exists.** `lerp(a, b, rate * delta)` — the idiom the old +## Kern animation used throughout — is frame-rate dependent: a 30 fps machine +## and a 144 fps machine close the gap at different real-world speeds, so the +## same character reads "floaty" on one and "snappy" on another. Every routine +## here is instead expressed as a HALF-LIFE (seconds to close half the remaining +## gap) and integrated exactly, so the motion is identical at any timestep. +## +## **Architecture.** Leaf utility module — depends on nothing, allocates nothing +## in its static functions (they run per-bone, per-frame). Imported by +## `gait_engine.gd`, `foot_planter.gd`, `emote_player.gd` and +## `creature_animator.gd`. See `docs/ARCHITECTURE.md` § "Procedural animation". + +## ln(2), the constant that turns a half-life into an exponential rate. +const LN2: float = 0.6931471805599453 + +## Half-lives below this are treated as "snap instantly" — guards the division +## in every damp routine against a zero/negative half-life. +const MIN_HALF_LIFE: float = 0.00001 + + +# --- Exponential damping (frame-rate independent) --------------------------- + +## Ease `current` toward `target`, closing half the remaining gap every +## `half_life` seconds. Exact at any `delta`: the closed-form solution of +## dx/dt = -k(x - target), so 4 small steps land exactly where 1 big step does. +static func damp(current: float, target: float, half_life: float, + delta: float) -> float: + if half_life <= MIN_HALF_LIFE: + return target + return target + (current - target) * exp(-LN2 * delta / half_life) + + +## Vector3 form of `damp` — each axis converges independently. +static func damp_vec3(current: Vector3, target: Vector3, half_life: float, + delta: float) -> Vector3: + if half_life <= MIN_HALF_LIFE: + return target + var k: float = exp(-LN2 * delta / half_life) + return target + (current - target) * k + + +## Vector2 form of `damp`. +static func damp_vec2(current: Vector2, target: Vector2, half_life: float, + delta: float) -> Vector2: + if half_life <= MIN_HALF_LIFE: + return target + var k: float = exp(-LN2 * delta / half_life) + return target + (current - target) * k + + +## Angular form of `damp` — takes the short way around the circle, so a +## character turning past ±PI never spins the long way to get back. +static func damp_angle(current: float, target: float, half_life: float, + delta: float) -> float: + if half_life <= MIN_HALF_LIFE: + return target + var difference: float = wrapf(target - current, -PI, PI) + return current + difference * (1.0 - exp(-LN2 * delta / half_life)) + + +## Quaternion form of `damp`, along the shortest arc. Used for bone rotations +## where euler damping would gimbal or take a visibly wrong path. +static func damp_quat(current: Quaternion, target: Quaternion, + half_life: float, delta: float) -> Quaternion: + if half_life <= MIN_HALF_LIFE: + return target + var weight: float = 1.0 - exp(-LN2 * delta / half_life) + return current.slerp(_shortest(current, target), weight) + + +## Flip `to` into the same hemisphere as `from` so slerp takes the short arc. +## Without this a quaternion and its negation — the same rotation — interpolate +## the long way round, which reads as a limb swinging through the body. +static func _shortest(from: Quaternion, to: Quaternion) -> Quaternion: + if from.dot(to) < 0.0: + return -to + return to + + +# --- Easing ------------------------------------------------------------------ + +## Hermite smoothstep on an already-normalised 0..1 input. Zero first +## derivative at both ends — the workhorse for blending pose layers in and out. +static func smoothstep01(t: float) -> float: + var x: float = clampf(t, 0.0, 1.0) + return x * x * (3.0 - 2.0 * x) + + +## Perlin's smootherstep: zero FIRST AND SECOND derivative at both ends. Used +## for the foot swing arc, where a smoothstep's non-zero acceleration at +## lift-off still reads as a mechanical twitch. +static func smootherstep01(t: float) -> float: + var x: float = clampf(t, 0.0, 1.0) + return x * x * x * (x * (x * 6.0 - 15.0) + 10.0) + + +## A 0 -> 1 -> 0 bell over t in 0..1, flat at both ends. The foot-lift and +## breath curves ride on this. +static func bell01(t: float) -> float: + var x: float = clampf(t, 0.0, 1.0) + return 0.5 - 0.5 * cos(x * TAU) + + +## Bell with an adjustable peak position: `peak` in 0..1 says where the maximum +## lands. Real foot lift peaks EARLY in the swing (toe-off is explosive, the +## reach to heel-strike is a long glide), which a symmetric bell can't express. +static func skewed_bell01(t: float, peak: float) -> float: + var x: float = clampf(t, 0.0, 1.0) + var p: float = clampf(peak, 0.02, 0.98) + if x < p: + return bell01(0.5 * x / p) + return bell01(0.5 + 0.5 * (x - p) / (1.0 - p)) + + +## Signed power curve that keeps the sign of `v` — sharpens or softens a +## -1..1 oscillation without breaking its symmetry. `power` > 1 flattens the +## middle (a snappier, more "held" extreme), < 1 rounds it. +static func signed_pow(v: float, power: float) -> float: + return signf(v) * pow(absf(v), power) + + +## Remap `v` from [from_min, from_max] onto [to_min, to_max], clamped. The +## gait code uses this constantly to turn a speed into a blend weight. +static func remap01(v: float, from_min: float, from_max: float) -> float: + if absf(from_max - from_min) < 0.000001: + return 0.0 + return clampf((v - from_min) / (from_max - from_min), 0.0, 1.0) + + +# --- Springs ----------------------------------------------------------------- + +## A critically-damped spring in one dimension, integrated exactly. +## +## Unlike `damp`, a spring carries VELOCITY, so it overshoots-and-settles the +## way real mass on a tendon does. That is what makes an arm swing feel like an +## arm and not like a value being interpolated. Critically damped (no +## oscillation) is the default because oscillating limbs read as rubber; the +## `stiffness_scale` lets a caller trade settle time for looseness. +class Spring1: + extends RefCounted + + ## Current value. + var value: float = 0.0 + ## Current rate of change, in units/second. Carried across frames — this is + ## the whole point of a spring over a damp. + var velocity: float = 0.0 + ## Seconds to close half the gap. Smaller = stiffer. + var half_life: float = 0.08 + + ## Local copies of the outer constants — an inner class cannot reliably + ## reach its own file's `class_name` during parse, and duplicating two + ## numbers is cheaper than a resolution order that breaks on a cold import. + const EPSILON: float = 0.00001 + const LN2: float = 0.6931471805599453 + + func _init(initial: float = 0.0, initial_half_life: float = 0.08) -> void: + value = initial + half_life = initial_half_life + + ## Advance toward `target` by `delta` seconds and return the new value. + ## Exact integration of a critically-damped second-order system (the + ## standard "spring_damper_exact" formulation). + func step(target: float, delta: float) -> float: + if half_life <= EPSILON: + value = target + velocity = 0.0 + return value + # Convert half-life to the critical-damping eigenvalue. The 2x is + # because a critically-damped system has a repeated root, so its + # envelope decays as (1 + y*t)*exp(-y*t), not plain exp(-y*t). + var y: float = 2.0 * LN2 / half_life + var offset: float = value - target + var combined: float = velocity + offset * y + var decay: float = exp(-y * delta) + value = target + decay * (offset + combined * delta) + velocity = decay * (velocity - combined * y * delta) + return value + + ## Jump to a value with no motion — use on teleports/respawns so the spring + ## does not fling the limb across the world catching up. + func reset(to_value: float) -> void: + value = to_value + velocity = 0.0 + + +## Three-dimensional critically-damped spring. Same integrator as `Spring1`, +## run per axis. Used for the pelvis offset, cloak swing and head lag. +class Spring3: + extends RefCounted + + ## Current value. + var value: Vector3 = Vector3.ZERO + ## Current rate of change, in units/second. + var velocity: Vector3 = Vector3.ZERO + ## Seconds to close half the gap. Smaller = stiffer. + var half_life: float = 0.08 + + ## See the note on `Spring1` — inner classes keep their own copies. + const EPSILON: float = 0.00001 + const LN2: float = 0.6931471805599453 + + func _init(initial: Vector3 = Vector3.ZERO, + initial_half_life: float = 0.08) -> void: + value = initial + half_life = initial_half_life + + ## Advance toward `target` by `delta` seconds and return the new value. + func step(target: Vector3, delta: float) -> Vector3: + if half_life <= EPSILON: + value = target + velocity = Vector3.ZERO + return value + var y: float = 2.0 * LN2 / half_life + var offset: Vector3 = value - target + var combined: Vector3 = velocity + offset * y + var decay: float = exp(-y * delta) + value = target + decay * (offset + combined * delta) + velocity = decay * (velocity - combined * y * delta) + return value + + ## Jump to a value with no motion. + func reset(to_value: Vector3) -> void: + value = to_value + velocity = Vector3.ZERO diff --git a/game/src/anim/anim_math.gd.uid b/game/src/anim/anim_math.gd.uid new file mode 100644 index 0000000..9f0b44a --- /dev/null +++ b/game/src/anim/anim_math.gd.uid @@ -0,0 +1 @@ +uid://bdftg8waf3dyi diff --git a/game/src/anim/creature_animator.gd b/game/src/anim/creature_animator.gd new file mode 100644 index 0000000..ac052ee --- /dev/null +++ b/game/src/anim/creature_animator.gd @@ -0,0 +1,680 @@ +class_name CreatureAnimator +extends RefCounted +## Drives one legged creature's whole body from its physics state: gait, foot +## planting, terrain adaptation, air and landing behaviour, body lean, idle +## life and emotes, composited into a single `PoseStack` per frame. +## +## **What it is for.** This is the piece that makes the framework reusable. +## `kern_visual.gd` used to own ~200 lines of hand-tuned sine waves that only +## Kern could ever use; everything general has moved here, so a villager, a +## wolf or a boss gets the same distance-phased gait, ground-locked feet, slope +## adaptation and momentum lean by constructing one of these against its own +## skeleton. What stays in the character's own script is only what is genuinely +## its own — Kern's cloak, sword, arcane glow and combat poses. +## +## **Frame order** (each step depends on the last, so the order is load-bearing): +## 1. measure real displacement -> advance the distance-phased gait +## 2. choose and blend the gait profile for the current speed and crouch +## 3. place the pelvis (terrain drop, crouch, bob, landing absorption, lean) +## 4. project ideal foot targets into the world, probe the ground, plant them +## 5. solve both legs with analytic IK back onto those planted targets +## 6. layer torso, arms, head, idle life +## 7. cross-fade the airborne pose, then composite any emote on top +## +## **Architecture.** Depends on every other module in `src/anim/`; owns no +## nodes. Its output is a `PoseStack` that the caller commits to whatever +## skeletons it has — which is what lets `kern_visual.gd` keep driving both its +## procedural rig and the imported base-mesh rig from one animation. +## See `docs/ARCHITECTURE.md` § "Procedural animation". + +const AM: GDScript = preload("res://src/anim/anim_math.gd") + +## Half-life for the speed used to pick a gait. Long enough that a stutter in +## the input does not flick the character between gaits, short enough that +## breaking into a run still reads as immediate. +const SPEED_HALF_LIFE: float = 0.10 + +## Half-life for the airborne cross-fade. +const AIR_HALF_LIFE: float = 0.07 + +## Ground speed at which locomotion reaches full amplitude, m/s. +const FULL_STRIDE_SPEED: float = 0.75 + +## Vertical speed treated as a maximum-impact landing, m/s. +const HARD_LANDING_SPEED: float = 12.0 + +## Deepest the pelvis dips absorbing a landing, metres. +const MAX_LANDING_DIP: float = 0.30 + +## Deepest the pelvis will drop to keep a downhill foot reachable, metres. +const MAX_TERRAIN_DROP: float = 0.42 + +## Distance below the feet at which a falling creature starts reaching for the +## ground, metres. Landing anticipation is a large part of why a real jump +## reads as controlled rather than as a dropped puppet. +const LANDING_REACH_DISTANCE: float = 1.30 + + +## Measured body configuration and gait set. +var profile: LocomotionProfile + +## The distance-phased cycle. +var gait: GaitEngine = GaitEngine.new() + +## Ground probing and plant locking. +var planter: FootPlanter = FootPlanter.new() + +## Emote playback. +var emotes: EmotePlayer = EmotePlayer.new() + +## The composited pose for this frame. +var pose: PoseStack = PoseStack.new() + +## Blended gait profile for this frame — exposed so debug overlays and the +## locomotion lab can report which gait is actually active. +var active_gait: GaitProfile = GaitProfile.new() + +## Smoothed ground speed, m/s. +var speed_smooth: float = 0.0 + +## How airborne the creature is, 0..1. +var air_blend: float = 0.0 + +## Resolved ground state per foot from this frame, for callers that want to +## spawn dust, play footstep audio or trample grass. +var feet: Array = [] + +## Diagnostics for the locomotion lab: how far the IK's own solution fell short +## of the requested target this frame (metres, worst foot), and whether it had +## to clamp. Distinguishes "the leg could not reach" from "the leg reached but +## the skeleton ended up somewhere else", which look identical from outside and +## have completely different fixes. +var ik_tip_error: float = 0.0 +var ik_clamped: bool = false + +## World-space ankle position the IK was ASKED for, and the one it actually +## solved, per foot. The locomotion lab compares both against the rendered bone +## to tell apart three different failures that look identical from outside: a +## wrong target, a solver that fell short, and a solution that something later +## in the frame overwrote. +var ik_target_world: Array = [Vector3.ZERO, Vector3.ZERO] +var ik_solved_world: Array = [Vector3.ZERO, Vector3.ZERO] + +var _visual: Node3D +var _body: Node3D +var _skeleton: Skeleton3D +var _bones: Dictionary = {} +var _prev_position: Vector3 = Vector3.ZERO +var _have_prev: bool = false +var _model_to_world: Transform3D = Transform3D.IDENTITY +var _world_to_model: Transform3D = Transform3D.IDENTITY + +# Secondary motion springs. +var _lean: AnimMath.Spring3 = AnimMath.Spring3.new(Vector3.ZERO, 0.13) +var _landing_dip: AnimMath.Spring1 = AnimMath.Spring1.new(0.0, 0.11) +var _crouch_smooth: AnimMath.Spring1 = AnimMath.Spring1.new(0.0, 0.09) +var _head_look: AnimMath.Spring3 = AnimMath.Spring3.new(Vector3.ZERO, 0.16) +## Where the head is being asked to look, as (pitch, yaw, 0) in radians. +## `look_at_world()` writes it; the spring eases toward it every frame so a +## target that appears suddenly does not snap the neck. +var _look_target: Vector3 = Vector3.ZERO +var _prev_velocity: Vector3 = Vector3.ZERO +var _prev_yaw: float = 0.0 + +# Idle life. +var _idle_time: float = 0.0 +var _fidget_countdown: float = 5.0 +var _fidget_id: int = -1 +var _fidget_time: float = 0.0 + +## Set true by `notify_landing()`; consumed on the next tick. +var _pending_impact: float = 0.0 + + +## Bind to a creature. `visual` is the node the animation rotates to face +## travel, `body` is the physics body whose displacement drives the gait, +## `skeleton` and `bones` are the rig, `height` is the creature's height in +## metres, and `exclude` are collider RIDs the ground probes must ignore +## (always at least the creature's own body). +func bind(visual: Node3D, body: Node3D, skeleton: Skeleton3D, + bones: Dictionary, height: float, collision_mask: int, + exclude: Array[RID]) -> void: + _visual = visual + _body = body + _skeleton = skeleton + _bones = bones + profile = LocomotionProfile.from_skeleton(skeleton, bones, height) + # The rocker geometry rotates the foot about a point on the GROUND, so the + # gait needs to know how high the ankle rides above the sole. + gait.set_ankle_height(profile.ankle_rest_y) + planter.setup(visual, collision_mask, exclude) + active_gait.copy_from(profile.idle) + _prev_position = body.global_position + _have_prev = true + _prev_yaw = visual.rotation.y + + +## Tell the animator the creature just landed at `impact_speed` m/s downward, +## so it can absorb the landing with the knees and pelvis. +func notify_landing(impact_speed: float) -> void: + _pending_impact = maxf(_pending_impact, absf(impact_speed)) + + +## Reset all continuous state — call on teleport, respawn and scene changes so +## springs and plant locks do not drag the body back toward where it was. +func teleported() -> void: + gait.reset() + planter.reset() + _lean.reset(Vector3.ZERO) + _landing_dip.reset(0.0) + _head_look.reset(Vector3.ZERO) + _have_prev = false + air_blend = 0.0 + + +## Build this frame's pose. `velocity` and `on_floor` come from the physics +## body; `crouch` is 0..1. Returns the composited pose. +func tick(delta: float, velocity: Vector3, on_floor: bool, + crouch: float) -> PoseStack: + if _visual == null or _skeleton == null: + return pose + _update_transforms() + var travelled: float = _measure_travel() + + var ground_speed: float = Vector2(velocity.x, velocity.z).length() + speed_smooth = AM.damp(speed_smooth, ground_speed, SPEED_HALF_LIFE, delta) + var crouch_amount: float = _crouch_smooth.step(clampf(crouch, 0.0, 1.0), delta) + + profile.blend_for_speed(active_gait, speed_smooth, crouch_amount) + # Only ground contact advances the cycle: a creature in mid-air is not + # taking steps, and letting flight advance the phase makes the legs windmill. + gait.advance(travelled * (1.0 - air_blend), active_gait, delta) + + air_blend = AM.damp(air_blend, 0.0 if on_floor else 1.0, + AIR_HALF_LIFE, delta) + _idle_time += delta + + pose.clear() + + var body_phase: GaitEngine.BodyPhase = gait.body_phase(active_gait) + var stride_weight: float = AM.smoothstep01( + speed_smooth / FULL_STRIDE_SPEED) * (1.0 - air_blend) + + _update_lean(delta, velocity, ground_speed) + var pelvis_offset: Vector3 = _place_pelvis(delta, body_phase, stride_weight, + crouch_amount, on_floor) + _solve_legs(delta, pelvis_offset, body_phase, stride_weight) + _pose_torso(body_phase, stride_weight, crouch_amount) + _pose_arms(stride_weight, crouch_amount) + _pose_head(body_phase, stride_weight, delta) + _idle_life(delta, stride_weight, crouch_amount) + _air_pose(velocity) + + emotes.tick(delta) + if emotes.weight() > 0.001: + pose.blend_toward(emotes.pose, emotes.weight()) + # A full-body emote has taken the legs off the gait, so the feet are no + # longer gait-planted: clear the stance flags. Everything downstream + # keys off them — footstep audio, dust, grass trample, the locomotion + # bench — and none of it should fire walking cues out of a dance. + if emotes.overrides_legs() and emotes.weight() > 0.5: + for entry in feet: + (entry as FootPlanter.FootGround).stance = false + return pose + + +# --- Frames and travel ------------------------------------------------------- + +## Rebuild the model<->world transforms for this frame. +## +## Deliberately reconstructed from the body position and the visual's yaw +## rather than read from `_visual.global_transform`: the visual node also +## carries the jump/land squash SCALE, and feeding a non-uniform scale into the +## foot IK would stretch every target off the ground. +func _update_transforms() -> void: + var yaw: float = _visual.rotation.y + var body_basis: Basis = _body.global_transform.basis.orthonormalized() + _model_to_world = Transform3D(body_basis * Basis(Vector3.UP, yaw), + _body.global_position) + _world_to_model = _model_to_world.affine_inverse() + + +## Ground distance covered since the last frame, signed along the facing. +## +## Measured from the body's ACTUAL displacement, not from `velocity * delta`: +## the two disagree whenever `move_and_slide` clips a wall or rides a slope, and +## the gait must follow the ground the body really covered or the feet slip +## exactly in the moments a player is most likely to notice. +func _measure_travel() -> float: + var position: Vector3 = _body.global_position + if not _have_prev: + _prev_position = position + _have_prev = true + return 0.0 + var displacement: Vector3 = position - _prev_position + _prev_position = position + displacement.y = 0.0 + var forward: Vector3 = -_model_to_world.basis.z + forward.y = 0.0 + if forward.length_squared() < 0.000001: + return displacement.length() + return displacement.dot(forward.normalized()) + + +# --- Lean -------------------------------------------------------------------- + +## Momentum lean: the body tips into acceleration and banks into turns. +## +## This is cheap and disproportionately convincing. A character that changes +## direction with a perfectly upright torso reads as weightless no matter how +## good the footwork is, because real mass has to be thrown before it moves. +func _update_lean(delta: float, velocity: Vector3, ground_speed: float) -> void: + var acceleration: Vector3 = (velocity - _prev_velocity) / maxf(delta, 0.0001) + _prev_velocity = velocity + acceleration.y = 0.0 + + var local_accel: Vector3 = _world_to_model.basis * acceleration + # Forward lean from longitudinal acceleration (-Z is forward). + var lean_forward: float = clampf(-local_accel.z * 0.010, -0.22, 0.28) + # Bank from turn rate scaled by speed: standing still and spinning should + # not throw the body over, but carving at a run should. + var yaw_rate: float = wrapf(_visual.rotation.y - _prev_yaw, -PI, PI) \ + / maxf(delta, 0.0001) + _prev_yaw = _visual.rotation.y + var bank: float = clampf(yaw_rate * ground_speed * 0.016, -0.20, 0.20) + _lean.step(Vector3(lean_forward, 0.0, bank), delta) + + +# --- Pelvis ------------------------------------------------------------------ + +## Position the pelvis: gait bob and sway, crouch depth, landing absorption and +## the terrain drop that keeps a downhill foot reachable. Returns the model- +## space offset, which the leg IK then solves against. +func _place_pelvis(delta: float, body_phase: GaitEngine.BodyPhase, + stride_weight: float, crouch_amount: float, + on_floor: bool) -> Vector3: + # Landing absorption: convert the impact into a dip the spring recovers. + if _pending_impact > 0.0: + var severity: float = clampf(_pending_impact / HARD_LANDING_SPEED, + 0.0, 1.0) + _landing_dip.value -= MAX_LANDING_DIP * severity + _pending_impact = 0.0 + _landing_dip.step(0.0, delta) + + var crouch_drop: float = -active_gait.pelvis_drop * crouch_amount + var bob: float = body_phase.bob * stride_weight + var sway: float = body_phase.sway * stride_weight + var terrain: float = planter.pelvis_offset if on_floor else 0.0 + # Blend from the standing flex to the gait's own stance depth. Never zero: + # Kern's rest pose puts the hip at EXACTLY leg length above the ankle, so + # with no flex at all he stands with locked, dead-straight knees, the IK + # sits on its singular fully-extended configuration, and every idle frame + # clamps. A centimetre of flex fixes the look and the maths together. + var gait_drop: float = lerpf(_standing_flex(), _gait_stance_drop(), + stride_weight) + + # One offset, published on the pose so the caller can translate the pelvis + # bone with it, and returned so the leg IK solves against the same number. + # Splitting them was how an earlier pass ended up with legs that solved for + # a pelvis position the mesh was not actually at. + var offset: Vector3 = Vector3(sway, + bob + crouch_drop + terrain + gait_drop + _landing_dip.value, 0.0) + pose.root_offset = offset + return offset + + +## Baseline knee flex while standing still, as a pelvis drop in metres +## (negative). +## +## Small numbers go a long way here: the hip-height-to-knee-angle relationship +## is a cosine near full extension, so lowering Kern's pelvis by one centimetre +## already bends the knee about 16° — comfortably "standing relaxed" rather than +## "locked at attention". +func _standing_flex() -> float: + return -profile.leg_length() * 0.012 + + +## Rest height of the thigh joint above the ankle joint, metres — the leg's +## own vertical span, and the reference every stance-depth calculation uses. +func _hip_rest_height() -> float: + return profile.hip_rest[0].y - profile.ankle_rest_y + + +## How far the pelvis must sit below its rest height for this gait's stride to +## be reachable, in metres (never positive). +## +## Kern's rest pose puts the hip at EXACTLY leg-length above the ankle — legs +## dead straight — so at rest height the legs cannot reach forward at all +## without the IK clamping. Real bodies carry a permanent stance flexion, and +## the faster the gait the deeper it gets, which is why sprinters run low. +## Deriving it from the actual stride rather than hard-coding a crouch means +## every creature, at every speed, sits exactly as low as its own geometry +## needs and no lower. +func _gait_stance_drop() -> float: + var duty: float = clampf(active_gait.duty, 0.05, 0.95) + # The furthest the ankle gets from directly under the hip. The heel rocker + # pulls the heel-strike position slightly back toward the body, so the + # reach the leg actually has to make is a little less than the raw stride. + var reach_out: float = maxf(0.0, active_gait.stride * duty * 0.5 + - active_gait.heel_lever) + var leg: float = profile.leg_reach() + if reach_out >= leg: + return -(profile.pelvis_rest_y - profile.ankle_rest_y) * 0.35 + # Pythagoras: the vertical the leg has left once it has reached that far. + var usable_height: float = sqrt(leg * leg - reach_out * reach_out) + # Measured from the THIGH JOINT, which is what the leg actually hangs from + # — not from the pelvis bone, which sits 5.5 cm higher on this rig. Using + # the pelvis made the solver believe the legs were longer than they are and + # crouched Kern that much deeper than the geometry ever required. + # + # The pelvis bob is subtracted as headroom: the hip spends half of every + # step ABOVE its mean height, and sizing the stance for the mean leaves the + # leg over-reaching on exactly those frames. That is most of why the fast + # gaits clamped their IK far more often than the walk did. + return minf(0.0, usable_height - _hip_rest_height() + - active_gait.pelvis_bob) + + +# --- Legs -------------------------------------------------------------------- + +## Place both feet on the world and solve the legs onto them. +func _solve_legs(delta: float, pelvis_offset: Vector3, + body_phase: GaitEngine.BodyPhase, stride_weight: float) -> void: + # Pelvis rotation, which carries the hip joints with it. + var pelvis_rotation: Quaternion = Quaternion.from_euler(Vector3( + -active_gait.torso_lean * stride_weight * 0.25 + _lean.value.x * 0.30, + body_phase.pelvis_yaw * stride_weight, + body_phase.pelvis_roll * stride_weight + _lean.value.z * 0.35)) + pose.set_rot("Hips", pelvis_rotation) + + # The exact bone origin the skeleton itself rotates the hips about. + var pivot: Vector3 = profile.pelvis_rest + var resolved: Array = [] + var targets: Array = [] + + for index in 2: + var foot: GaitEngine.FootPhase = gait.foot_phase(index, active_gait) + var lateral: float = GaitEngine.stance_lateral(index, + profile.hip_half_width) + # The planter is given the foot's ANCHOR — where the ankle would be with + # the sole flat — because that is the point which is genuinely + # stationary for the whole stance. The ankle's own rocker displacement + # is added back afterwards, so the lock never has to fight the roll. + # Model space: forward is -Z, so the gait's forward `along` is -Z. + var anchor_model: Vector3 = Vector3( + lateral, + profile.ankle_rest_y, + -foot.anchor_along * stride_weight) + var anchor_world: Vector3 = _model_to_world * anchor_model + var ground: FootPlanter.FootGround = planter.resolve(index, anchor_world, + foot.contact * (1.0 - air_blend), delta, profile.ankle_rest_y, + 0.0, foot.flat * (1.0 - air_blend)) + # Ride the ankle off the resolved anchor by the rocker offset (and, in + # swing, by the whole swing arc). + var rocker: Vector3 = Vector3(0.0, foot.lift, + -(foot.along - foot.anchor_along)) * stride_weight + ground.world_position += _model_to_world.basis * rocker + ground.stance = foot.stance and air_blend < 0.5 + resolved.append(ground) + targets.append(foot) + feet = resolved + + # Drop the pelvis if either planted foot is out of reach downhill. + var hip_world_y: float = (_model_to_world * (pivot + pelvis_offset)).y + planter.update_pelvis(hip_world_y, resolved, profile.leg_reach(), + MAX_TERRAIN_DROP, delta) + + for index in 2: + var ground: FootPlanter.FootGround = resolved[index] + var foot: GaitEngine.FootPhase = targets[index] + var hip_model: Vector3 = pelvis_rotation * (profile.hip_rest[index] + - pivot) + pivot + pelvis_offset + var target_model: Vector3 = _world_to_model * ground.world_position + # In the air the ground is meaningless; fall back to the gait's ideal so + # the cross-fade into the air pose starts from something sensible. + if air_blend > 0.001: + var ideal_model: Vector3 = Vector3( + GaitEngine.stance_lateral(index, profile.hip_half_width), + profile.ankle_rest_y, + -foot.along * stride_weight) + target_model = target_model.lerp(ideal_model, air_blend) + + # Pole out in front of the knee so it always bends forward. Riding the + # hip means the pole turns with the leg instead of dragging the knee + # toward a fixed world point when the body rotates. + var pole: Vector3 = hip_model + Vector3(0.0, -profile.thigh_length * 0.5, + -active_gait.knee_pole_ahead) + + var solution: TwoBoneIk.Solution = TwoBoneIk.solve(hip_model, + target_model, pole, profile.thigh_length, profile.shin_length, + profile.thigh_rest_dir, profile.shin_rest_dir) + if index == 0: + ik_tip_error = 0.0 + ik_clamped = false + ik_tip_error = maxf(ik_tip_error, + solution.tip_position.distance_to(target_model)) + ik_clamped = ik_clamped or solution.clamped + ik_target_world[index] = _model_to_world * target_model + ik_solved_world[index] = _model_to_world * solution.tip_position + + var suffix: String = "R" if index == 1 else "L" + var thigh_model: Quaternion = solution.upper_rotation + var shin_model: Quaternion = solution.lower_rotation + pose.set_rot("Thigh" + suffix, + TwoBoneIk.to_local(thigh_model, pelvis_rotation)) + pose.set_rot("Shin" + suffix, + TwoBoneIk.to_local(shin_model, thigh_model)) + + # Ankle: the gait's heel-to-toe roll, then conform to the surface. + var ankle_model: Quaternion = Quaternion.from_euler( + Vector3(foot.roll * stride_weight, 0.0, 0.0)) + if ground.found and ground.contact > 0.01: + var normal_model: Vector3 = _world_to_model.basis * ground.normal + ankle_model = FootPlanter.align_to_surface(Vector3.UP, normal_model, + ground.contact * (1.0 - air_blend)) * ankle_model + pose.set_rot("Foot" + suffix, + TwoBoneIk.to_local(ankle_model, shin_model)) + + +# --- Torso, arms, head ------------------------------------------------------- + +## Spine chain: forward lean, counter-rotation against the pelvis, momentum +## lean. The spine bones point UP, so a forward lean is a NEGATIVE X rotation. +func _pose_torso(body_phase: GaitEngine.BodyPhase, stride_weight: float, + crouch_amount: float) -> void: + var lean: float = active_gait.torso_lean * stride_weight \ + + _lean.value.x * 0.55 + var crouch_lean: float = 0.22 * crouch_amount + pose.set_euler("Spine", Vector3( + -(lean * 0.42 + crouch_lean * 0.4), + body_phase.chest_yaw * 0.45 * stride_weight, + (body_phase.chest_roll * 0.5 + _lean.value.z * 0.30) * stride_weight)) + pose.set_euler("Chest", Vector3( + -(lean * 0.48 + crouch_lean * 0.5), + body_phase.chest_yaw * 0.55 * stride_weight, + (body_phase.chest_roll * 0.5 + _lean.value.z * 0.35) * stride_weight)) + + +## Arms: counter-swing against the legs, with the elbow flexing on the forward +## half of the swing the way a real arm does. +func _pose_arms(stride_weight: float, crouch_amount: float) -> void: + for index in 2: + var right: bool = index == 1 + var suffix: String = "R" if right else "L" + var side: float = 1.0 if right else -1.0 + # The arm opposes the SAME-side leg, so it reads off that foot's cycle + # shifted half a turn. + var cycle: float = fposmod(gait.phase + (0.5 if right else 0.0), 1.0) + var swing: float = cos(cycle * TAU) + var pitch: float = -active_gait.arm_swing * swing * stride_weight + var abduct: float = (active_gait.arm_lift * stride_weight + 0.12 + * crouch_amount + 0.14) * side + # Elbow closes as the hand comes forward. + var elbow: float = active_gait.elbow_bend * stride_weight \ + + active_gait.elbow_swing * maxf(0.0, -swing) * stride_weight \ + + 0.18 + 0.35 * crouch_amount + pose.set_euler("UpperArm" + suffix, Vector3(pitch, 0.0, abduct)) + pose.set_euler("Forearm" + suffix, Vector3(elbow, 0.10 * side, 0.0)) + pose.set_euler("Clavicle" + suffix, Vector3( + -0.05 * active_gait.arm_swing * swing * stride_weight, 0.0, + 0.05 * side)) + + +## Head: counter-rotate against the shoulders to hold the gaze level, plus the +## small residual bob real heads never quite remove, plus the look target. +func _pose_head(body_phase: GaitEngine.BodyPhase, stride_weight: float, + delta: float) -> void: + var stabilise: float = -body_phase.chest_yaw * 0.55 * stride_weight + # Anticipation: the head leads a turn slightly, because people look where + # they are going before they get there. Added to any explicit look target + # rather than replacing it, so an NPC can hold eye contact while walking. + var anticipation: Vector3 = Vector3.ZERO + if speed_smooth > 0.4: + var local_velocity: Vector3 = _world_to_model.basis * _prev_velocity + anticipation = Vector3(0.0, + clampf(-local_velocity.x * 0.045, -0.22, 0.22), 0.0) + var look: Vector3 = _head_look.step(_look_target + anticipation, delta) + pose.set_euler("Neck", Vector3( + -body_phase.head_pitch * stride_weight - look.x * 0.45, + stabilise * 0.5 + look.y * 0.45, 0.0)) + pose.set_euler("Head", Vector3( + -body_phase.head_pitch * 0.5 * stride_weight - look.x * 0.55, + stabilise * 0.5 + look.y * 0.55, 0.0)) + + +## Point the head at a world position; pass `Vector3.INF` to release it and let +## the neck ease back to neutral. Used for NPC conversation, boss telegraphs +## and Bit's chatter — the head turning to acknowledge things is most of what +## separates a character from a prop. +func look_at_world(target: Vector3) -> void: + if target == Vector3.INF: + _look_target = Vector3.ZERO + return + var local: Vector3 = _world_to_model * target + var flat: float = Vector2(local.x, local.z).length() + # x of the look vector is pitch (positive looks up), y is yaw. + _look_target = Vector3( + clampf(atan2(local.y - profile.height * 0.85, maxf(flat, 0.01)), + -0.45, 0.45), + clampf(atan2(-local.x, -local.z), -0.85, 0.85), 0.0) + + +# --- Idle life --------------------------------------------------------------- + +## Breathing, weight shift and occasional fidgets, all scaled by how still the +## creature is. Without the fidget scheduler an idle character loops a two- +## second breath forever, which the eye picks up within about ten seconds. +func _idle_life(delta: float, stride_weight: float, + crouch_amount: float) -> void: + var calm: float = (1.0 - stride_weight) * (1.0 - air_blend) + if calm <= 0.001: + _fidget_countdown = randf_range(5.0, 11.0) + _fidget_id = -1 + return + + # Breathing: the chest opens and the shoulders rise a little on the inhale. + var breath: float = sin(_idle_time * 1.45) + var breath_amount: float = calm * (1.0 - 0.4 * crouch_amount) + pose.add_euler("Chest", Vector3(-breath * 0.016 * breath_amount, 0.0, 0.0)) + pose.add_euler("Spine", Vector3(breath * 0.009 * breath_amount, 0.0, 0.0)) + pose.add_euler("ClavicleL", Vector3(0.0, 0.0, -breath * 0.020 * breath_amount)) + pose.add_euler("ClavicleR", Vector3(0.0, 0.0, breath * 0.020 * breath_amount)) + + # Slow weight shift from foot to foot, and the arms drift with it. + var shift: float = sin(_idle_time * 0.42) + pose.add_euler("Hips", Vector3(0.0, 0.0, shift * 0.035 * calm)) + pose.add_euler("UpperArmL", Vector3(sin(_idle_time * 0.9) * 0.022 * calm, + 0.0, -shift * 0.020 * calm)) + pose.add_euler("UpperArmR", Vector3(sin(_idle_time * 0.9 + 0.7) * 0.022 + * calm, 0.0, shift * 0.020 * calm)) + pose.add_euler("Neck", Vector3(sin(_idle_time * 0.61) * 0.020 * calm, + sin(_idle_time * 0.37) * 0.045 * calm, 0.0)) + + _tick_fidget(delta, calm) + + +## Schedule and play short additive idle actions so a standing character keeps +## producing new motion instead of looping one breath cycle. +func _tick_fidget(delta: float, calm: float) -> void: + if _fidget_id < 0: + _fidget_countdown -= delta + if _fidget_countdown <= 0.0: + _fidget_id = randi() % 4 + _fidget_time = 0.0 + return + + _fidget_time += delta + var duration: float = 2.4 + if _fidget_time >= duration: + _fidget_id = -1 + _fidget_countdown = randf_range(6.0, 13.0) + return + + # A bell envelope so every fidget eases in and out of the idle pose. + var envelope: float = AM.bell01(_fidget_time / duration) * calm + match _fidget_id: + 0: # Roll the shoulders back. + pose.add_euler("ClavicleL", Vector3(0.0, 0.0, -0.10 * envelope)) + pose.add_euler("ClavicleR", Vector3(0.0, 0.0, 0.10 * envelope)) + pose.add_euler("Chest", Vector3(-0.05 * envelope, 0.0, 0.0)) + 1: # Glance around. + pose.add_euler("Neck", Vector3(0.05 * envelope, 0.34 * envelope, 0.0)) + pose.add_euler("Head", Vector3(0.03 * envelope, 0.22 * envelope, 0.0)) + 2: # Shift weight onto the other hip. + pose.add_euler("Hips", Vector3(0.0, 0.0, -0.07 * envelope)) + pose.add_euler("Spine", Vector3(0.0, 0.0, 0.035 * envelope)) + 3: # Flex the sword hand and settle the shoulder. + pose.add_euler("ForearmR", Vector3(0.20 * envelope, 0.0, 0.0)) + pose.add_euler("UpperArmR", Vector3(-0.09 * envelope, 0.0, + 0.05 * envelope)) + + +# --- Air --------------------------------------------------------------------- + +## Cross-fade toward an airborne pose: knees tuck on the way up, then the legs +## reach for the ground as it approaches. The reach is the part that reads — +## a falling character whose legs hang slack looks unconscious. +func _air_pose(velocity: Vector3) -> void: + if air_blend <= 0.001: + return + var rising: float = AM.remap01(velocity.y, 0.0, 6.0) + var falling: float = AM.remap01(-velocity.y, 1.0, 9.0) + + # How close the ground is, so the legs extend into the landing. + var reach: float = 0.0 + if velocity.y < 0.0 and not feet.is_empty(): + var lowest: float = 999.0 + for entry in feet: + var ground: FootPlanter.FootGround = entry + if ground.found: + lowest = minf(lowest, (_model_to_world * Vector3( + 0.0, profile.ankle_rest_y, 0.0)).y - ground.world_position.y) + if lowest < 900.0: + reach = 1.0 - AM.remap01(lowest, 0.0, LANDING_REACH_DISTANCE) + + var tuck: float = maxf(rising, falling * (1.0 - reach)) + var weight: float = air_blend + + # Trailing leg tucks harder than the leading one, which keeps the silhouette + # asymmetric — symmetric air poses read as a mannequin dropped from a crane. + pose.blend_euler("ThighL", Vector3(0.62 * tuck + 0.30 * reach, 0.0, 0.04), + weight) + pose.blend_euler("ThighR", Vector3(0.34 * tuck + 0.42 * reach, 0.0, -0.04), + weight) + pose.blend_euler("ShinL", Vector3(-(1.15 * tuck + 0.16), 0.0, 0.0), weight) + pose.blend_euler("ShinR", Vector3(-(0.78 * tuck + 0.16), 0.0, 0.0), weight) + pose.blend_euler("FootL", Vector3(0.22 * tuck - 0.18 * reach, 0.0, 0.0), + weight) + pose.blend_euler("FootR", Vector3(0.18 * tuck - 0.18 * reach, 0.0, 0.0), + weight) + # Arms lift and open for balance, more so the faster the fall. + pose.blend_euler("UpperArmL", Vector3(-0.45 * tuck - 0.30 * falling, 0.0, + -(0.30 + 0.25 * falling)), weight) + pose.blend_euler("UpperArmR", Vector3(-0.45 * tuck - 0.30 * falling, 0.0, + 0.30 + 0.25 * falling), weight) + pose.blend_euler("ForearmL", Vector3(0.55 + 0.30 * falling, 0.0, 0.0), weight) + pose.blend_euler("ForearmR", Vector3(0.55 + 0.30 * falling, 0.0, 0.0), weight) + pose.blend_euler("Spine", Vector3(-0.10 * falling + 0.12 * rising, 0.0, 0.0), + weight) + pose.blend_euler("Chest", Vector3(-0.12 * falling + 0.10 * rising, 0.0, 0.0), + weight) diff --git a/game/src/anim/creature_animator.gd.uid b/game/src/anim/creature_animator.gd.uid new file mode 100644 index 0000000..6cd7c63 --- /dev/null +++ b/game/src/anim/creature_animator.gd.uid @@ -0,0 +1 @@ +uid://diss1kmkes1au diff --git a/game/src/anim/emote_library.gd b/game/src/anim/emote_library.gd new file mode 100644 index 0000000..69f7f6f --- /dev/null +++ b/game/src/anim/emote_library.gd @@ -0,0 +1,446 @@ +class_name EmoteLibrary +extends RefCounted +## Every emote and dance in Gradientfall, authored procedurally as functions of +## time rather than as keyframed clips. +## +## **Why procedural.** The project generates all assets in code (CLAUDE.md +## § Conventions), so there is no animation-import path to hang a dance on. It +## turns out to be the better fit anyway: a dance is mostly oscillators — +## phase-shifted limbs, held beats, body rolls — and writing `sin(beat * TAU)` +## is both shorter and more editable than baking sixty keyframes of the same +## curve. Emotes also inherit the rig's units, so re-proportioning the body +## does not desynchronise them. +## +## **Canon.** The dances are named out of the world's own vocabulary — a +## gradient descent, a dropout, an overfit — rather than borrowed from +## elsewhere. They read as Gradientfall's, and the joke lands for the audience +## the game is actually about. +## +## **Rotation conventions on this rig** (verified against +## `kern_body_builder.gd`'s rest skeleton, where every limb bone hangs DOWN and +## the spine chain points UP): +## * UpperArm/Thigh +X -> swings the limb FORWARD (flexion). +## * Forearm +X -> elbow flexion (hand comes up and forward). +## * Shin -X -> knee flexion. POSITIVE X hyperextends the knee, +## which is how the placeholder gait ended up bending +## Kern's knees backwards; `_leg()` takes flexion as a +## positive number and applies the sign itself so no +## emote can repeat that mistake. +## * Spine/Chest/Neck -X -> leans/looks FORWARD (the chain points up, so the +## sign is inverted from the limbs). +## * Abduction (limb out to the side) is +Z on the right, -Z on the left; +## `_arm()`/`_leg()` take a side flag and handle the mirroring. +## +## **Architecture.** Pure functions plus a metadata table; depends on +## `anim_math.gd` and `pose_stack.gd`. Driven by `emote_player.gd`, which owns +## timing and blending. See `docs/ARCHITECTURE.md` § "Procedural animation". + +const AM: GDScript = preload("res://src/anim/anim_math.gd") + + +## Metadata for one emote. The pose itself lives in `evaluate()`. +class EmoteDef: + extends RefCounted + + ## Stable id, used by input bindings, saves and the emote wheel. + var id: String = "" + ## Name shown in the emote wheel. + var display_name: String = "" + ## Seconds for one pass. For looping emotes this is the loop length. + var duration: float = 2.0 + ## Whether the emote repeats until cancelled. + var loops: bool = false + ## Seconds to ease in and out. Long enough to read as a transition, short + ## enough that the emote still feels responsive to the button. + var blend_in: float = 0.22 + var blend_out: float = 0.28 + ## True if the emote poses the legs, in which case the animator hands the + ## legs over and stops planting the feet with IK. + var overrides_legs: bool = true + ## True if the player is pinned in place while it plays. + var locks_movement: bool = true + ## Category, for grouping in the wheel: "dance" or "gesture". + var category: String = "gesture" + + +## Ordered emote table. Order is the emote wheel's order. +static func defs() -> Array: + var out: Array = [] + out.append(_def("wave", "Wave", 2.2, false, "gesture", false, false)) + out.append(_def("cheer", "Cheer", 2.0, false, "gesture", true, true)) + out.append(_def("bow", "Bow", 2.6, false, "gesture", true, true)) + out.append(_def("point", "Point", 1.8, false, "gesture", false, false)) + out.append(_def("salute", "Salute", 1.9, false, "gesture", false, false)) + out.append(_def("think", "Ponder", 3.4, true, "gesture", false, true)) + out.append(_def("stretch", "Stretch", 3.2, false, "gesture", true, true)) + out.append(_def("sit", "Sit", 2.8, true, "gesture", true, true)) + out.append(_def("gradient_descent", "Gradient Descent", 2.4, true, "dance", true, true)) + out.append(_def("backprop", "The Backprop", 1.6, true, "dance", true, true)) + out.append(_def("overfit", "Overfit", 2.0, true, "dance", true, true)) + out.append(_def("dropout", "Dropout", 2.4, true, "dance", true, true)) + out.append(_def("convergence", "Convergence", 3.0, true, "dance", true, true)) + out.append(_def("weight_shuffle", "Weight Shuffle", 1.4, true, "dance", true, true)) + out.append(_def("epoch_step", "Epoch Step", 2.0, true, "dance", true, true)) + out.append(_def("softmax", "Softmax", 2.8, true, "dance", true, true)) + return out + + +static func _def(id: String, display_name: String, duration: float, + loops: bool, category: String, overrides_legs: bool, + locks_movement: bool) -> EmoteDef: + var d: EmoteDef = EmoteDef.new() + d.id = id + d.display_name = display_name + d.duration = duration + d.loops = loops + d.category = category + d.overrides_legs = overrides_legs + d.locks_movement = locks_movement + return d + + +## Look one up by id, or null. +static func find(id: String) -> EmoteDef: + for entry in defs(): + var d: EmoteDef = entry + if d.id == id: + return d + return null + + +## Write emote `id` into `pose`. +## +## `t` is seconds since the emote started; `beat` is the normalised position in +## the current pass, 0..1 (wrapping for looping emotes). Splitting the two lets +## an emote use `beat` for its cyclic content and `t` for one-shot ramps. +static func evaluate(id: String, pose: PoseStack, t: float, + beat: float) -> void: + match id: + "wave": _wave(pose, beat) + "cheer": _cheer(pose, beat) + "bow": _bow(pose, beat) + "point": _point(pose, beat) + "salute": _salute(pose, beat) + "think": _think(pose, beat) + "stretch": _stretch(pose, beat) + "sit": _sit(pose, t, beat) + "gradient_descent": _gradient_descent(pose, beat) + "backprop": _backprop(pose, beat) + "overfit": _overfit(pose, beat) + "dropout": _dropout(pose, beat) + "convergence": _convergence(pose, t, beat) + "weight_shuffle": _weight_shuffle(pose, beat) + "epoch_step": _epoch_step(pose, beat) + "softmax": _softmax(pose, beat) + _: pass + + +# --- Pose helpers ------------------------------------------------------------ + +## Pose one arm. `pitch` is forward flexion, `abduct` lifts the arm away from +## the body (sign handled per side), `twist` rotates about the bone, `elbow` is +## flexion. Radians throughout. +static func _arm(pose: PoseStack, right: bool, pitch: float, abduct: float, + twist: float, elbow: float) -> void: + var side: float = 1.0 if right else -1.0 + var suffix: String = "R" if right else "L" + pose.set_euler("UpperArm" + suffix, + Vector3(pitch, twist * side, abduct * side)) + pose.set_euler("Forearm" + suffix, Vector3(elbow, 0.0, 0.0)) + + +## Pose one leg. `hip_pitch` swings the thigh forward, `abduct` opens the leg +## outward, `knee` is FLEXION as a positive number (the negation that a real +## knee needs is applied here, once, so callers cannot get it backwards). +static func _leg(pose: PoseStack, right: bool, hip_pitch: float, + abduct: float, knee: float, ankle: float = 0.0) -> void: + var side: float = 1.0 if right else -1.0 + var suffix: String = "R" if right else "L" + pose.set_euler("Thigh" + suffix, Vector3(hip_pitch, 0.0, abduct * side)) + pose.set_euler("Shin" + suffix, Vector3(-absf(knee), 0.0, 0.0)) + pose.set_euler("Foot" + suffix, Vector3(ankle, 0.0, 0.0)) + + +## Pose the spine chain. All three take FORWARD lean as a positive number and +## invert internally, because the spine bones point up. +static func _torso(pose: PoseStack, lean: float, twist: float, + roll: float) -> void: + pose.set_euler("Hips", Vector3(-lean * 0.25, twist * 0.45, roll * 0.5)) + pose.set_euler("Spine", Vector3(-lean * 0.35, twist * 0.25, roll * 0.3)) + pose.set_euler("Chest", Vector3(-lean * 0.40, twist * 0.30, roll * 0.2)) + + +## Pose the head. `pitch` positive looks DOWN. +static func _head(pose: PoseStack, pitch: float, yaw: float, + tilt: float) -> void: + pose.set_euler("Neck", Vector3(-pitch * 0.5, yaw * 0.5, tilt * 0.5)) + pose.set_euler("Head", Vector3(-pitch * 0.5, yaw * 0.5, tilt * 0.5)) + + +## Square wave with softened edges — the "held then snapped" timing that makes +## robotic and hip-hop styled dances read as choreography rather than as a sine. +static func _snap(beat: float, sharpness: float = 8.0) -> float: + return tanh(sin(beat * TAU) * sharpness) + + +# --- Gestures ---------------------------------------------------------------- + +## An open-handed wave: arm up and out, forearm oscillating from the elbow. +static func _wave(pose: PoseStack, beat: float) -> void: + var raise: float = AM.bell01(clampf(beat * 1.15, 0.0, 1.0)) + var flap: float = sin(beat * TAU * 3.0) + _arm(pose, true, -0.35 * raise, 1.15 * raise, 0.0, + (0.95 + 0.30 * flap) * raise) + _arm(pose, false, 0.0, 0.10, 0.0, 0.22) + _torso(pose, 0.02, -0.06 * raise, 0.0) + _head(pose, -0.05 * raise, 0.10 * raise, 0.06 * flap * raise) + + +## Both arms thrown up, with a small hop's worth of body rise. +static func _cheer(pose: PoseStack, beat: float) -> void: + var up: float = AM.bell01(clampf(beat * 1.2, 0.0, 1.0)) + var pump: float = sin(beat * TAU * 2.0) * up + _arm(pose, true, -1.95 * up - 0.15 * pump, 0.42 * up, 0.0, 0.30 * up) + _arm(pose, false, -1.95 * up + 0.15 * pump, 0.42 * up, 0.0, 0.30 * up) + _leg(pose, true, 0.05 * up, 0.03, 0.16 * up) + _leg(pose, false, 0.05 * up, 0.03, 0.16 * up) + _torso(pose, -0.18 * up, 0.0, 0.0) + _head(pose, -0.22 * up, 0.0, 0.0) + pose.root_offset = Vector3(0.0, 0.06 * up, 0.0) + + +## A formal bow from the waist, one arm across the chest. +static func _bow(pose: PoseStack, beat: float) -> void: + var depth: float = AM.bell01(clampf(beat * 1.1, 0.0, 1.0)) + _torso(pose, 1.05 * depth, 0.0, 0.0) + _head(pose, 0.35 * depth, 0.0, 0.0) + _arm(pose, true, -0.45 * depth, -0.25 * depth, 0.0, 1.35 * depth) + _arm(pose, false, 0.30 * depth, 0.30 * depth, 0.0, 0.18) + _leg(pose, true, 0.0, 0.02, 0.06 * depth) + _leg(pose, false, -0.10 * depth, 0.05, 0.10 * depth) + pose.root_offset = Vector3(0.0, -0.05 * depth, 0.0) + + +## Point straight ahead, weight shifting onto the front foot. +static func _point(pose: PoseStack, beat: float) -> void: + var out: float = AM.bell01(clampf(beat * 1.25, 0.0, 1.0)) + _arm(pose, true, -1.48 * out, 0.10 * out, 0.0, 0.08 * out) + _arm(pose, false, 0.05, 0.08, 0.0, 0.24) + _torso(pose, 0.06 * out, -0.16 * out, 0.0) + _head(pose, -0.02 * out, 0.06 * out, 0.0) + pose.root_offset = Vector3(0.0, 0.0, -0.04 * out) + + +## Crisp salute — hand to brow, held, released. +static func _salute(pose: PoseStack, beat: float) -> void: + var up: float = AM.smoothstep01(clampf(beat * 4.0, 0.0, 1.0)) \ + * (1.0 - AM.smoothstep01(AM.remap01(beat, 0.78, 1.0))) + _arm(pose, true, -0.55 * up, 0.62 * up, 0.0, 2.05 * up) + _arm(pose, false, 0.0, 0.05, 0.0, 0.18) + _leg(pose, true, 0.0, 0.0, 0.04) + _leg(pose, false, 0.0, 0.0, 0.04) + _torso(pose, -0.06 * up, 0.0, 0.0) + _head(pose, -0.06 * up, 0.0, 0.0) + + +## Hand to chin, weight on one hip, slow head drift. Loops. +static func _think(pose: PoseStack, beat: float) -> void: + var drift: float = sin(beat * TAU) + _arm(pose, true, -0.62, 0.18, 0.0, 2.15 + 0.06 * drift) + _arm(pose, false, 0.10, 0.02, 0.0, 0.85) + _torso(pose, 0.10, 0.05 * drift, 0.10) + _head(pose, 0.10 + 0.05 * drift, 0.12 * drift, 0.10) + _leg(pose, true, -0.04, 0.03, 0.10) + _leg(pose, false, 0.02, 0.06, 0.22) + pose.root_offset = Vector3(0.03, -0.02, 0.0) + + +## An overhead stretch with a yawning arch, then release. +static func _stretch(pose: PoseStack, beat: float) -> void: + var arch: float = AM.bell01(clampf(beat * 1.1, 0.0, 1.0)) + var twist: float = sin(beat * TAU) * 0.12 + _arm(pose, true, -2.25 * arch, 0.30 * arch, 0.0, 0.35 * arch) + _arm(pose, false, -2.25 * arch, 0.30 * arch, 0.0, 0.35 * arch) + _torso(pose, -0.30 * arch, twist, 0.0) + _head(pose, -0.28 * arch, twist * 0.5, 0.0) + _leg(pose, true, 0.0, 0.04, 0.05) + _leg(pose, false, 0.0, 0.04, 0.05) + pose.root_offset = Vector3(0.0, 0.045 * arch, 0.0) + + +## Sit cross-legged on the ground, with a slow breathing sway. Loops; `t` +## drives the one-time descent so the loop does not re-play it. +static func _sit(pose: PoseStack, t: float, beat: float) -> void: + var down: float = AM.smoothstep01(clampf(t / 0.8, 0.0, 1.0)) + var sway: float = sin(beat * TAU) * 0.03 + _leg(pose, true, 1.15 * down, 0.55 * down, 2.15 * down) + _leg(pose, false, 1.15 * down, 0.55 * down, 2.15 * down) + _arm(pose, true, 0.30 * down, 0.16 * down, 0.0, 0.70 * down) + _arm(pose, false, 0.30 * down, 0.16 * down, 0.0, 0.70 * down) + _torso(pose, (0.16 + sway) * down, 0.0, 0.0) + _head(pose, (0.06 + sway) * down, sway * 2.0, 0.0) + # Drop to the floor: the hips end up roughly a knee-height below standing. + pose.root_offset = Vector3(0.0, -0.62 * down, 0.0) + + +# --- Dances ------------------------------------------------------------------ + +## **Gradient Descent** — a staircase shuffle that steps the body down in +## discrete drops and springs back to the top, the shape of the algorithm. +static func _gradient_descent(pose: PoseStack, beat: float) -> void: + # Four descending steps, then a reset leap back to the start. + var steps: float = 4.0 + var descending: float = clampf(beat / 0.82, 0.0, 1.0) + var stair: float = floor(descending * steps) / steps + var reset: float = AM.smoothstep01(AM.remap01(beat, 0.82, 1.0)) + var height: float = lerpf(-stair * 0.20, 0.0, reset) + var step_beat: float = fposmod(descending * steps, 1.0) + var lead: float = sin(step_beat * TAU) + + _leg(pose, true, 0.22 * lead, 0.06, 0.30 + 0.28 * maxf(0.0, lead)) + _leg(pose, false, -0.22 * lead, 0.06, 0.30 + 0.28 * maxf(0.0, -lead)) + # Arms chop downward on each step, like marking off a descent. + _arm(pose, true, -0.85 + 0.55 * lead, 0.30, 0.0, 1.25) + _arm(pose, false, -0.85 - 0.55 * lead, 0.30, 0.0, 1.25) + _torso(pose, 0.22 + 0.10 * reset, 0.10 * lead, 0.0) + _head(pose, 0.12 - 0.30 * reset, 0.0, 0.0) + pose.root_offset = Vector3(0.0, height + 0.06 * reset, 0.0) + + +## **The Backprop** — a running man travelling the wrong way, error signal +## chasing itself back through the layers. +static func _backprop(pose: PoseStack, beat: float) -> void: + var drive: float = sin(beat * TAU) + var opposite: float = sin(beat * TAU + PI) + # One knee drives up while the other slides back — the running-man shape. + _leg(pose, true, 0.85 * maxf(0.0, drive), 0.04, + 0.30 + 1.05 * maxf(0.0, drive), -0.25 * minf(0.0, drive)) + _leg(pose, false, 0.85 * maxf(0.0, opposite), 0.04, + 0.30 + 1.05 * maxf(0.0, opposite), -0.25 * minf(0.0, opposite)) + _arm(pose, true, 0.75 * opposite, 0.14, 0.0, 1.35) + _arm(pose, false, 0.75 * drive, 0.14, 0.0, 1.35) + _torso(pose, 0.16, -0.14 * drive, 0.0) + _head(pose, 0.04, -0.10 * drive, 0.0) + pose.root_offset = Vector3(0.0, 0.035 * absf(drive), 0.0) + + +## **Overfit** — the robot: every joint lands exactly on the beat and holds, +## fitting the training data far too precisely. +static func _overfit(pose: PoseStack, beat: float) -> void: + # Quantise the beat to eighths and snap between them: the "too precise" read. + var quantised: float = floor(beat * 8.0) / 8.0 + var snap: float = _snap(quantised, 12.0) + var alt: float = _snap(quantised + 0.25, 12.0) + _arm(pose, true, -1.55 - 0.65 * snap, 0.90 + 0.55 * alt, 0.0, + 1.70 + 0.60 * snap) + _arm(pose, false, -1.55 + 0.65 * snap, 0.90 - 0.55 * alt, 0.0, + 1.70 - 0.60 * snap) + _leg(pose, true, 0.10 + 0.10 * snap, 0.12 + 0.10 * snap, 0.30) + _leg(pose, false, 0.10 - 0.10 * snap, 0.12 - 0.10 * snap, 0.30) + _torso(pose, 0.10, 0.40 * alt, 0.24 * snap) + _head(pose, 0.0, 0.50 * snap, 0.28 * alt) + pose.root_offset = Vector3(0.05 * snap, 0.0, 0.0) + + +## **Dropout** — dancing full-out, then random limbs cut to zero for a beat and +## come back. The units keep dropping out. +static func _dropout(pose: PoseStack, beat: float) -> void: + var swing: float = sin(beat * TAU * 2.0) + var bounce: float = absf(sin(beat * TAU * 2.0)) + # Deterministic per-quarter-beat mask, so the "randomness" is identical on + # every machine and every replay — a dance that desyncs is not a dance. + var slot: int = int(beat * 8.0) % 8 + var arm_r: float = 0.0 if slot == 1 or slot == 5 else 1.0 + var arm_l: float = 0.0 if slot == 3 or slot == 6 else 1.0 + var leg_gate: float = 0.0 if slot == 7 else 1.0 + + _arm(pose, true, (-1.75 + 1.15 * swing) * arm_r, 1.15 * arm_r, 0.0, + 1.35 * arm_r + 0.30) + _arm(pose, false, (-1.75 - 1.15 * swing) * arm_l, 1.15 * arm_l, 0.0, + 1.35 * arm_l + 0.30) + _leg(pose, true, 0.42 * swing * leg_gate, 0.16, + 0.35 + 0.80 * bounce * leg_gate) + _leg(pose, false, -0.42 * swing * leg_gate, 0.16, + 0.35 + 0.80 * bounce * leg_gate) + _torso(pose, 0.24, 0.46 * swing, 0.34 * swing) + _head(pose, 0.12 - 0.30 * bounce, 0.38 * swing, 0.0) + pose.root_offset = Vector3(0.0, -0.13 * bounce, 0.0) + + +## **Convergence** — a wide spin that decays into a still, centred pose, then +## opens out again. Slower and more graceful than the rest. +static func _convergence(pose: PoseStack, _t: float, beat: float) -> void: + # Amplitude decays across the loop and re-expands at the end: the classic + # damped-oscillation shape, danced. + var envelope: float = exp(-3.2 * beat) + AM.smoothstep01( + AM.remap01(beat, 0.85, 1.0)) + var swirl: float = sin(beat * TAU * 3.0) * envelope + _arm(pose, true, -0.95 - 0.55 * swirl, 0.95 * envelope, 0.30 * swirl, 0.55) + _arm(pose, false, -0.95 + 0.55 * swirl, 0.95 * envelope, -0.30 * swirl, 0.55) + _leg(pose, true, 0.12 * swirl, 0.10 * envelope, 0.22 + 0.20 * envelope) + _leg(pose, false, -0.12 * swirl, 0.10 * envelope, 0.22 + 0.20 * envelope) + _torso(pose, 0.10, 0.30 * swirl, 0.18 * swirl) + _head(pose, 0.0, 0.35 * swirl, 0.10 * swirl) + # The whole body rotates, winding down toward a settled heading. + pose.root_spin = swirl * 0.85 + pose.root_offset = Vector3(0.0, 0.03 * envelope * absf(swirl), 0.0) + + +## **Weight Shuffle** — hips hard one way, arms swinging hard the other, both +## arms sweeping across the body on every beat. The party dance of Bootstrap. +## +## Amplitudes here are deliberately LARGE. The first pass at these dances used +## the same restrained numbers as the locomotion layer — a fifth of a radian +## here and there — and the result read as a man shifting his weight +## uncomfortably rather than dancing. An emote is a performance: it has to be +## legible from across a field, at a glance, over a cloak. +static func _weight_shuffle(pose: PoseStack, beat: float) -> void: + var hips: float = sin(beat * TAU) + var arms: float = sin(beat * TAU + PI) + var cross: float = absf(arms) + var bounce: float = absf(sin(beat * TAU * 2.0)) + _arm(pose, true, -1.15 + 0.85 * arms, 1.20 - 1.05 * cross, 0.0, + 0.70 + 1.35 * cross) + _arm(pose, false, -1.15 - 0.85 * arms, 1.20 - 1.05 * cross, 0.0, + 0.70 + 1.35 * cross) + _leg(pose, true, 0.34 * hips, 0.16, 0.42 + 0.46 * maxf(0.0, hips)) + _leg(pose, false, -0.34 * hips, 0.16, 0.42 + 0.46 * maxf(0.0, -hips)) + _torso(pose, 0.20, -0.62 * hips, 0.48 * hips) + _head(pose, 0.06, -0.34 * hips, 0.26 * hips) + pose.root_offset = Vector3(0.13 * hips, -0.10 * bounce, 0.0) + + +## **Epoch Step** — a two-step with a clap on the turn of each epoch. +static func _epoch_step(pose: PoseStack, beat: float) -> void: + var step: float = sin(beat * TAU) + # Clap lands on the half beat; hands come together sharply and part slowly. + var clap_phase: float = fposmod(beat * 2.0, 1.0) + var clap: float = 1.0 - AM.smoothstep01(clampf(clap_phase / 0.35, 0.0, 1.0)) + _arm(pose, true, -1.35, 1.05 - 0.92 * clap, 0.0, 1.45 + 0.40 * clap) + _arm(pose, false, -1.35, 1.05 - 0.92 * clap, 0.0, 1.45 + 0.40 * clap) + _leg(pose, true, 0.52 * step, 0.13, 0.32 + 0.62 * maxf(0.0, step)) + _leg(pose, false, -0.52 * step, 0.13, 0.32 + 0.62 * maxf(0.0, -step)) + _torso(pose, 0.18, 0.34 * step, 0.26 * step) + _head(pose, -0.10, 0.26 * step, 0.0) + pose.root_offset = Vector3(0.07 * step, 0.06 * clap, 0.0) + + +## **Softmax** — a smooth full-body wave travelling shoulder to shoulder, every +## joint sharing the motion rather than any one taking it all. +static func _softmax(pose: PoseStack, beat: float) -> void: + # One travelling wave, each segment further down the chain lagging further. + var wave: float = sin(beat * TAU) + var lag1: float = sin(beat * TAU - 0.55) + var lag2: float = sin(beat * TAU - 1.10) + var lag3: float = sin(beat * TAU - 1.65) + _arm(pose, true, -0.55 + 0.35 * wave, 1.05 + 0.25 * wave, 0.0, + 0.75 + 0.45 * lag1) + _arm(pose, false, -0.55 - 0.35 * wave, 1.05 - 0.25 * wave, 0.0, + 0.75 + 0.45 * lag2) + _leg(pose, true, 0.08 * lag3, 0.08, 0.28 + 0.14 * maxf(0.0, lag3)) + _leg(pose, false, -0.08 * lag3, 0.08, 0.28 + 0.14 * maxf(0.0, -lag3)) + pose.set_euler("Hips", Vector3(-0.06, 0.14 * wave, 0.16 * wave)) + pose.set_euler("Spine", Vector3(-0.10 * lag1, 0.10 * lag1, 0.12 * lag1)) + pose.set_euler("Chest", Vector3(-0.12 * lag2, 0.12 * lag2, 0.10 * lag2)) + _head(pose, 0.06 * lag3, 0.14 * lag3, 0.12 * lag3) + pose.root_offset = Vector3(0.03 * wave, 0.02 * absf(lag2), 0.0) diff --git a/game/src/anim/emote_library.gd.uid b/game/src/anim/emote_library.gd.uid new file mode 100644 index 0000000..40f5d23 --- /dev/null +++ b/game/src/anim/emote_library.gd.uid @@ -0,0 +1 @@ +uid://famhgfxcyewa diff --git a/game/src/anim/emote_player.gd b/game/src/anim/emote_player.gd new file mode 100644 index 0000000..ab205ed --- /dev/null +++ b/game/src/anim/emote_player.gd @@ -0,0 +1,124 @@ +class_name EmotePlayer +extends RefCounted +## Timing and blending for emotes: which one is playing, how far through it is, +## and how strongly it currently overrides the creature's locomotion. +## +## **Separation of concerns.** `emote_library.gd` answers "what pose is this +## dance at beat 0.4"; this class answers "which dance, how long has it run, and +## how much of the body does it own right now". Keeping them apart means the +## library stays a table of pure functions — trivially testable and safe to hot- +## edit — while all the stateful awkwardness (blend ramps, one-shot completion, +## cancel-on-move) lives in exactly one place. +## +## **Blending out matters as much as blending in.** An emote that snaps off the +## instant the player touches a stick looks worse than one that never played. +## The player keeps evaluating a stopped emote at its final beat while the +## weight ramps down, so Kern eases back into his stride instead of teleporting +## into it. +## +## **Architecture.** Depends on `anim_math.gd`, `pose_stack.gd` and +## `emote_library.gd`; owns no nodes. Driven by `creature_animator.gd`, which +## composites `pose()` over the locomotion pose at `weight()`. + +const AM: GDScript = preload("res://src/anim/anim_math.gd") + +## The emote currently playing (or blending out), null when idle. +var current: EmoteLibrary.EmoteDef = null + +## Scratch pose the library writes into each frame. +var pose: PoseStack = PoseStack.new() + +var _elapsed: float = 0.0 +var _weight: float = 0.0 +var _stopping: bool = false + + +## Start emote `id`. Returns false if the id is unknown. Re-issuing the emote +## that is already playing restarts it, which is what a second button press +## should do. +func play(id: String) -> bool: + var def: EmoteLibrary.EmoteDef = EmoteLibrary.find(id) + if def == null: + return false + current = def + _elapsed = 0.0 + _stopping = false + return true + + +## Begin blending out. The emote keeps posing at its last beat until the weight +## reaches zero, then clears itself. +func stop() -> void: + if current != null: + _stopping = true + + +## Drop the emote instantly with no blend — for cutscenes, death and teleports, +## where a graceful exit would be wrong. +func cancel() -> void: + current = null + _stopping = false + _elapsed = 0.0 + _weight = 0.0 + pose.clear() + + +## True while an emote owns any part of the body. +func is_active() -> bool: + return current != null or _weight > 0.001 + + +## True while an emote is playing forward (not already blending out) — the +## test the controller uses to decide whether to pin the player in place. +func is_playing() -> bool: + return current != null and not _stopping + + +## How much of the body the emote currently owns, 0..1. +func weight() -> float: + return _weight + + +## True if the active emote poses the legs, so the animator should hand them +## over and stop planting feet with IK. +func overrides_legs() -> bool: + return current != null and current.overrides_legs + + +## True if the active emote pins the player in place. +func locks_movement() -> bool: + return is_playing() and current.locks_movement + + +## Advance timing, evaluate the pose, and update the blend weight. +func tick(delta: float) -> void: + if current == null: + _weight = maxf(0.0, _weight - delta * 4.0) + if _weight <= 0.001: + pose.clear() + return + + _elapsed += delta + var beat: float = 0.0 + if current.loops: + beat = fposmod(_elapsed / maxf(current.duration, 0.01), 1.0) + else: + beat = clampf(_elapsed / maxf(current.duration, 0.01), 0.0, 1.0) + # A one-shot that has run its course starts blending out on its own. + if _elapsed >= current.duration: + _stopping = true + + # Weight ramps in over blend_in and out over blend_out, both frame-rate + # independent (a fixed per-frame step would blend faster at high fps). + var target: float = 0.0 if _stopping else 1.0 + var ramp: float = current.blend_out if _stopping else current.blend_in + _weight = move_toward(_weight, target, delta / maxf(ramp, 0.01)) + + pose.clear() + EmoteLibrary.evaluate(current.id, pose, _elapsed, beat) + + if _stopping and _weight <= 0.001: + current = null + _stopping = false + _elapsed = 0.0 + pose.clear() diff --git a/game/src/anim/emote_player.gd.uid b/game/src/anim/emote_player.gd.uid new file mode 100644 index 0000000..0d629cf --- /dev/null +++ b/game/src/anim/emote_player.gd.uid @@ -0,0 +1 @@ +uid://dhx8ssbih7w2f diff --git a/game/src/anim/foot_planter.gd b/game/src/anim/foot_planter.gd new file mode 100644 index 0000000..c1730e0 --- /dev/null +++ b/game/src/anim/foot_planter.gd @@ -0,0 +1,240 @@ +class_name FootPlanter +extends RefCounted +## Puts a creature's feet on the actual ground: per-foot terrain probes, a world +## plant-lock that survives the body turning, ankle alignment to the surface +## normal, and the pelvis drop that keeps both legs inside their reach. +## +## **Why this exists.** `gait_engine.gd` guarantees a planted foot holds still +## relative to the BODY, which removes foot slip on flat ground. It knows +## nothing about the world: on Datasedge Meadows' heightmap the same perfect +## stride still floats a foot over a dip and buries one in a rise. This class +## closes that gap by raycasting the real collision world under each foot and +## correcting the gait's ideal target onto the surface it finds. +## +## **The plant lock.** Once a foot touches down its WORLD position is recorded +## and held for the rest of the stance. That is stricter than the gait alone: +## it also kills the slip that appears when the body rotates under a planted +## foot (turning on the spot used to drag both feet sideways through the +## ground). The lock has a leash — beyond `MAX_LEASH` metres the foot gives up +## and slides to the new spot, which is exactly the scuffing pivot a person +## makes when they spin in place, so the failure mode is itself correct. +## +## **Architecture.** Needs a node for its physics-space handle but owns no +## nodes; depends on `anim_math.gd` and `gait_engine.gd`. Consumed by +## `creature_animator.gd`, and reusable by any legged creature — a quadruped +## makes four instances. See `docs/ARCHITECTURE.md` § "Procedural animation". + +const AM: GDScript = preload("res://src/anim/anim_math.gd") + +## How far above the ideal foot position the probe ray starts, metres. Must +## clear the tallest step the creature can walk up. +const PROBE_UP: float = 0.85 + +## How far below it the probe ray reaches, metres. Sets how deep a hole the +## foot will still try to reach into before giving up and staying airborne. +const PROBE_DOWN: float = 1.25 + +## Metres a locked foot may be dragged before it releases and re-plants. +const MAX_LEASH: float = 0.42 + +## Half-life for the pelvis following the terrain, seconds. Slower than the +## feet on purpose: the hips of a person walking over rough ground lag the +## ankles, and matching them exactly makes the whole body jitter. +const PELVIS_HALF_LIFE: float = 0.085 + +## Half-life for a foot's ground height easing while airborne — stops a foot +## from snapping when it swings out over a cliff edge. +const AIR_HALF_LIFE: float = 0.06 + +## Steepest surface (radians from horizontal) the ankle will still align to. +## Beyond this the foot keeps a level-ish pose rather than standing on edge. +const MAX_ALIGN_ANGLE: float = 0.72 + + +## Everything known about one foot this frame. +class FootGround: + extends RefCounted + + ## True when the probe found a surface at all. + var found: bool = false + ## World position the foot should occupy, after probing and locking. + var world_position: Vector3 = Vector3.ZERO + ## Surface normal under the foot, world space. + var normal: Vector3 = Vector3.UP + ## Height of the surface under the foot, world space. + var ground_y: float = 0.0 + ## How planted this foot is, 0..1 — copied through from the gait so the IK + ## and the ankle alignment fade together. + var contact: float = 0.0 + ## True while the world lock is holding this foot in place. + var locked: bool = false + ## True while this foot is in STANCE — genuinely bearing load on the ground. + ## + ## Distinct from `contact`, which deliberately ramps up during the last + ## fraction of the swing so that foot IK and surface alignment ease in + ## before touchdown rather than snapping on. Anything asking "is this foot + ## planted right now" — footstep audio, dust, slip measurement — must use + ## THIS, not `contact`, or it fires while the foot is still travelling. + var stance: bool = false + + +## Per-foot persistent state. +var _lock_position: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO] +var _locked: Array[bool] = [false, false] +var _ground_y: Array[float] = [0.0, 0.0] +var _normal: Array[Vector3] = [Vector3.UP, Vector3.UP] +var _ground_valid: Array[bool] = [false, false] + +## Smoothed pelvis vertical correction, metres (never positive — the pelvis +## only ever drops to keep the lower foot reachable). +var pelvis_offset: float = 0.0 + +var _space: PhysicsDirectSpaceState3D +var _mask: int = 1 +var _exclude: Array[RID] = [] + + +## Bind to a physics world. `owner_node` supplies the space; `collision_mask` +## should be the world/terrain layer; `exclude` keeps the creature's own +## collider from being probed (otherwise every foot lands on the capsule). +func setup(owner_node: Node3D, collision_mask: int, + exclude: Array[RID]) -> void: + _space = owner_node.get_world_3d().direct_space_state + _mask = collision_mask + _exclude = exclude + + +## Drop the lock and the smoothing — call on teleport/respawn so the feet do +## not stretch back toward wherever the creature used to be standing. +func reset() -> void: + _locked = [false, false] + _ground_valid = [false, false] + pelvis_offset = 0.0 + + +## Probe and resolve one foot. +## +## `index` is 0 (left) or 1 (right). `ideal_world` is where the gait wants the +## ANKLE JOINT, already in world space. `contact` is the gait's contact weight. +## `sole_offset` is how far the ankle joint sits above the sole of the foot in +## metres — without it the solver drives the ankle onto the surface and buries +## the whole foot in the ground. `lift` is the gait's requested clearance above +## the surface. Returns the resolved ground state. +func resolve(index: int, ideal_world: Vector3, contact: float, delta: float, + sole_offset: float, lift: float, flat: float) -> FootGround: + var out: FootGround = FootGround.new() + out.contact = contact + + if _space == null: + out.world_position = ideal_world + return out + + # Probe straight down through the ideal position. + var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create( + ideal_world + Vector3.UP * PROBE_UP, + ideal_world + Vector3.DOWN * PROBE_DOWN) + query.collision_mask = _mask + query.exclude = _exclude + var hit: Dictionary = _space.intersect_ray(query) + + if hit.is_empty(): + # Nothing under the foot (a ledge, a gap). Keep the last known height + # and let the foot hang at the gait's ideal — the air pose owns it. + out.found = false + _ground_valid[index] = false + out.ground_y = _ground_y[index] + out.normal = _normal[index] + else: + out.found = true + _ground_valid[index] = true + var hit_y: float = (hit["position"] as Vector3).y + # Ease the sampled height while the foot is in the air so swinging out + # over a step change does not snap the ankle. + _ground_y[index] = AM.damp(_ground_y[index], hit_y, + AIR_HALF_LIFE * (1.0 - contact), delta) if contact < 0.999 else hit_y + _normal[index] = AM.damp_vec3(_normal[index], + (hit["normal"] as Vector3).normalized(), 0.06, delta).normalized() + out.ground_y = _ground_y[index] + out.normal = _normal[index] + + # The ankle rides `sole_offset` above the surface (so the SOLE touches it), + # plus whatever swing clearance the gait asked for. + var target: Vector3 = ideal_world + if _ground_valid[index]: + target.y = out.ground_y + sole_offset + maxf(0.0, lift) + + # --- Plant lock --------------------------------------------------------- + # Keyed on FLATNESS, not contact. During the heel and toe pivots the ankle + # is meant to travel through the world, so locking it there would fight the + # gait and stiffen the roll into a stilt-walk. Only the flat-foot middle of + # the stance is genuinely pinned. + if flat > 0.02: + if not _locked[index]: + _locked[index] = true + _lock_position[index] = target + else: + # Leash: a foot dragged too far gives up and re-plants, which is the + # scuff a person makes pivoting on the spot. + var drift: Vector3 = target - _lock_position[index] + drift.y = 0.0 + if drift.length() > MAX_LEASH: + _lock_position[index] += drift.normalized() \ + * (drift.length() - MAX_LEASH) + # Vertical always tracks the surface, so a foot planted on a moving + # or deforming surface does not sink into it. + _lock_position[index].y = target.y + out.world_position = target.lerp(_lock_position[index], flat) + out.locked = flat > 0.5 + else: + _locked[index] = false + out.world_position = target + out.locked = false + + return out + + +## Ankle alignment for a planted foot, as a model-space rotation to compose +## onto the gait's ankle pose. +## +## `up_model` is the creature's own up axis expressed in model space (normally +## +Y) and `normal_model` is the surface normal brought into the same space. +## The rotation is scaled by contact so a foot only conforms while it is +## actually on the ground, and capped at `MAX_ALIGN_ANGLE` so a foot near a +## cliff face does not stand vertically. +static func align_to_surface(up_model: Vector3, normal_model: Vector3, + contact: float) -> Quaternion: + var from: Vector3 = up_model.normalized() + var to: Vector3 = normal_model.normalized() + var dot: float = clampf(from.dot(to), -1.0, 1.0) + var angle: float = acos(dot) + if angle < 0.0005 or contact <= 0.001: + return Quaternion.IDENTITY + angle = minf(angle, MAX_ALIGN_ANGLE) * clampf(contact, 0.0, 1.0) + var axis: Vector3 = from.cross(to) + if axis.length_squared() < 0.000001: + return Quaternion.IDENTITY + return Quaternion(axis.normalized(), angle) + + +## How far the pelvis must drop so BOTH feet stay inside their legs' reach. +## +## `hip_world_y` is where the hip joint currently sits, `feet` are the resolved +## foot states, and `leg_reach` is the usable hip-to-ankle length (already +## shortened so the knee never locks straight). The result is smoothed and +## clamped to `max_drop`, and is never positive — a creature lifts its body by +## straightening its legs, not by floating upward. +func update_pelvis(hip_world_y: float, feet: Array, leg_reach: float, + max_drop: float, delta: float) -> float: + var needed: float = 0.0 + for entry in feet: + var ground: FootGround = entry + if not ground.found or ground.contact <= 0.01: + continue + # How far below the hip this foot sits, versus how far the leg reaches. + var drop_to_foot: float = hip_world_y - ground.world_position.y + var excess: float = drop_to_foot - leg_reach + if excess > 0.0: + needed = minf(needed, -excess) + needed = maxf(needed, -absf(max_drop)) + pelvis_offset = AM.damp(pelvis_offset, needed, PELVIS_HALF_LIFE, delta) + return pelvis_offset diff --git a/game/src/anim/foot_planter.gd.uid b/game/src/anim/foot_planter.gd.uid new file mode 100644 index 0000000..5fdc291 --- /dev/null +++ b/game/src/anim/foot_planter.gd.uid @@ -0,0 +1 @@ +uid://bhsadhx7volhn diff --git a/game/src/anim/gait_engine.gd b/game/src/anim/gait_engine.gd new file mode 100644 index 0000000..148d5a6 --- /dev/null +++ b/game/src/anim/gait_engine.gd @@ -0,0 +1,346 @@ +class_name GaitEngine +extends RefCounted +## Turns "how far has this creature travelled" into "where are its feet" — +## the distance-phased gait cycle at the centre of Gradientfall's animation. +## +## **The one idea that matters.** The old Kern animation advanced its cycle with +## `phase += delta * rate`: the legs cycled on a TIMER while the body moved at +## whatever speed it happened to be moving. The two are unrelated, so the feet +## skated across the ground — the single loudest tell that a character is not +## really walking. Here the cycle is advanced by DISTANCE TRAVELLED instead: +## +## phase += distance_this_frame / stride_length +## +## Because a planted foot's body-relative position then retreats at exactly the +## speed the body advances, the foot holds still in WORLD space for the whole +## stance. Foot slip goes to zero by construction, at every speed, during +## acceleration, and through gait changes — not as a tuned approximation but as +## an algebraic identity. Everything else in this class is detail on top. +## +## **Cycle convention.** One cycle is TWO steps (left then right), so the right +## foot runs half a cycle behind the left. Within a foot's cycle, `[0, duty)` is +## stance (planted, retreating) and `[duty, 1)` is swing (lifted, reaching). +## +## **Architecture.** Depends on `anim_math.gd` and `gait_profile.gd`; owns no +## nodes and touches no skeleton, so it is testable in isolation and reusable by +## bipeds, quadrupeds (four instances, phase-offset) and the locomotion lab. +## Consumed by `creature_animator.gd`. See `docs/ARCHITECTURE.md`. + +const AM: GDScript = preload("res://src/anim/anim_math.gd") + +## Fraction of the cycle over which contact weight ramps at each stance edge. +## A hard 0/1 switch pops the IK; this rolls the foot on and off the ground. +const CONTACT_BLEND: float = 0.06 + +## Below this stride the phase integrator would divide by ~zero and spin. +const MIN_STRIDE: float = 0.05 + +## Where in the stance the foot is FLAT on the ground, as a fraction of stance. +## Before `FLAT_START` the body is pivoting over the heel; after `FLAT_END` it +## is rolling over the toe. Only the flat window is held perfectly still — the +## pivots are supposed to move the ankle, and forcing them still is what makes +## a walk look like it is on stilts. +const FLAT_START: float = 0.32 +const FLAT_END: float = 0.72 + + +## Per-foot state for one frame. Positions are in the creature's MODEL space +## (character faces -Z), relative to the foot's neutral standing spot. +class FootPhase: + extends RefCounted + + ## This foot's position in the cycle, 0..1. + var cycle: float = 0.0 + ## True while the foot is planted. + var stance: bool = true + ## Progress through the stance, 0..1. Meaningless while swinging. + var stance_t: float = 0.0 + ## Progress through the swing, 0..1. Meaningless while planted. + var swing_t: float = 0.0 + ## Longitudinal offset of the ANKLE from the neutral spot, metres. Positive + ## is FORWARD along travel. + var along: float = 0.0 + ## Longitudinal offset of the foot's ANCHOR — where the ankle would be if + ## the foot were flat. This is the quantity that retreats perfectly linearly + ## during stance and therefore the one the world plant-lock holds; the ankle + ## itself is allowed to move off it as the foot rocks. + var anchor_along: float = 0.0 + ## Clearance above the plant height, metres. Zero through the whole stance. + var lift: float = 0.0 + ## Ankle pitch, radians. Positive is toes-up (heel-strike, swing clearance). + var roll: float = 0.0 + ## How planted the foot is, 0..1, ramped at the stance edges. Drives how + ## strongly foot IK pins this foot to the ground. + var contact: float = 1.0 + ## How FLAT the foot is on the ground, 0..1 — full only through the middle + ## of the stance. The world plant-lock uses this rather than `contact`, + ## because during the heel and toe pivots the ankle is supposed to travel. + var flat: float = 1.0 + + +## Whole-body oscillation for one frame, in metres and radians. +class BodyPhase: + extends RefCounted + + ## Vertical pelvis offset, metres. Positive is up. + var bob: float = 0.0 + ## Lateral pelvis offset, metres. Positive is the creature's right. + var sway: float = 0.0 + ## Transverse pelvic rotation, radians. + var pelvis_yaw: float = 0.0 + ## Frontal-plane pelvic roll, radians. + var pelvis_roll: float = 0.0 + ## Shoulder-girdle counter-rotation, radians — already lagged behind the + ## pelvis by the profile's `spine_lag`. + var chest_yaw: float = 0.0 + ## Shoulder-girdle roll, radians. + var chest_roll: float = 0.0 + ## Residual head pitch, radians. + var head_pitch: float = 0.0 + + +## Position in the cycle, 0..1. Persisted across frames; this is the only +## integrator state the gait has. +var phase: float = 0.0 + +## Cycles per second, derived last frame. Exposed for footstep audio and for +## the locomotion lab's cadence check. +var cadence: float = 0.0 + +## Height of the ankle joint above the sole, metres. Set once from the measured +## rig; the rocker geometry needs it because the foot rotates about a point on +## the GROUND, not about the ankle. +var _ankle_height: float = 0.115 + + +## Tell the engine how high the ankle joint rides above the sole. Called once +## when the creature's `LocomotionProfile` is measured. +func set_ankle_height(height: float) -> void: + _ankle_height = maxf(0.01, height) + +## Stance flags from the previous frame, used to fire `just_planted`. +var _was_stance: Array[bool] = [true, true] +var _planted_this_frame: Array[bool] = [false, false] + + +## Advance the cycle by the ground distance covered this frame. +## +## `signed_distance` is metres travelled ALONG the facing direction — negative +## when backing up, which plays the cycle in reverse so a backpedal reads as a +## backpedal rather than a forward walk sliding backwards. +func advance(signed_distance: float, profile: GaitProfile, delta: float) -> void: + var stride: float = maxf(profile.stride, MIN_STRIDE) + var cycles: float = signed_distance / stride + phase = fposmod(phase + cycles, 1.0) + cadence = cycles / maxf(delta, 0.00001) + for i in 2: + var now_stance: bool = _stance_at(_foot_cycle(i), profile) + _planted_this_frame[i] = now_stance and not _was_stance[i] + _was_stance[i] = now_stance + + +## True on the single frame foot `index` (0 left, 1 right) touched down. +## Footstep audio, dust puffs and grass rustle hang off this. +func just_planted(index: int) -> bool: + return _planted_this_frame[index] + + +## Reset to a clean standing phase — call on spawn, teleport and respawn so a +## creature does not resume mid-stride somewhere else in the world. +func reset() -> void: + phase = 0.0 + cadence = 0.0 + _was_stance = [true, true] + _planted_this_frame = [false, false] + + +## This foot's own position in the cycle. The right foot trails the left by +## half a cycle — that offset IS the alternation. +func _foot_cycle(index: int) -> float: + return fposmod(phase + (0.5 if index == 1 else 0.0), 1.0) + + +func _stance_at(cycle: float, profile: GaitProfile) -> bool: + return cycle < clampf(profile.duty, 0.05, 0.95) + + +## Full state for foot `index` (0 left, 1 right) under `profile`. +func foot_phase(index: int, profile: GaitProfile) -> FootPhase: + var out: FootPhase = FootPhase.new() + var duty: float = clampf(profile.duty, 0.05, 0.95) + var cycle: float = _foot_cycle(index) + out.cycle = cycle + + # Half the ground a single stance covers. The foot travels from +amplitude + # (just landed, out front) to -amplitude (about to leave, trailing behind), + # and the total 2*amplitude equals exactly the distance the body advances + # during that stance — which is why the foot holds still in world space. + var amplitude: float = profile.stride * duty * 0.5 + + if cycle < duty: + out.stance = true + out.stance_t = cycle / duty + # The ANCHOR retreats perfectly linearly: the body advances linearly + # through the stance, so this one line is the no-sliding guarantee. + # Do not ease it. + out.anchor_along = amplitude - 2.0 * amplitude * out.stance_t + out.roll = _stance_roll(out.stance_t, profile.foot_roll) + # The ankle then rides off the anchor exactly as far as rocking the + # rigid foot about its planted end demands. + var rocker: Vector2 = _rocker_offset(out.stance_t, out.roll, profile) + out.along = out.anchor_along + rocker.x + out.lift = rocker.y + out.contact = 1.0 + out.flat = _flat_weight(out.stance_t) + else: + out.stance = false + out.flat = 0.0 + out.swing_t = (cycle - duty) / (1.0 - duty) + # The swing eases: the foot unloads, accelerates past the body, then + # decelerates into the landing. Smootherstep's zero acceleration at both + # ends is what keeps toe-off and heel-strike from twitching. + var eased: float = AM.smootherstep01(out.swing_t) + # A touch of reach past the landing spot, pulled back before contact. + var reach: float = profile.step_overshoot * amplitude \ + * sin(clampf(out.swing_t, 0.0, 1.0) * PI) \ + * AM.smoothstep01(out.swing_t * 2.0) + # Start the swing exactly where the toe-off rocker left the ankle and + # finish exactly where the heel-strike rocker wants it. Interpolating + # between bare anchor positions instead leaves a ~4 cm jump at both ends + # of every swing, which reads as the foot flicking. + var leave: Vector2 = _rocker_offset(1.0, -profile.foot_roll * 0.85, + profile) + var land: Vector2 = _rocker_offset(0.0, profile.foot_roll, profile) + out.along = lerpf(-amplitude + leave.x, amplitude + land.x, eased) \ + + reach + out.lift = lerpf(leave.y, land.y, eased) + profile.step_height \ + * AM.skewed_bell01(out.swing_t, profile.step_peak) + out.roll = _swing_roll(out.swing_t, profile.foot_roll) + out.contact = 0.0 + # In swing there is no planted anchor, so the probe simply follows the + # foot — that is what makes it find the next stair tread rather than + # the ground under the body. + out.anchor_along = out.along + + out.contact = _contact_weight(cycle, duty) + return out + + +## Where the ankle sits relative to the foot's flat anchor, as (forward, up) in +## metres, given how far through the stance we are and the current ankle roll. +## +## This is exact rigid-body geometry, not an approximation. The foot is a rigid +## lever of known length; while the toes are up it is rotating about the heel, +## while the heel is up it is rotating about the toe, and in between it is flat. +## Rotating the ankle about whichever end is planted gives the ankle's +## displacement in closed form — so the planted end does not move by so much as +## a millimetre, at any speed, on any slope, for any roll amplitude. +## +## Getting this from geometry rather than from a tuned ratio is what took foot +## slip from "small on average" to "zero by construction": a fudge factor and +## the ankle-roll curve animated on top of it are two different descriptions of +## the same foot, and they were quietly disagreeing every frame. +func _rocker_offset(t: float, roll: float, profile: GaitProfile) -> Vector2: + if t >= FLAT_START and t <= FLAT_END: + return Vector2.ZERO + var sin_r: float = sin(roll) + var cos_r: float = cos(roll) + if t < FLAT_START: + # Rocking over the heel, which sits `heel_lever` BEHIND the ankle and + # `ankle_rest` below it. Displacement is where the ankle ends up after + # rotating about that fixed point, minus where it sits when flat. + var lever: float = profile.heel_lever + return Vector2( + lever * cos_r - _ankle_height * sin_r - lever, + lever * sin_r + _ankle_height * cos_r - _ankle_height) + # Rocking over the toe, `toe_lever` AHEAD of the ankle. + var toe: float = profile.toe_lever + return Vector2( + -toe * cos_r - _ankle_height * sin_r + toe, + -toe * sin_r + _ankle_height * cos_r - _ankle_height) + + +## 1 through the flat middle of the stance, easing to 0 across both pivots. +func _flat_weight(t: float) -> float: + if t < FLAT_START: + return AM.smoothstep01(t / FLAT_START) + if t > FLAT_END: + return AM.smoothstep01((1.0 - t) / maxf(1.0 - FLAT_END, 0.0001)) + return 1.0 + + +## Ankle pitch through the stance: lands toes-up on the heel, rolls flat under +## bodyweight, then drives toes-down through toe-off. +## +## The flat window here is exactly `FLAT_START..FLAT_END`, the same window the +## plant-lock uses. Keeping them aligned is not cosmetic: if the foot is still +## pitched while the lock says "flat", the lock pins a foot that is physically +## mid-pivot and the ankle drags; if it goes flat early, the foot skates before +## the lock engages. +func _stance_roll(t: float, range_rad: float) -> float: + if t < FLAT_START: + return range_rad * (1.0 - AM.smoothstep01(t / FLAT_START)) + if t <= FLAT_END: + return 0.0 + var push: float = AM.smoothstep01((t - FLAT_END) / maxf(1.0 - FLAT_END, 0.0001)) + return -range_rad * 0.85 * push + + +## Ankle pitch through the swing: stays toes-down out of toe-off, dorsiflexes +## to clear the ground, then presents the heel for the next strike. +func _swing_roll(t: float, range_rad: float) -> float: + var clear: float = AM.bell01(clampf(t / 0.7, 0.0, 1.0)) * range_rad * 0.55 + var present: float = AM.smoothstep01(AM.remap01(t, 0.6, 1.0)) * range_rad + var leaving: float = -range_rad * 0.85 * (1.0 - AM.smoothstep01(t / 0.25)) + return leaving + clear + present + + +## Ramp the contact weight in and out at the stance edges so foot IK engages +## and releases smoothly instead of snapping the leg on the plant frame. +func _contact_weight(cycle: float, duty: float) -> float: + var blend: float = minf(CONTACT_BLEND, duty * 0.45) + if cycle >= duty: + # In swing — but ramp back up as we approach the next heel-strike. + var to_landing: float = 1.0 - cycle + return AM.smoothstep01(1.0 - to_landing / maxf(blend, 0.0001)) \ + if to_landing < blend else 0.0 + if cycle < blend: + return AM.smoothstep01(cycle / blend) + if cycle > duty - blend: + return AM.smoothstep01((duty - cycle) / blend) + return 1.0 + + +## Whole-body oscillation for this frame. +func body_phase(profile: GaitProfile) -> BodyPhase: + var out: BodyPhase = BodyPhase.new() + var turns: float = phase * TAU + + # Bob runs at TWICE the cycle rate — the body rises and falls once per step, + # not once per stride. `pelvis_bob_phase` flips it between the walk's vault + # (highest at midstance) and the run's compression (lowest at midstance). + out.bob = profile.pelvis_bob \ + * cos(2.0 * (turns - profile.pelvis_bob_phase * TAU)) + + # Sway, pelvic yaw and roll all run at the CYCLE rate — one full left-right + # excursion per stride, weight shifting onto each foot in turn. + out.sway = profile.pelvis_sway * sin(turns) + out.pelvis_yaw = profile.pelvis_yaw * sin(turns) + out.pelvis_roll = profile.pelvis_roll * sin(turns) + + # The shoulder girdle counter-rotates AND lags: the spine is elastic, so + # the chest arrives after the pelvis. Matching phases would make the torso + # read as one rigid block, which is the classic amateur-rig look. + var lagged: float = turns - profile.spine_lag * TAU + out.chest_yaw = -profile.chest_counter * sin(lagged) + out.chest_roll = -profile.pelvis_roll * 0.35 * sin(lagged) + out.head_pitch = profile.head_bob * cos(2.0 * turns) + return out + + +## Neutral standing width for foot `index`, as a signed lateral offset in +## metres given the creature's hip half-width. Feet track slightly inboard of +## the hips, which is how humans actually stand and walk. +static func stance_lateral(index: int, hip_half_width: float) -> float: + var side: float = -1.0 if index == 0 else 1.0 + return side * hip_half_width * 0.82 diff --git a/game/src/anim/gait_engine.gd.uid b/game/src/anim/gait_engine.gd.uid new file mode 100644 index 0000000..0c80909 --- /dev/null +++ b/game/src/anim/gait_engine.gd.uid @@ -0,0 +1 @@ +uid://cq4s0wcq4rhqq diff --git a/game/src/anim/gait_profile.gd b/game/src/anim/gait_profile.gd new file mode 100644 index 0000000..61398d4 --- /dev/null +++ b/game/src/anim/gait_profile.gd @@ -0,0 +1,420 @@ +class_name GaitProfile +extends RefCounted +## One named way of moving on two legs — walk, jog, run, sprint, crouch-walk, +## limp — expressed as measurable biomechanics rather than animation curves. +## +## **Why a profile and not a keyframed clip.** Gradientfall generates every +## asset in code (CLAUDE.md § Conventions), so there are no motion-capture +## clips to blend. Instead each gait is a small set of numbers taken from real +## gait analysis — stride length, duty factor, pelvic oscillation — and +## `gait_engine.gd` integrates them into a pose. Two payoffs: gaits blend +## CONTINUOUSLY by speed (no pops at a walk/run threshold, because there is no +## threshold), and a new creature is a new set of numbers, not a new art task. +## +## **Where the numbers come from.** Defaults are human gait-lab figures for a +## ~1.78 m adult: walking cadence near 110 steps/min at 1.4 m/s with a 62% duty +## factor and ~45 mm of pelvic rise; running crossing to a sub-50% duty factor +## (a real flight phase) with the pelvis LOWEST at midstance instead of highest. +## Preserving that inversion is a surprising amount of why running reads as +## running and not as fast walking. +## +## **Architecture.** Pure data + blending; depends only on `anim_math.gd`. +## Authored by `locomotion_profile.gd` per creature and consumed by +## `gait_engine.gd`. See `docs/ARCHITECTURE.md` § "Procedural animation". + +## Human-readable name, for debug overlays and the locomotion lab's reports. +var name: String = "walk" + +# --- Ground contract -------------------------------------------------------- + +## Ground speed this gait is authored at, m/s. Blending between profiles is +## keyed on this. +var speed: float = 1.4 + +## Metres of ground covered by one full cycle (two steps — left and right). +## Together with `speed` this fixes the cadence, so it is the single most +## important number here: `cadence = speed / stride`. +var stride: float = 1.50 + +## Fraction of the cycle each foot spends planted. Above 0.5 both feet are +## sometimes down (a walk's double support); below 0.5 neither sometimes is +## (a run's flight phase). Crossing 0.5 IS the walk/run transition. +var duty: float = 0.62 + +# --- Foot swing ------------------------------------------------------------- + +## Peak clearance of the swinging foot above its plant height, metres. +var step_height: float = 0.075 + +## Where in the swing the clearance peaks, 0..1. Real feet snap up fast at +## toe-off and glide down to heel-strike, so this sits well before the middle. +var step_peak: float = 0.38 + +## Ankle pitch range through the step, radians — toes-up at heel-strike, +## toes-down at toe-off. Without it feet land flat and read as stilts. +var foot_roll: float = 0.32 + +## How far the swinging foot reaches PAST its landing spot before settling +## back, as a fraction of the step. A small overshoot reads as a real reach. +var step_overshoot: float = 0.06 + +## Distance from the ankle joint back to the heel and forward to the toe, in +## metres — the two levers the body rocks over during a stance. +## +## These are real anatomy, not tuning knobs, and `gait_engine.gd` derives the +## ankle's whole stance trajectory from them by treating the foot as a rigid +## body rotating about whichever end is currently touching the ground. That is +## what makes the contact patch EXACTLY stationary through heel-strike, flat +## foot and toe-off alike: the ankle rises and advances by precisely the amount +## the geometry demands, rather than by a fudge factor that only approximately +## agrees with the ankle roll being animated on top. +var heel_lever: float = 0.060 +var toe_lever: float = 0.160 + +# --- Pelvis ----------------------------------------------------------------- + +## Vertical pelvis oscillation, metres (peak to centre). Runs at twice the +## cycle frequency — one rise per step. +var pelvis_bob: float = 0.022 + +## Phase offset of the bob, in cycles. 0.0 puts the pelvis HIGHEST at midstance +## (a walk vaulting over a straight stance leg); 0.5 puts it LOWEST there (a +## run compressing into the stance leg). This is the walk/run inversion. +var pelvis_bob_phase: float = 0.0 + +## Lateral pelvis shift toward the stance foot, metres. Once per cycle. +var pelvis_sway: float = 0.028 + +## Constant lowering of the whole body, metres — how crouched this gait is. +var pelvis_drop: float = 0.0 + +## Transverse pelvic rotation (the hip leading the swing leg), radians. +var pelvis_yaw: float = 0.09 + +## Frontal-plane pelvic drop toward the SWING side, radians. The Trendelenburg +## dip; small, but its absence is why stiff rigs look like they are on rails. +var pelvis_roll: float = 0.045 + +# --- Spine and torso -------------------------------------------------------- + +## Constant forward lean of the torso, radians. Grows with speed — sprinting +## upright is one of the loudest tells of a rig that has never been tuned. +var torso_lean: float = 0.03 + +## Shoulder-girdle counter-rotation against the pelvis, radians. +var chest_counter: float = 0.10 + +## How far the chest LAGS the pelvis, in cycles. Real spines transmit rotation +## with a delay; matching them exactly makes the torso read as one rigid block. +var spine_lag: float = 0.09 + +## Residual head pitch left after the neck stabilises the gaze, radians. Real +## heads are not perfectly stabilised, and perfect stabilisation looks uncanny. +var head_bob: float = 0.012 + +# --- Arms ------------------------------------------------------------------- + +## Shoulder flexion amplitude, radians — the arm swing. +var arm_swing: float = 0.42 + +## Constant shoulder abduction, radians. At speed the arms ride out from the +## body so they clear the torso. +var arm_lift: float = 0.05 + +## Baseline elbow flexion, radians. Walkers hang near-straight; runners hold +## close to a right angle and keep it there. +var elbow_bend: float = 0.22 + +## Extra elbow flexion added on the forward half of the swing, radians. +var elbow_swing: float = 0.30 + +# --- Legs ------------------------------------------------------------------- + +## Knee flexion at midstance, radians — the loading response that absorbs +## bodyweight. Straight-legged stance is the "marching toy soldier" look. +var stance_knee: float = 0.09 + +## How far in front of the knee the IK pole sits, metres. Larger values push +## the knees further forward and stop them from wandering toward each other. +var knee_pole_ahead: float = 0.85 + + +## Deep copy — profiles get blended into scratch instances every frame, and +## sharing a preset by reference would let one frame's blend corrupt the preset. +func duplicate_profile() -> GaitProfile: + var copy: GaitProfile = GaitProfile.new() + copy.copy_from(self) + return copy + + +## Overwrite every field from `other`. +func copy_from(other: GaitProfile) -> void: + name = other.name + speed = other.speed + stride = other.stride + duty = other.duty + step_height = other.step_height + step_peak = other.step_peak + foot_roll = other.foot_roll + step_overshoot = other.step_overshoot + heel_lever = other.heel_lever + toe_lever = other.toe_lever + pelvis_bob = other.pelvis_bob + pelvis_bob_phase = other.pelvis_bob_phase + pelvis_sway = other.pelvis_sway + pelvis_drop = other.pelvis_drop + pelvis_yaw = other.pelvis_yaw + pelvis_roll = other.pelvis_roll + torso_lean = other.torso_lean + chest_counter = other.chest_counter + spine_lag = other.spine_lag + head_bob = other.head_bob + arm_swing = other.arm_swing + arm_lift = other.arm_lift + elbow_bend = other.elbow_bend + elbow_swing = other.elbow_swing + stance_knee = other.stance_knee + knee_pole_ahead = other.knee_pole_ahead + + +## Linear blend of every field, written into `out` to avoid a per-frame +## allocation. `t` of 0 gives `a`, 1 gives `b`. +## +## Every field blends linearly INCLUDING `duty`, which is what makes the +## walk-to-run transition continuous: duty slides through 0.5 rather than +## jumping, so double-support shortens to nothing and the flight phase opens up +## over a few tenths of a second the way a real gait transition does. +static func blend_into(out: GaitProfile, a: GaitProfile, b: GaitProfile, + t: float) -> void: + var k: float = clampf(t, 0.0, 1.0) + out.name = a.name if k < 0.5 else b.name + out.speed = lerpf(a.speed, b.speed, k) + out.stride = lerpf(a.stride, b.stride, k) + out.duty = lerpf(a.duty, b.duty, k) + out.step_height = lerpf(a.step_height, b.step_height, k) + out.step_peak = lerpf(a.step_peak, b.step_peak, k) + out.foot_roll = lerpf(a.foot_roll, b.foot_roll, k) + out.step_overshoot = lerpf(a.step_overshoot, b.step_overshoot, k) + out.heel_lever = lerpf(a.heel_lever, b.heel_lever, k) + out.toe_lever = lerpf(a.toe_lever, b.toe_lever, k) + out.pelvis_bob = lerpf(a.pelvis_bob, b.pelvis_bob, k) + out.pelvis_bob_phase = lerpf(a.pelvis_bob_phase, b.pelvis_bob_phase, k) + out.pelvis_sway = lerpf(a.pelvis_sway, b.pelvis_sway, k) + out.pelvis_drop = lerpf(a.pelvis_drop, b.pelvis_drop, k) + out.pelvis_yaw = lerpf(a.pelvis_yaw, b.pelvis_yaw, k) + out.pelvis_roll = lerpf(a.pelvis_roll, b.pelvis_roll, k) + out.torso_lean = lerpf(a.torso_lean, b.torso_lean, k) + out.chest_counter = lerpf(a.chest_counter, b.chest_counter, k) + out.spine_lag = lerpf(a.spine_lag, b.spine_lag, k) + out.head_bob = lerpf(a.head_bob, b.head_bob, k) + out.arm_swing = lerpf(a.arm_swing, b.arm_swing, k) + out.arm_lift = lerpf(a.arm_lift, b.arm_lift, k) + out.elbow_bend = lerpf(a.elbow_bend, b.elbow_bend, k) + out.elbow_swing = lerpf(a.elbow_swing, b.elbow_swing, k) + out.stance_knee = lerpf(a.stance_knee, b.stance_knee, k) + out.knee_pole_ahead = lerpf(a.knee_pole_ahead, b.knee_pole_ahead, k) + + +# --- Human presets ---------------------------------------------------------- +# Authored for a 1.78 m adult. `locomotion_profile.gd` rescales these for +# creatures of other sizes rather than duplicating the table. + +## Standing-still reference. Stride and duty still matter: they are what the +## blend interpolates toward as a character slows to a stop, so a bad idle +## profile shows up as a stutter in the last half-step before standing. +static func human_idle() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.name = "idle" + p.speed = 0.0 + p.stride = 0.90 + p.duty = 0.75 + p.step_height = 0.018 + p.step_peak = 0.42 + p.foot_roll = 0.10 + p.step_overshoot = 0.0 + p.pelvis_bob = 0.004 + p.pelvis_bob_phase = 0.0 + p.pelvis_sway = 0.012 + p.pelvis_drop = 0.0 + p.pelvis_yaw = 0.02 + p.pelvis_roll = 0.012 + p.torso_lean = 0.0 + p.chest_counter = 0.02 + p.spine_lag = 0.10 + p.head_bob = 0.004 + p.arm_swing = 0.06 + p.arm_lift = 0.0 + p.elbow_bend = 0.16 + p.elbow_swing = 0.05 + p.stance_knee = 0.05 + p.knee_pole_ahead = 0.85 + return p + + +## Unhurried walk. 1.4 m/s at a 1.5 m stride is ~112 steps/min — the textbook +## comfortable human cadence. +static func human_walk() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.name = "walk" + p.speed = 1.40 + p.stride = 1.50 + p.duty = 0.62 + p.step_height = 0.075 + p.step_peak = 0.38 + p.foot_roll = 0.32 + p.step_overshoot = 0.06 + p.pelvis_bob = 0.022 + p.pelvis_bob_phase = 0.0 # highest at midstance: vaulting + p.pelvis_sway = 0.030 + p.pelvis_drop = 0.0 + p.pelvis_yaw = 0.09 + p.pelvis_roll = 0.045 + p.torso_lean = 0.025 + p.chest_counter = 0.10 + p.spine_lag = 0.09 + p.head_bob = 0.012 + p.arm_swing = 0.42 + p.arm_lift = 0.03 + p.elbow_bend = 0.22 + p.elbow_swing = 0.30 + p.stance_knee = 0.09 + p.knee_pole_ahead = 0.85 + return p + + +## Brisk jog — the first gait with a genuine flight phase (duty below 0.5). +static func human_jog() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.name = "jog" + p.speed = 3.20 + p.stride = 2.30 + p.duty = 0.44 + p.step_height = 0.155 + p.step_peak = 0.34 + p.foot_roll = 0.40 + p.step_overshoot = 0.05 + p.pelvis_bob = 0.042 + p.pelvis_bob_phase = 0.5 # lowest at midstance: spring compression + p.pelvis_sway = 0.022 + p.pelvis_drop = 0.015 + p.pelvis_yaw = 0.12 + p.pelvis_roll = 0.055 + p.torso_lean = 0.085 + p.chest_counter = 0.16 + p.spine_lag = 0.07 + p.head_bob = 0.022 + p.arm_swing = 0.70 + p.arm_lift = 0.10 + p.elbow_bend = 0.95 + p.elbow_swing = 0.34 + p.stance_knee = 0.22 + p.knee_pole_ahead = 0.95 + return p + + +## Committed run. +static func human_run() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.name = "run" + p.speed = 5.60 + p.stride = 3.20 + p.duty = 0.36 + p.step_height = 0.235 + p.step_peak = 0.31 + p.foot_roll = 0.46 + p.step_overshoot = 0.04 + p.pelvis_bob = 0.055 + p.pelvis_bob_phase = 0.5 + p.pelvis_sway = 0.016 + p.pelvis_drop = 0.030 + p.pelvis_yaw = 0.15 + p.pelvis_roll = 0.060 + p.torso_lean = 0.145 + p.chest_counter = 0.20 + p.spine_lag = 0.06 + p.head_bob = 0.028 + p.arm_swing = 0.92 + p.arm_lift = 0.16 + p.elbow_bend = 1.28 + p.elbow_swing = 0.30 + p.stance_knee = 0.30 + p.knee_pole_ahead = 1.05 + return p + + +## Flat-out sprint — knees high, torso well forward, arms driving. +static func human_sprint() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.name = "sprint" + p.speed = 7.50 + p.stride = 3.95 + p.duty = 0.29 + p.step_height = 0.320 + p.step_peak = 0.29 + p.foot_roll = 0.50 + p.step_overshoot = 0.03 + p.pelvis_bob = 0.062 + p.pelvis_bob_phase = 0.5 + p.pelvis_sway = 0.012 + p.pelvis_drop = 0.042 + p.pelvis_yaw = 0.18 + p.pelvis_roll = 0.062 + p.torso_lean = 0.200 + p.chest_counter = 0.24 + p.spine_lag = 0.05 + p.head_bob = 0.032 + p.arm_swing = 1.15 + p.arm_lift = 0.21 + p.elbow_bend = 1.45 + p.elbow_swing = 0.26 + p.stance_knee = 0.36 + p.knee_pole_ahead = 1.15 + return p + + +## Crouched movement — short shuffling stride, body low, arms tucked and quiet. +static func human_crouch_walk() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.name = "crouch_walk" + p.speed = 1.70 + p.stride = 1.05 + p.duty = 0.68 + p.step_height = 0.050 + p.step_peak = 0.40 + p.foot_roll = 0.18 + p.step_overshoot = 0.03 + p.pelvis_bob = 0.012 + p.pelvis_bob_phase = 0.0 + p.pelvis_sway = 0.024 + p.pelvis_drop = 0.340 + p.pelvis_yaw = 0.06 + p.pelvis_roll = 0.030 + p.torso_lean = 0.230 + p.chest_counter = 0.06 + p.spine_lag = 0.10 + p.head_bob = 0.008 + p.arm_swing = 0.20 + p.arm_lift = 0.06 + p.elbow_bend = 0.85 + p.elbow_swing = 0.12 + p.stance_knee = 0.70 + p.knee_pole_ahead = 1.05 + return p + + +## Crouched and still. +static func human_crouch_idle() -> GaitProfile: + var p: GaitProfile = GaitProfile.new() + p.copy_from(human_crouch_walk()) + p.name = "crouch_idle" + p.speed = 0.0 + p.stride = 0.80 + p.duty = 0.80 + p.step_height = 0.014 + p.pelvis_bob = 0.003 + p.pelvis_sway = 0.008 + p.pelvis_yaw = 0.015 + p.pelvis_roll = 0.010 + p.arm_swing = 0.04 + p.elbow_swing = 0.03 + return p diff --git a/game/src/anim/gait_profile.gd.uid b/game/src/anim/gait_profile.gd.uid new file mode 100644 index 0000000..9b0e056 --- /dev/null +++ b/game/src/anim/gait_profile.gd.uid @@ -0,0 +1 @@ +uid://1rimwyt71ulc diff --git a/game/src/anim/locomotion_profile.gd b/game/src/anim/locomotion_profile.gd new file mode 100644 index 0000000..a27e251 --- /dev/null +++ b/game/src/anim/locomotion_profile.gd @@ -0,0 +1,232 @@ +class_name LocomotionProfile +extends RefCounted +## Everything the animation system needs to know about one creature's BODY: +## measured limb lengths, joint rest positions, and the set of gaits it can use. +## +## **Measured, not typed in.** Almost every field is read off the creature's +## actual `Skeleton3D` rest pose at bind time rather than hard-coded. Kern's +## proportions have already been re-tuned several times across the project's +## life, and every hand-copied "thigh is 0.417 m" constant is a bug waiting for +## the next re-proportion. Measuring also means a wolf, a villager and a boss +## all configure themselves from their own rig with no new code. +## +## **Scaling the gait set.** Stride length and step height are governed by leg +## length in real animals (dynamic similarity — the Froude number). So the human +## presets in `gait_profile.gd` are authored for Kern's 0.81 m leg and then +## scaled by the measured ratio, which gives a short villager a believably +## shorter, quicker stride for free instead of making them moonwalk. +## +## **Architecture.** Depends on `gait_profile.gd`. Built once per creature by +## `creature_animator.gd`. See `docs/ARCHITECTURE.md` § "Procedural animation". + +## Leg length the human gait presets were authored against, metres: the +## hip-to-ankle distance of the real 1.78 m adult whose gait-lab figures the +## presets came from. +## +## This is deliberately NOT Kern's own leg length. Kern's rig puts his ankle +## joint about 4 cm higher than a real one, so his usable leg is ~7% shorter +## than the anatomy the stride numbers assume — and a stride authored for a +## longer leg is exactly what forces an IK solver to over-reach. Anchoring the +## presets to real anatomy makes `rescale_gaits()` shorten his stride to suit, +## and does the same automatically for any creature built to any size. +const REFERENCE_LEG: float = 0.870 + +## Fraction of full leg extension the IK is allowed to use. Keeps the knee off +## its singular straight-locked position and matches real legs, which never +## fully extend under load. +const USABLE_REACH: float = 0.985 + +## Deepest the hip may sit below its rest height while simply walking, as a +## fraction of leg length. Sets the ceiling on stride length via +## `max_reachable_stride()`. +const MAX_STANCE_CROUCH: float = 0.085 + + +## Measured hip (thigh joint) rest position per side, model space. Index 0 is +## left, 1 is right — the same indexing the gait and planter use throughout. +var hip_rest: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO] + +## Measured thigh and shin lengths, metres. +var thigh_length: float = 0.417 +var shin_length: float = 0.393 + +## Rest direction of the thigh and shin bones in model space — handed to the IK +## so it works on rigs whose limbs do not hang exactly straight down. +var thigh_rest_dir: Vector3 = Vector3.DOWN +var shin_rest_dir: Vector3 = Vector3.DOWN + +## Rest height of the ankle joint above the creature's origin, metres. Foot +## targets are authored relative to this. +var ankle_rest_y: float = 0.115 + +## Half the distance between the hips, metres. Sets the stance width. +var hip_half_width: float = 0.095 + +## Rest height of the pelvis above the origin, metres. +var pelvis_rest_y: float = 0.98 + +## Full rest position of the pelvis bone, model space. +## +## The leg IK rotates the hip joints about this exact point, and `Skeleton3D` +## composes the real bone chain about it too. Approximating it as +## `(0, pelvis_rest_y, 0)` looks harmless but drops the rig's 5 mm forward +## offset, so the solver and the skeleton disagreed about where the thigh joint +## ended up and every planted foot missed its target by that much — which shows +## up as slip, because the miss changes as the pelvis rotates. +var pelvis_rest: Vector3 = Vector3(0.0, 0.98, 0.0) + +## Overall height, metres — used for scale-relative tuning. +var height: float = 1.78 + +## Gait set, blended by speed. +var idle: GaitProfile = GaitProfile.human_idle() +var walk: GaitProfile = GaitProfile.human_walk() +var jog: GaitProfile = GaitProfile.human_jog() +var run: GaitProfile = GaitProfile.human_run() +var sprint: GaitProfile = GaitProfile.human_sprint() +var crouch_idle: GaitProfile = GaitProfile.human_crouch_idle() +var crouch_walk: GaitProfile = GaitProfile.human_crouch_walk() + +## Scratch profile reused by `blend_for_speed` so the per-frame path allocates +## nothing. Never read it directly — it holds an intermediate blend. +var _crouch_scratch: GaitProfile = GaitProfile.new() + + +## Usable hip-to-ankle reach, metres — the IK target distance ceiling and the +## number `foot_planter.gd` uses to decide when the pelvis must drop. +func leg_reach() -> float: + return (thigh_length + shin_length) * USABLE_REACH + + +## Full leg length, metres. +func leg_length() -> float: + return thigh_length + shin_length + + +## Build a profile by measuring a real skeleton. +## +## `bones` maps the animation's bone names to indices (the dictionary +## `kern_body_builder.gd` returns). Bones that are missing leave their defaults +## in place, so a partial rig degrades instead of erroring. +static func from_skeleton(skeleton: Skeleton3D, bones: Dictionary, + total_height: float) -> LocomotionProfile: + var p: LocomotionProfile = LocomotionProfile.new() + p.height = total_height + + var thigh_l: Vector3 = _rest_origin(skeleton, bones, "ThighL") + var thigh_r: Vector3 = _rest_origin(skeleton, bones, "ThighR") + var shin_l: Vector3 = _rest_origin(skeleton, bones, "ShinL") + var foot_l: Vector3 = _rest_origin(skeleton, bones, "FootL") + var hips: Vector3 = _rest_origin(skeleton, bones, "Hips") + + if thigh_l != Vector3.INF and thigh_r != Vector3.INF: + p.hip_rest = [thigh_l, thigh_r] + p.hip_half_width = absf(thigh_r.x - thigh_l.x) * 0.5 + if thigh_l != Vector3.INF and shin_l != Vector3.INF: + p.thigh_length = thigh_l.distance_to(shin_l) + p.thigh_rest_dir = (shin_l - thigh_l).normalized() + if shin_l != Vector3.INF and foot_l != Vector3.INF: + p.shin_length = shin_l.distance_to(foot_l) + p.shin_rest_dir = (foot_l - shin_l).normalized() + if foot_l != Vector3.INF: + p.ankle_rest_y = foot_l.y + if hips != Vector3.INF: + p.pelvis_rest_y = hips.y + p.pelvis_rest = hips + + p.rescale_gaits() + return p + + +## Rescale every gait in the set from the reference human leg onto this +## creature's measured leg. +## +## Stride and step height scale linearly with leg length; SPEED scales with its +## square root, because gravity-driven gaits obey dynamic similarity (a pendulum +## twice as long swings only sqrt(2) times slower). Duty factors, lean angles +## and joint amplitudes are dimensionless and are left alone. +func rescale_gaits() -> void: + var ratio: float = leg_length() / REFERENCE_LEG + # No early-out when the ratio is 1: the stride cap below must run for every + # creature, including one that happens to match the reference exactly. + var speed_ratio: float = sqrt(ratio) + for entry in [idle, walk, jog, run, sprint, crouch_idle, crouch_walk]: + var g: GaitProfile = entry + g.speed *= speed_ratio + g.stride *= ratio + g.step_height *= ratio + g.pelvis_bob *= ratio + g.pelvis_sway *= ratio + g.pelvis_drop *= ratio + g.knee_pole_ahead *= ratio + g.stride = minf(g.stride, max_reachable_stride(g)) + + +## The longest stride this creature can take without crouching more than +## `MAX_STANCE_CROUCH` of its leg length. +## +## A stride is not a free parameter: reaching further forward with a fixed-length +## leg can only be paid for by lowering the hip, and past a few percent that +## stops reading as "striding out" and starts reading as "sneaking". Rather than +## let a too-long stride quietly sink the character (which is exactly what a +## first pass here did — Kern walked in a 16 cm squat), the stride is clamped to +## what the leg can actually do and the cadence rises to keep the speed. +func max_reachable_stride(gait: GaitProfile) -> float: + var rest_height: float = hip_rest[0].y - ankle_rest_y + # Faster gaits are allowed to sit lower, because runners genuinely do: the + # profile's own `pelvis_drop` is added to the standing allowance rather than + # holding every gait to a walk's posture. Budgeting the pelvis BOB in here + # as well was tried and is wrong — it collapsed the run stride to 0.86 m, + # which at 5.6 m/s is 780 steps a minute, i.e. a scurry. + var usable: float = rest_height \ + - (leg_length() * MAX_STANCE_CROUCH + gait.pelvis_drop) + var reach: float = leg_reach() + if usable >= reach: + return gait.stride + var reach_out: float = sqrt(reach * reach - usable * usable) + var duty: float = clampf(gait.duty, 0.05, 0.95) + return (reach_out + gait.heel_lever) * 2.0 / duty + + +## Choose and blend the gait for a given ground speed, writing the result into +## `out` so the per-frame path allocates nothing. +## +## `crouch` (0..1) cross-fades the whole result toward the crouched gaits, so a +## character can be half-crouched at a jog and still look coherent. +func blend_for_speed(out: GaitProfile, speed: float, crouch: float) -> void: + _blend_upright(out, speed) + var c: float = clampf(crouch, 0.0, 1.0) + if c <= 0.001: + return + var crouch_t: float = clampf(speed / maxf(crouch_walk.speed, 0.01), 0.0, 1.0) + GaitProfile.blend_into(_crouch_scratch, crouch_idle, crouch_walk, crouch_t) + GaitProfile.blend_into(out, out, _crouch_scratch, c) + + +## Blend across the upright gait ladder by speed. +func _blend_upright(out: GaitProfile, speed: float) -> void: + if speed <= idle.speed: + out.copy_from(idle) + elif speed < walk.speed: + GaitProfile.blend_into(out, idle, walk, + (speed - idle.speed) / maxf(walk.speed - idle.speed, 0.01)) + elif speed < jog.speed: + GaitProfile.blend_into(out, walk, jog, + (speed - walk.speed) / maxf(jog.speed - walk.speed, 0.01)) + elif speed < run.speed: + GaitProfile.blend_into(out, jog, run, + (speed - jog.speed) / maxf(run.speed - jog.speed, 0.01)) + elif speed < sprint.speed: + GaitProfile.blend_into(out, run, sprint, + (speed - run.speed) / maxf(sprint.speed - run.speed, 0.01)) + else: + out.copy_from(sprint) + + +## A bone's global rest origin, or `Vector3.INF` when the rig lacks that bone. +static func _rest_origin(skeleton: Skeleton3D, bones: Dictionary, + bone_name: String) -> Vector3: + var idx: int = bones.get(bone_name, -1) + if idx < 0: + return Vector3.INF + return skeleton.get_bone_global_rest(idx).origin diff --git a/game/src/anim/locomotion_profile.gd.uid b/game/src/anim/locomotion_profile.gd.uid new file mode 100644 index 0000000..acdbe26 --- /dev/null +++ b/game/src/anim/locomotion_profile.gd.uid @@ -0,0 +1 @@ +uid://dyipsgn26m0te diff --git a/game/src/anim/pose_stack.gd b/game/src/anim/pose_stack.gd new file mode 100644 index 0000000..68cc725 --- /dev/null +++ b/game/src/anim/pose_stack.gd @@ -0,0 +1,137 @@ +class_name PoseStack +extends RefCounted +## The pose a creature is in this frame, as layered bone rotations plus a root +## offset — the buffer every animation layer writes into before anything +## touches a `Skeleton3D`. +## +## **Why quaternions and not the euler `Dictionary` the old rig used.** Euler +## triples are fine for a small oscillation, which is all the placeholder gait +## ever did. They fail the moment layers have to COMBINE: adding two euler +## vectors is not composing two rotations, so an emote that raises an arm over +## a gait that is swinging it produced a limb that skewed and gimballed instead +## of blending. Quaternions compose correctly, slerp along the short arc, and +## make "blend this whole pose 40% toward that one" a one-liner — which is +## exactly what emote blend-in/blend-out needs. +## +## **Rotation convention.** Every value is a bone's rotation RELATIVE TO ITS +## REST, in the bone's parent frame — precisely what +## `Skeleton3D.set_bone_pose_rotation()` consumes. Model-space results out of +## the IK solver must be converted with `TwoBoneIk.to_local()` before landing +## here. +## +## **Architecture.** Leaf container; depends only on `anim_math.gd`. Written by +## `gait_engine.gd` consumers, `emote_player.gd` and the combat overlay; read by +## `creature_animator.gd` when it commits to the skeleton. + +## bone name -> Quaternion, relative to rest. +var rotations: Dictionary = {} + +## Model-space translation applied to the whole body — the pelvis drop from +## terrain adaptation, crouch, landing absorption and emote hops all land here +## rather than moving the physics body. +var root_offset: Vector3 = Vector3.ZERO + +## Extra yaw applied to the whole visual, radians. Emotes that spin use it so +## the character can turn without fighting the controller's facing logic. +var root_spin: float = 0.0 + + +## Empty the stack for a fresh frame. Reuses the backing Dictionary so a +## per-frame pose costs no allocation after the first few frames. +func clear() -> void: + rotations.clear() + root_offset = Vector3.ZERO + root_spin = 0.0 + + +## Read a bone's rotation, identity if nothing has written it yet. +func get_rot(bone: String) -> Quaternion: + return rotations.get(bone, Quaternion.IDENTITY) + + +## Overwrite a bone's rotation. +func set_rot(bone: String, rotation: Quaternion) -> void: + rotations[bone] = rotation + + +## Overwrite a bone's rotation from an euler triple (radians, YXZ as Godot +## orders it). Convenience for hand-authored poses, which are far easier to +## read as "0.3 rad of pitch" than as four quaternion components. +func set_euler(bone: String, euler: Vector3) -> void: + rotations[bone] = Quaternion.from_euler(euler) + + +## Compose `rotation` ON TOP of whatever is already there, in the bone's own +## local frame. This is the additive layer operation: the result is "do what +## you were doing, then this as well". +func add_rot(bone: String, rotation: Quaternion) -> void: + rotations[bone] = get_rot(bone) * rotation + + +## Additive layer from an euler triple. +func add_euler(bone: String, euler: Vector3) -> void: + add_rot(bone, Quaternion.from_euler(euler)) + + +## Blend a bone toward `rotation` by `weight` (0 keeps the current pose, 1 +## replaces it). Slerp along the short arc, so a limb never swings through the +## body to reach its target. +func blend_rot(bone: String, rotation: Quaternion, weight: float) -> void: + var w: float = clampf(weight, 0.0, 1.0) + if w <= 0.0: + return + if w >= 1.0: + rotations[bone] = rotation + return + var current: Quaternion = get_rot(bone) + rotations[bone] = current.slerp(_short(current, rotation), w) + + +## Blend a bone toward an euler triple by `weight`. +func blend_euler(bone: String, euler: Vector3, weight: float) -> void: + blend_rot(bone, Quaternion.from_euler(euler), weight) + + +## Blend this pose toward `other` by `weight`, including the root offset and +## spin. +## +## Only bones `other` actually writes are affected — a PARTIAL blend. That is +## what makes the emote layer composable: `wave` poses one arm and the head, so +## Kern keeps walking on gait-driven legs while he waves, whereas a full-body +## dance writes every bone and therefore takes the whole body over. Blending +## unwritten bones toward rest instead would straighten the legs under every +## upper-body gesture. +func blend_toward(other: PoseStack, weight: float) -> void: + var w: float = clampf(weight, 0.0, 1.0) + if w <= 0.0: + return + for bone in other.rotations: + blend_rot(String(bone), other.rotations[bone] as Quaternion, w) + root_offset = root_offset.lerp(other.root_offset, w) + root_spin = lerpf(root_spin, other.root_spin, w) + + +## Copy every value from `other`, replacing this pose entirely. +func copy_from(other: PoseStack) -> void: + rotations.clear() + for bone in other.rotations: + rotations[bone] = other.rotations[bone] + root_offset = other.root_offset + root_spin = other.root_spin + + +## Flip `to` into `from`'s hemisphere so the slerp takes the short arc. +static func _short(from: Quaternion, to: Quaternion) -> Quaternion: + if from.dot(to) < 0.0: + return -to + return to + + +## Mirror an euler triple from one side of the body to the other. +## +## The rig is built symmetric about the model's X axis, so a left-side pose +## becomes its right-side twin by negating the yaw and roll and keeping the +## pitch. Emotes author one arm and get the other for free — and, more +## importantly, cannot drift out of symmetry through a copy-paste typo. +static func mirror_euler(euler: Vector3) -> Vector3: + return Vector3(euler.x, -euler.y, -euler.z) diff --git a/game/src/anim/pose_stack.gd.uid b/game/src/anim/pose_stack.gd.uid new file mode 100644 index 0000000..3848196 --- /dev/null +++ b/game/src/anim/pose_stack.gd.uid @@ -0,0 +1 @@ +uid://dov561u0u3t3g diff --git a/game/src/anim/two_bone_ik.gd b/game/src/anim/two_bone_ik.gd new file mode 100644 index 0000000..057437e --- /dev/null +++ b/game/src/anim/two_bone_ik.gd @@ -0,0 +1,182 @@ +class_name TwoBoneIk +extends RefCounted +## Closed-form two-bone inverse kinematics for limbs (hip-knee-ankle, +## shoulder-elbow-wrist) on any creature in the game. +## +## **Why analytic and not `SkeletonIK3D`.** Godot's node-based IK is an +## iterative FABRIK solver that owns the bones it touches, runs on its own +## schedule, and can't be blended per-frame against a procedural pose. A limb +## with exactly two segments has an EXACT solution from the law of cosines — one +## square root, no iteration, no convergence error, and the result is just a +## pair of rotations we can weight against the gait pose like any other layer. +## That is the whole reason feet can be planted on terrain without the rest of +## the animation losing control of the leg. +## +## **Frames.** The solver works entirely in the creature's MODEL space (the +## space the rig is authored in: character faces -Z, up is +Y). Callers hand in +## model-space positions and get back model-space rotations, plus a helper to +## convert those into the parent-relative rotations `Skeleton3D` actually wants. +## +## **Architecture.** Leaf module; depends only on `anim_math.gd`. Consumed by +## `foot_planter.gd` (legs on ground) and `creature_animator.gd` (arms reaching). +## See `docs/ARCHITECTURE.md` § "Procedural animation". + +## Never let the chain fully lock out — a perfectly straight limb has a +## singular bend plane, so the knee direction becomes undefined and pops. Real +## legs never hyperextend either, so holding back a hair is also correct. +const MAX_EXTENSION: float = 0.995 + +## Never let the chain fold past this fraction of its folded limit either, +## which keeps the cosine arguments inside the valid domain. +const MIN_EXTENSION: float = 1.02 + + +## The result of a solve. All rotations are MODEL-space. +class Solution: + extends RefCounted + + ## Model-space rotation for the upper bone (thigh / upper arm). + var upper_rotation: Quaternion = Quaternion.IDENTITY + ## Model-space rotation for the lower bone (shin / forearm). + var lower_rotation: Quaternion = Quaternion.IDENTITY + ## Where the middle joint (knee / elbow) ended up, in model space. Useful + ## for debug draws and for pushing a knee out of terrain. + var joint_position: Vector3 = Vector3.ZERO + ## Where the chain tip actually landed. Differs from the requested target + ## when the target was out of reach and got clamped — callers use this to + ## detect over-reach and lean the body instead of stretching the limb. + var tip_position: Vector3 = Vector3.ZERO + ## True when the requested target was beyond the limb's reach and had to be + ## pulled in. The hip/pelvis solver responds by lowering the body. + var clamped: bool = false + + +## Solve a two-bone chain. +## +## `root` is the model-space position of the upper bone's joint (hip/shoulder). +## `target` is where the chain tip (ankle/wrist) should land, in model space. +## `pole` is a model-space point the middle joint should aim toward — for a leg +## that is a point out in front of the knee, which is what stops knees from +## bending sideways or backwards. +## `upper_length` / `lower_length` are the segment lengths in metres. +## `rest_upper_dir` / `rest_lower_dir` are the UNIT directions each segment +## points in the rig's rest pose (for Kern's legs, very nearly straight down). +## Passing the true rest directions rather than assuming "down" is what lets the +## same solver drive an arm, a bird's wing, or a quadruped's foreleg. +static func solve(root: Vector3, target: Vector3, pole: Vector3, + upper_length: float, lower_length: float, + rest_upper_dir: Vector3, rest_lower_dir: Vector3) -> Solution: + var result: Solution = Solution.new() + var total_length: float = upper_length + lower_length + var to_target: Vector3 = target - root + var distance: float = to_target.length() + + # Degenerate target sitting exactly on the joint: fall back to the rest + # direction so the limb keeps a defined orientation instead of NaN-ing. + if distance < 0.00001: + to_target = rest_upper_dir * (total_length * 0.5) + distance = to_target.length() + + var chain_dir: float = distance + # Clamp into the annulus the chain can actually reach. Outside it there is + # no solution at all, and an unclamped acos() returns NaN. + var reach_max: float = total_length * MAX_EXTENSION + var reach_min: float = absf(upper_length - lower_length) * MIN_EXTENSION + if chain_dir > reach_max: + chain_dir = reach_max + result.clamped = true + elif chain_dir < reach_min: + chain_dir = reach_min + result.clamped = true + + var direction: Vector3 = to_target / distance + var solved_target: Vector3 = root + direction * chain_dir + + # Law of cosines: the interior angle at the root between the chain axis and + # the upper segment. + var cos_root: float = clampf( + (upper_length * upper_length + chain_dir * chain_dir + - lower_length * lower_length) / (2.0 * upper_length * chain_dir), + -1.0, 1.0) + var root_angle: float = acos(cos_root) + + # The bend plane is spanned by the chain axis and the pole. Its normal is + # the axis the joint rotates about. + var to_pole: Vector3 = pole - root + var bend_axis: Vector3 = direction.cross(to_pole) + if bend_axis.length_squared() < 0.000001: + # Pole is collinear with the chain (a leg reaching exactly at the pole). + # Any perpendicular is valid; pick a stable one off the model axes so + # the choice doesn't flicker frame to frame. + bend_axis = direction.cross(Vector3.RIGHT) + if bend_axis.length_squared() < 0.000001: + bend_axis = direction.cross(Vector3.FORWARD) + bend_axis = bend_axis.normalized() + + # Rotate the chain axis by the root angle, about the bend normal, to get the + # upper segment's direction; the lower segment then just closes onto the tip. + # + # The sign is POSITIVE and that is load-bearing. Rotating about + # `direction x to_pole` has instantaneous velocity `n x direction`, which by + # the vector triple product equals the component of `to_pole` perpendicular + # to the chain — i.e. positive angles swing the joint TOWARD the pole. + # Negating it (the first version did) puts the knee on the far side of the + # leg from the pole, which is exactly a backwards-bending knee. + var upper_dir: Vector3 = direction.rotated(bend_axis, root_angle) + var joint: Vector3 = root + upper_dir * upper_length + var lower_dir: Vector3 = (solved_target - joint).normalized() + + result.joint_position = joint + result.tip_position = solved_target + result.upper_rotation = _swing_twist(rest_upper_dir, upper_dir, bend_axis) + result.lower_rotation = _swing_twist(rest_lower_dir, lower_dir, bend_axis) + return result + + +## Build the model-space rotation that carries `rest_dir` onto `solved_dir` +## while keeping the limb's bend plane square to `bend_axis`. +## +## A bare `rest_dir -> solved_dir` shortest-arc rotation leaves the twist about +## the bone's own axis undefined, which shows up as a shin that rolls as the leg +## swings (feet pigeon-toe in and out). Building both frames explicitly and +## taking their difference pins the twist to the bend plane, so the knee and +## ankle stay square through the whole stride. +static func _swing_twist(rest_dir: Vector3, solved_dir: Vector3, + bend_axis: Vector3) -> Quaternion: + var rest_frame: Basis = _frame(rest_dir, bend_axis) + var solved_frame: Basis = _frame(solved_dir, bend_axis) + return (solved_frame * rest_frame.inverse()).get_rotation_quaternion() + + +## Orthonormal frame with its Y axis along `dir` and its X axis as close to +## `bend_axis` as orthogonality allows (Gram-Schmidt). +static func _frame(dir: Vector3, bend_axis: Vector3) -> Basis: + var y_axis: Vector3 = dir.normalized() + var x_axis: Vector3 = bend_axis - y_axis * bend_axis.dot(y_axis) + if x_axis.length_squared() < 0.000001: + # bend_axis parallel to the bone: choose any stable perpendicular. + x_axis = y_axis.cross(Vector3.FORWARD) + if x_axis.length_squared() < 0.000001: + x_axis = y_axis.cross(Vector3.RIGHT) + x_axis = x_axis.normalized() + var z_axis: Vector3 = x_axis.cross(y_axis) + return Basis(x_axis, y_axis, z_axis) + + +## Convert a MODEL-space bone rotation into the parent-relative rotation that +## `Skeleton3D.set_bone_pose_rotation()` expects. +## +## Godot stores a bone pose relative to its parent's pose, so a chain composes: +## model(child) = model(parent) * local(child). Inverting that is the last step +## of every IK write, and forgetting it is the classic "the knee rotates twice +## as far as it should" bug. +static func to_local(model_rotation: Quaternion, + parent_model_rotation: Quaternion) -> Quaternion: + return parent_model_rotation.inverse() * model_rotation + + +## Measured length between two model-space rest positions — small helper so rigs +## can derive their segment lengths from the skeleton instead of hard-coding +## numbers that drift when the body is re-proportioned. +static func segment_length(from_rest: Vector3, to_rest: Vector3) -> float: + return from_rest.distance_to(to_rest) diff --git a/game/src/anim/two_bone_ik.gd.uid b/game/src/anim/two_bone_ik.gd.uid new file mode 100644 index 0000000..f0b8735 --- /dev/null +++ b/game/src/anim/two_bone_ik.gd.uid @@ -0,0 +1 @@ +uid://uvxpouodphtv diff --git a/game/src/dev/locomotion_lab.gd b/game/src/dev/locomotion_lab.gd new file mode 100644 index 0000000..b6d197e --- /dev/null +++ b/game/src/dev/locomotion_lab.gd @@ -0,0 +1,899 @@ +extends Node3D +## Dev-only instrumented test bench for Kern's movement — the measuring +## instrument the locomotion work is tuned against. +## +## **Why measure instead of eyeballing.** "Smoother" and "more real" are not +## quantities you can iterate on by looking at screenshots; by the fifth pass +## nobody can remember whether the feet slid more or less than they did in the +## second. So this scene builds a controlled obstacle course, drives the REAL +## player controller through a scripted movement program with synthetic input, +## and samples hard numbers every physics frame: +## +## * **foot slip** — how far a foot that is supposed to be planted actually +## slides through the world. The headline number; on a correct +## distance-phased gait it is near zero at every speed. +## * **ground error** — how far each planted foot floats above or sinks below +## the real collision surface. +## * **knee direction** — signed, so a rig bending its knees backwards is +## caught by the harness instead of by a player. +## * **pose jerk** — the largest single-frame bone rotation, which is how +## pops, snaps and unblended state changes show up numerically. +## * **body jerk** — third derivative of position; controller smoothness. +## +## Results are printed as a per-segment table and written as JSON so successive +## runs can be diffed. When run with a real display it also saves a contact +## sheet of frames, because numbers cannot tell you whether a pose is dignified. +## +## Run: +## godot --headless --path game res://scenes/dev/locomotion_lab.tscn -- \ +## --out=C:/abs/dir +## Optional: `--shots` also saves PNG frames (needs a real display, not +## --headless), `--segment=run` restricts the program to one named segment. +## +## Architecture: dev-only harness, never shipped and referenced by nothing in +## the game. Drives `player.gd` through the same `Input` singleton a human uses, +## so it exercises the shipping code path rather than a test double. + +const PlayerScene: PackedScene = preload("res://scenes/player/player.tscn") + +## Physics frames to let the rig settle before sampling starts. +const WARMUP_FRAMES: int = 30 + +## A foot is treated as "should be planted" above this contact weight. +const PLANTED_THRESHOLD: float = 0.55 + +## Foot dimensions used to locate the instantaneous ground-contact point: +## metres from the ankle joint back to the heel and forward to the toe. +## Approximately a 0.22 m foot, which is right for a 1.78 m figure. +const HEEL_BEHIND: float = 0.06 +const TOE_AHEAD: float = 0.16 + +## Foot pitch, in radians, beyond which the load is treated as being on the +## heel or the toe rather than spread across a flat sole (~1.7°). +## +## Deliberately tiny. A rigid foot is either flat or pivoting; there is no wide +## middle band. An earlier 0.12 rad threshold called the last third of the heel +## rocker "flat", so the harness measured the ankle at a moment the ankle is +## SUPPOSED to be travelling, and reported honest foot-roll as ~90 mm/m of slip. +const FLAT_PITCH_EPSILON: float = 0.03 + +## Frames between saved screenshots when `--shots` is on. +const SHOT_INTERVAL: int = 24 + +## Hard ceiling on physics frames for a whole run, after which the harness +## reports and exits regardless. The full program is about 4,400 frames. +const MAX_FRAMES: int = 12000 + +## Physics frames after each teleport during which the segment is DRIVEN but +## not SAMPLED. +## +## Sized off the SLOWEST continuous state in the animator, not off "looks like +## enough". The airborne cross-fade has a 0.07 s half-life and every teleport +## re-triggers it, so it needs roughly ten half-lives to become negligible; +## until it does, `_air_pose` is still blending the legs away from the IK +## solution and every foot measurement is really a measurement of the teleport. +## At 20 frames that transient was leaking into the samples and inflating +## reported foot slip several times over. +const SETTLE_FRAMES: int = 60 + + +## One step of the scripted movement program. +class Segment: + extends RefCounted + + ## Name used in the report. + var name: String = "" + ## Seconds to hold this segment. + var duration: float = 2.0 + ## Held actions -> analog strength. + var actions: Dictionary = {} + ## Camera heading in radians, which is what makes movement input + ## directional (the controller is camera-relative). + var heading: float = 0.0 + ## Radians/second the camera turns during the segment — how the harness + ## exercises turning without a human on a stick. + var heading_rate: float = 0.0 + ## Emote to fire on entry, empty for none. + var emote: String = "" + ## Fire a jump every `jump_period` seconds; 0 disables. + var jump_period: float = 0.0 + ## Where to teleport the player before the segment starts. + ## + ## Segments are deliberately INDEPENDENT: each one is placed on the terrain + ## feature it is meant to test rather than inheriting wherever the previous + ## segment happened to end up. The first version of this harness let the + ## player drift across the course and produced numbers that were mostly a + ## report on which obstacle it had wandered into. + var start: Vector3 = Vector3(0.0, 0.6, 30.0) + + +## Accumulated measurements for one segment. +class SegmentStats: + extends RefCounted + + var name: String = "" + var frames: int = 0 + var distance: float = 0.0 + var slip_total: float = 0.0 + var slip_max: float = 0.0 + var planted_frames: int = 0 + var ground_error_total: float = 0.0 + var ground_error_max: float = 0.0 + var pose_jerk_max: float = 0.0 + var body_jerk_total: float = 0.0 + var knee_backward_frames: int = 0 + ## How far the rendered ankle ends up from the target the planter asked + ## for. Non-zero means the IK could not reach and clamped — which is a + ## different failure from sliding, and the two are easy to confuse because + ## a clamped foot drifts as the hip moves. + var ik_error_total: float = 0.0 + var ik_error_max: float = 0.0 + var ik_samples: int = 0 + ## Slip split by which part of the foot was bearing load: heel / flat sole / + ## toe. Splitting it is what tells you WHICH model is wrong — a flat-sole + ## figure means the plant lock is failing, a heel or toe figure means the + ## rocker geometry disagrees with the ankle roll being animated. + var slip_by_regime: Array[float] = [0.0, 0.0, 0.0] + var speed_total: float = 0.0 + var head_y_min: float = 1e9 + var head_y_max: float = -1e9 + + ## Millimetres of slip per metre travelled — the scale-free headline + ## number, so a fast segment is not flattered by covering more ground. + func slip_per_metre_mm() -> float: + if distance < 0.01: + return 0.0 + return (slip_total / distance) * 1000.0 + + func mean_ground_error_mm() -> float: + if planted_frames == 0: + return 0.0 + return (ground_error_total / float(planted_frames)) * 1000.0 + + func mean_speed() -> float: + if frames == 0: + return 0.0 + return speed_total / float(frames) + + func mean_body_jerk() -> float: + if frames == 0: + return 0.0 + return body_jerk_total / float(frames) + + func mean_ik_error_mm() -> float: + if ik_samples == 0: + return 0.0 + return (ik_error_total / float(ik_samples)) * 1000.0 + + +var _player: CharacterBody3D +var _visual: Node3D +var _skeleton: Skeleton3D +var _bones: Dictionary = {} +var _rig: Node3D + +var _segments: Array = [] +var _stats: Array = [] +var _index: int = 0 +var _elapsed: float = 0.0 +var _warmup: int = 0 +var _heading: float = 0.0 +var _jump_timer: float = 0.0 +var _held: Dictionary = {} +var _settle: int = 0 +var _entered: bool = false +var _ankle_rest_y: float = 0.115 +var _solver_shortfall_mm: float = 0.0 +var _solver_clamped_frames: int = 0 +var _solver_samples: int = 0 +var _total_frames: int = 0 +## `--trace` prints the raw target-versus-result numbers for one foot. Summary +## statistics can only tell you a discrepancy exists; this tells you its shape. +var _tracing: bool = false +var _trace_left: int = 24 + +var _prev_foot_world: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO] +var _prev_regime: Array[int] = [1, 1] +var _have_prev_foot: bool = false +var _prev_bone_rotations: Dictionary = {} +var _prev_velocity: Vector3 = Vector3.ZERO +var _prev_accel: Vector3 = Vector3.ZERO +var _prev_position: Vector3 = Vector3.ZERO + +var _out_dir: String = "" +var _want_shots: bool = false +var _shot_counter: int = 0 +var _shots_saved: int = 0 +var _camera: Camera3D +var _finished: bool = false + + +func _ready() -> void: + _out_dir = _arg("--out=") + _want_shots = OS.get_cmdline_user_args().has("--shots") + _tracing = OS.get_cmdline_user_args().has("--trace") + InputSetup.ensure() + _build_stage() + _spawn_player() + _build_program() + set_physics_process(true) + + +func _arg(prefix: String) -> String: + for a in OS.get_cmdline_user_args(): + if a.begins_with(prefix): + return a.get_slice("=", 1) + return "" + + +# --- Stage ------------------------------------------------------------------- + +## Build the obstacle course: a long flat run, a ramp up, a ramp down, a stair +## flight and a bumpy patch. Every surface is on collision layer 1, which is +## what the player's mask and the foot probes look for. +## Build the course as four SEPARATE zones side by side along X, each a clean +## platform with one terrain feature on it. Segments teleport to the zone they +## need, so no test can contaminate the next one. +## x = 0 flat +## x = 60 ramps (up and down) +## x = 120 stairs +## x = 180 bumpy +func _build_stage() -> void: + # Zone A: flat. Long enough for a 4-second sprint with room to spare. + _add_box(Vector3(0.0, -0.5, 0.0), Vector3(30.0, 1.0, 120.0)) + + # Zone B: ramps. A gentle 12 degrees up and a steeper 22 down, each with a + # flat run-up so the character arrives at speed rather than from standstill. + _add_box(Vector3(60.0, -0.5, 20.0), Vector3(30.0, 1.0, 40.0)) + _add_ramp(Vector3(60.0, 0.52, -6.0), Vector3(16.0, 1.0, 26.0), + deg_to_rad(12.0)) + _add_box(Vector3(60.0, 2.14, -28.0), Vector3(30.0, 1.0, 20.0)) + _add_ramp(Vector3(60.0, 1.42, -46.0), Vector3(16.0, 1.0, 20.0), + -deg_to_rad(22.0)) + + # Zone C: an eight-step flight of 0.16 m risers — the foot-IK torture test. + _add_box(Vector3(120.0, -0.5, 20.0), Vector3(30.0, 1.0, 40.0)) + for i in 8: + var rise: float = float(i + 1) * 0.16 + _add_box(Vector3(120.0, rise * 0.5 - 0.5, -1.0 - float(i) * 0.42), + Vector3(12.0, rise + 1.0, 0.42)) + _add_box(Vector3(120.0, 0.78, -12.0), Vector3(30.0, 1.0, 18.0)) + + # Zone D: bumpy ground — low random rises that never let both feet share a + # height, which is what forces the pelvis and ankles to work independently. + _add_box(Vector3(180.0, -0.5, 0.0), Vector3(30.0, 1.0, 90.0)) + var rng: RandomNumberGenerator = RandomNumberGenerator.new() + rng.seed = 20260729 + for i in 90: + var x: float = 180.0 + rng.randf_range(-8.0, 8.0) + var z: float = rng.randf_range(-34.0, 34.0) + _add_box(Vector3(x, rng.randf_range(-0.02, 0.09), z), + Vector3(rng.randf_range(0.7, 2.0), 0.22, rng.randf_range(0.7, 2.0))) + + _add_stripes() + + var light: DirectionalLight3D = DirectionalLight3D.new() + light.rotation = Vector3(deg_to_rad(-48.0), deg_to_rad(35.0), 0.0) + light.light_energy = 1.2 + light.shadow_enabled = true + add_child(light) + + # Observer camera. The player's own rig is frozen for the test, so without + # this every screenshot is a photograph of the spawn point. + _camera = Camera3D.new() + _camera.name = "LabCamera" + _camera.fov = 42.0 + var env: Environment = Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = Color(0.20, 0.22, 0.26) + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.50, 0.55, 0.65) + env.ambient_light_energy = 0.4 + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + _camera.environment = env + add_child(_camera) + _camera.current = true + + +## Paint half-metre reference stripes along every walking surface. +## +## Foot slip of a centimetre or two is genuinely hard to see against blank +## ground, and it is exactly what the eye picks up as "wrong" in motion. Against +## a fixed stripe the contact point either holds a line or it does not, so a +## screenshot pair answers the question the metrics can only summarise. +func _add_stripes() -> void: + var zones: Array[float] = [0.0, 60.0, 120.0, 180.0] + for zone_x in zones: + for i in 260: + if i % 2 == 1: + continue + var z: float = -60.0 + float(i) * 0.5 + var stripe: MeshInstance3D = MeshInstance3D.new() + var plane: BoxMesh = BoxMesh.new() + plane.size = Vector3(9.0, 0.01, 0.5) + stripe.mesh = plane + var mat: StandardMaterial3D = StandardMaterial3D.new() + mat.albedo_color = Color(0.42, 0.45, 0.52) + mat.roughness = 0.95 + stripe.material_override = mat + stripe.position = Vector3(zone_x, 0.006, z) + add_child(stripe) + + +## A static box collider plus matching mesh, centred at `centre`. +func _add_box(centre: Vector3, size: Vector3) -> void: + var body: StaticBody3D = StaticBody3D.new() + body.collision_layer = 1 + body.collision_mask = 0 + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = size + shape.shape = box + body.add_child(shape) + var mesh: MeshInstance3D = MeshInstance3D.new() + var box_mesh: BoxMesh = BoxMesh.new() + box_mesh.size = size + mesh.mesh = box_mesh + body.add_child(mesh) + body.position = centre + add_child(body) + + +## A box rotated about X to make a walkable ramp. +func _add_ramp(centre: Vector3, size: Vector3, pitch: float) -> void: + var body: StaticBody3D = StaticBody3D.new() + body.collision_layer = 1 + body.collision_mask = 0 + var shape: CollisionShape3D = CollisionShape3D.new() + var box: BoxShape3D = BoxShape3D.new() + box.size = size + shape.shape = box + body.add_child(shape) + var mesh: MeshInstance3D = MeshInstance3D.new() + var box_mesh: BoxMesh = BoxMesh.new() + box_mesh.size = size + mesh.mesh = box_mesh + body.add_child(mesh) + body.position = centre + body.rotation.x = pitch + add_child(body) + + +func _spawn_player() -> void: + _player = PlayerScene.instantiate() as CharacterBody3D + add_child(_player) + _player.global_position = Vector3(0.0, 0.4, 30.0) + _visual = _player.get_node_or_null("Visual") + _rig = _player.get_node_or_null("CameraRig") + # The rig captures the mouse on setup; the lab drives its heading directly + # and watches through its own camera instead. + if _rig != null: + _rig.set_process(false) + var player_camera: Camera3D = _player.get_node_or_null( + "CameraRig/SpringArm3D/Camera3D") + if player_camera != null: + player_camera.current = false + # `--nocloak` hides the cloak and tunic skirt. They are most of Kern's + # silhouette and they hang over exactly the joints this harness exists to + # inspect, so gait work is done with them off and posture work with them on. + if _visual != null and OS.get_cmdline_user_args().has("--nocloak"): + for path in ["KernSkeleton/Cloak", "KernSkeleton/CloakTrim", + "KernSkeleton/Tunic", "KernSkeleton/ScarfTail"]: + var part: Node3D = _visual.get_node_or_null(path) + if part != null: + part.visible = false + if _visual != null: + _skeleton = _visual.get_node_or_null("KernSkeleton") + if _skeleton != null: + _bones = _collect_bones(_skeleton) + # Rest height of the ankle joint above the sole — the zero point + # every ground-error measurement is taken against. + var ankle: int = _bones.get("FootL", -1) + if ankle >= 0: + _ankle_rest_y = _skeleton.get_bone_global_rest(ankle).origin.y + _prev_position = _player.global_position + + +## Map the bone names the metrics need to their skeleton indices. +func _collect_bones(skeleton: Skeleton3D) -> Dictionary: + var out: Dictionary = {} + for i in skeleton.get_bone_count(): + out[String(skeleton.get_bone_name(i))] = i + return out + + +# --- Program ----------------------------------------------------------------- + +## The scripted movement program. Each segment isolates one thing that can go +## wrong, so a regression shows up in a named row rather than as a worse +## average. +func _build_program() -> void: + var only: String = _arg("--segment=") + # Zone origins. Movement input with heading 0 travels toward -Z, so each + # segment starts at the +Z end of its zone and runs the length of it. + # Start heights sit just clear of each surface. Dropping the character in + # from height makes every segment begin with a landing, which is its own + # transient and not what most of these segments are testing. + var flat: Vector3 = Vector3(0.0, 0.06, 45.0) + var ramps: Vector3 = Vector3(60.0, 0.06, 34.0) + var stairs: Vector3 = Vector3(120.0, 0.06, 32.0) + var bumps: Vector3 = Vector3(180.0, 0.28, 34.0) + + var all: Array = [] + all.append(_seg("idle", 2.5, {}, flat)) + all.append(_seg("walk_start", 2.5, {"move_forward": 0.35}, flat)) + all.append(_seg("walk_steady", 3.5, {"move_forward": 0.35}, flat)) + all.append(_seg("jog", 3.5, {"move_forward": 0.65}, flat)) + all.append(_seg("run", 3.5, {"move_forward": 1.0}, flat)) + all.append(_seg("sprint", 3.5, {"move_forward": 1.0, "sprint": 1.0}, flat)) + var turning: Segment = _seg("run_turning", 4.0, {"move_forward": 1.0}, flat) + turning.heading_rate = 0.7 + all.append(turning) + all.append(_seg("hard_stop", 2.5, {}, flat)) + var hop: Segment = _seg("jump_flat", 4.5, {"move_forward": 0.6}, flat) + hop.jump_period = 1.3 + all.append(hop) + all.append(_seg("bumpy", 5.0, {"move_forward": 0.6}, bumps)) + all.append(_seg("stairs_up", 5.0, {"move_forward": 0.5}, stairs)) + all.append(_seg("slope_up", 5.0, {"move_forward": 0.7}, ramps)) + all.append(_seg("slope_down", 5.0, {"move_forward": 0.7}, + Vector3(60.0, 2.70, -22.0))) + all.append(_seg("crouch_walk", 4.0, {"move_forward": 0.6, "crouch": 1.0}, + flat)) + all.append(_seg("crouch_idle", 2.5, {"crouch": 1.0}, flat)) + var spin: Segment = _seg("turn_in_place", 3.0, {}, flat) + spin.heading_rate = 2.0 + all.append(spin) + all.append(_seg("strafe", 3.0, {"move_right": 0.7}, flat)) + all.append(_seg("backpedal", 3.0, {"move_back": 0.5}, flat)) + var dance: Segment = _seg("emote_dance", 4.5, {}, flat) + dance.emote = "weight_shuffle" + all.append(dance) + all.append(_seg("idle_settle", 4.0, {}, flat)) + + for entry in all: + var s: Segment = entry + if only == "" or s.name == only: + _segments.append(s) + var stat: SegmentStats = SegmentStats.new() + stat.name = s.name + _stats.append(stat) + + +func _seg(name: String, duration: float, actions: Dictionary, + start: Vector3) -> Segment: + var s: Segment = Segment.new() + s.name = name + s.duration = duration + s.actions = actions + s.start = start + return s + + +# --- Drive + sample ---------------------------------------------------------- + +func _physics_process(delta: float) -> void: + if _finished: + return + # Watchdog. A harness that can hang is worse than no harness: it burns a + # whole run before anyone notices, and the failure looks like a slow test + # rather than a bug. Past the budget it reports what it has and exits. + _total_frames += 1 + if _total_frames > MAX_FRAMES: + push_warning("LocomotionLab: frame budget exhausted at segment %d/%d" % [ + _index, _segments.size()]) + _finish() + return + if _warmup < WARMUP_FRAMES: + _warmup += 1 + _prev_position = _player.global_position + return + if _index >= _segments.size(): + _finish() + return + + var segment: Segment = _segments[_index] + # An explicit flag, not `_elapsed == 0.0`: the settle window holds elapsed + # at zero, so testing elapsed re-entered (and re-teleported) the segment + # every frame and the program never advanced. + if not _entered: + _entered = true + _enter_segment(segment) + _drive(segment, delta) + if _settle > 0: + # Settling: hold the input but throw the measurements away. + _settle -= 1 + _prev_position = _player.global_position + return + _sample(_stats[_index], delta) + + _elapsed += delta + if _elapsed >= segment.duration: + _release_all() + _elapsed = 0.0 + _index += 1 + _entered = false + + if _want_shots: + _track_camera() + _shot_counter += 1 + if _shot_counter % _shot_interval() == 0: + _save_shot(segment.name) + + +## Frames between screenshots. `--shotevery=N` tightens it to inspect a single +## stance frame by frame, which is how a planted foot is checked against the +## ground stripes. +func _shot_interval() -> int: + var override: String = _arg("--shotevery=") + if override != "": + return maxi(1, int(override.to_int())) + return SHOT_INTERVAL + + +## Follow the player from the side at knee height — the view that actually +## shows whether feet are planting or skating. `--shotmode=full` pulls back to +## frame the whole body for judging posture and emotes instead. +func _track_camera() -> void: + if _camera == null or _player == null: + return + var focus: Vector3 = _player.global_position + if _arg("--shotmode=") == "full": + _camera.fov = 40.0 + _camera.global_position = focus + Vector3(3.6, 1.15, 0.9) + _camera.look_at(focus + Vector3(0.0, 0.95, 0.0)) + else: + _camera.fov = 34.0 + _camera.global_position = focus + Vector3(2.6, 0.62, 0.35) + _camera.look_at(focus + Vector3(0.0, 0.42, 0.0)) + + +## Teleport onto this segment's terrain feature, clear every continuous +## animation state, and let the body settle before sampling resumes. +func _enter_segment(segment: Segment) -> void: + _heading = segment.heading + _jump_timer = 0.0 + _player.global_position = segment.start + _player.velocity = Vector3.ZERO + if _visual != null and _visual.has_method("teleported"): + _visual.call("teleported") + if _visual != null and _visual.has_method("stop_emote"): + _visual.call("stop_emote") + # Springs, plant locks and the previous-frame caches all have to be + # discarded or the first frames of every segment measure the teleport. + _have_prev_foot = false + _prev_bone_rotations.clear() + _prev_velocity = Vector3.ZERO + _prev_accel = Vector3.ZERO + _prev_position = segment.start + _settle = SETTLE_FRAMES + if segment.emote != "" and _visual != null and _visual.has_method("play_emote"): + _visual.call("play_emote", segment.emote) + + +## Hold the segment's actions through the `Input` singleton — the same path a +## real keyboard takes, so the harness cannot accidentally test a code path the +## player never reaches. +func _drive(segment: Segment, delta: float) -> void: + _heading += segment.heading_rate * delta + if _rig != null: + _rig.rotation.y = _heading + for action in segment.actions: + var name: StringName = StringName(action) + if InputMap.has_action(name): + Input.action_press(name, float(segment.actions[action])) + _held[name] = true + if segment.jump_period > 0.0: + _jump_timer += delta + if _jump_timer >= segment.jump_period: + _jump_timer = 0.0 + Input.action_press(&"jump") + _held[&"jump"] = true + elif _jump_timer > 0.1 and Input.is_action_pressed(&"jump"): + Input.action_release(&"jump") + + +func _release_all() -> void: + for action in _held: + Input.action_release(action as StringName) + _held.clear() + + +## Take every measurement for this frame. +func _sample(stats: SegmentStats, delta: float) -> void: + stats.frames += 1 + var position: Vector3 = _player.global_position + var travelled: Vector3 = position - _prev_position + _prev_position = position + travelled.y = 0.0 + stats.distance += travelled.length() + stats.speed_total += Vector2(_player.velocity.x, _player.velocity.z).length() + + # Body jerk: third derivative, the standard smoothness measure. + var velocity: Vector3 = _player.velocity + var accel: Vector3 = (velocity - _prev_velocity) / maxf(delta, 0.0001) + var jerk: Vector3 = (accel - _prev_accel) / maxf(delta, 0.0001) + _prev_velocity = velocity + _prev_accel = accel + stats.body_jerk_total += jerk.length() + + if _skeleton == null: + return + + var animator: Object = _visual.get("animator") if _visual != null else null + if animator != null: + _solver_samples += 1 + var shortfall: float = float(animator.get("ik_tip_error")) + _solver_shortfall_mm += (shortfall * 1000.0 - _solver_shortfall_mm) \ + / float(_solver_samples) + if bool(animator.get("ik_clamped")): + _solver_clamped_frames += 1 + # `stance`, not `contact`: contact ramps up before touchdown so the IK eases + # in, so it is true for the last few frames of a swing while the foot is + # still travelling at full speed. Measuring slip on `contact` counted that + # honest swing motion as sliding and inflated every figure this harness + # produced by roughly a factor of three. + var planted: Array[bool] = [false, false] + var wanted: Array = [Vector3.ZERO, Vector3.ZERO] + var have_wanted: bool = false + if animator != null and animator.get("feet") != null: + var feet: Array = animator.get("feet") + have_wanted = feet.size() >= 2 + for i in mini(feet.size(), 2): + planted[i] = bool((feet[i] as Object).get("stance")) + wanted[i] = (feet[i] as Object).get("world_position") + + # --- Foot slip and ground error --- + # Slip is measured at the INSTANTANEOUS GROUND-CONTACT POINT, over the whole + # stance. That point is the heel while the toes are up, the toe once the + # heel has lifted, and the middle of the sole in between — and for a foot + # that is correctly pivoting rather than sliding, it is stationary in all + # three cases. Measuring the sole's centre instead (as the first version + # did) reports honest heel-and-toe roll as slip, which made a crouch-walk + # with almost no roll look ten times worse than a normal walk. + for i in 2: + var bone_name: String = "FootR" if i == 1 else "FootL" + var idx: int = _bones.get(bone_name, -1) + if idx < 0: + continue + var foot_transform: Transform3D = _skeleton.global_transform \ + * _skeleton.get_bone_global_pose(idx) + var ankle_world: Vector3 = foot_transform.origin + # Did the leg actually reach where it was told to stand? Compared + # against the solver's OWN reported shortfall below, this says whether + # the gap is the IK giving up or the skeleton not landing on the + # solution the IK returned. + if have_wanted and planted[i]: + var reach_miss: float = ankle_world.distance_to(wanted[i]) + stats.ik_error_total += reach_miss + stats.ik_error_max = maxf(stats.ik_error_max, reach_miss) + stats.ik_samples += 1 + var probe: Dictionary = _ground_probe(ankle_world) + var regime: int = _contact_regime(foot_transform, probe["normal"]) + var contact_world: Vector3 = foot_transform \ + * _regime_point_local(regime) + if _tracing and i == 0 and _trace_left > 0 and planted[i]: + _trace_left -= 1 + var heel_pt: Vector3 = foot_transform * Vector3(0.0, -_ankle_rest_y, + HEEL_BEHIND) + var toe_pt: Vector3 = foot_transform * Vector3(0.0, -_ankle_rest_y, + -TOE_AHEAD) + var fwd: Vector3 = -foot_transform.basis.z.normalized() + var asked: Vector3 = (animator.get("ik_target_world") as Array)[i] + var got: Vector3 = (animator.get("ik_solved_world") as Array)[i] + print("TRACE bone=(%.4f,%.4f) asked=(%.4f,%.4f) solved=(%.4f,%.4f) planter=(%.4f,%.4f)" % [ + ankle_world.z, ankle_world.y, asked.z, asked.y, + got.z, got.y, wanted[i].z, wanted[i].y]) + # Only compare within a single regime. Across a heel-to-toe changeover + # the sampled point legitimately moves to the other end of the foot, + # and counting that would report a 220 mm teleport every stride. + var comparable: bool = _have_prev_foot and regime == _prev_regime[i] + if comparable and planted[i]: + var drift: Vector3 = contact_world - _prev_foot_world[i] + drift.y = 0.0 + var slip: float = drift.length() + stats.slip_total += slip + stats.slip_max = maxf(stats.slip_max, slip) + stats.slip_by_regime[regime] += slip + stats.planted_frames += 1 + # Ground error: how far the sole sits off the real surface. + var error: float = (ankle_world.y - float(probe["y"])) - _ankle_rest_y + stats.ground_error_total += absf(error) + stats.ground_error_max = maxf(stats.ground_error_max, absf(error)) + _prev_foot_world[i] = contact_world + _prev_regime[i] = regime + _have_prev_foot = true + + # --- Knee direction: the knee must sit FORWARD of the hip-ankle line --- + for i in 2: + var suffix: String = "R" if i == 1 else "L" + var hip: int = _bones.get("Thigh" + suffix, -1) + var knee: int = _bones.get("Shin" + suffix, -1) + var ankle: int = _bones.get("Foot" + suffix, -1) + if hip < 0 or knee < 0 or ankle < 0: + continue + var hip_p: Vector3 = _skeleton.get_bone_global_pose(hip).origin + var knee_p: Vector3 = _skeleton.get_bone_global_pose(knee).origin + var ankle_p: Vector3 = _skeleton.get_bone_global_pose(ankle).origin + # Project the knee onto the straight hip-to-ankle line and look at which + # side it bulges to. Model forward is -Z, so a correct knee sits at + # NEGATIVE Z of that line and a hyperextended one at positive Z. + # (Comparing against the midpoint instead, as the first version did, + # just measures the leg's overall pitch and flags a perfectly good + # stance leg.) + var axis: Vector3 = ankle_p - hip_p + var along: float = 0.5 + if axis.length_squared() > 0.000001: + along = clampf((knee_p - hip_p).dot(axis) / axis.length_squared(), + 0.0, 1.0) + var on_line: Vector3 = hip_p + axis * along + # 4 mm of tolerance: a near-straight leg has no meaningful bend side. + if knee_p.z - on_line.z > 0.004: + stats.knee_backward_frames += 1 + + # --- Pose jerk: the largest single-frame bone rotation --- + var worst: float = 0.0 + for bone_name in _bones: + var idx: int = _bones[bone_name] + var rotation: Quaternion = _skeleton.get_bone_pose_rotation(idx) + if _prev_bone_rotations.has(bone_name): + var previous: Quaternion = _prev_bone_rotations[bone_name] + var angle: float = previous.angle_to(rotation) / maxf(delta, 0.0001) + worst = maxf(worst, angle) + _prev_bone_rotations[bone_name] = rotation + stats.pose_jerk_max = maxf(stats.pose_jerk_max, worst) + + # --- Head height envelope: the camera-visible bob --- + var head_idx: int = _bones.get("Head", -1) + if head_idx >= 0: + var head_y: float = (_skeleton.global_transform + * _skeleton.get_bone_global_pose(head_idx).origin).y - position.y + stats.head_y_min = minf(stats.head_y_min, head_y) + stats.head_y_max = maxf(stats.head_y_max, head_y) + + +## Which part of the foot is bearing the load right now: 0 heel, 1 whole sole, +## 2 toe. Read from the rendered bone basis, so the harness stays an +## independent check on the animation rather than a restatement of it. +## `surface_normal` is the normal of the ground under the foot. Pitch is +## measured against THAT, not against world up: on a ramp a foot lying flat on +## the slope is still flat, and testing it against world up would classify +## every downhill step as a toe-strike. +func _contact_regime(foot_transform: Transform3D, + surface_normal: Vector3) -> int: + # Model forward is -Z; a positive component along the surface normal means + # the toes are raised off the surface. + var forward: Vector3 = -foot_transform.basis.z.normalized() + var pitch: float = asin(clampf(forward.dot(surface_normal.normalized()), + -1.0, 1.0)) + if pitch > FLAT_PITCH_EPSILON: + return 0 + if pitch < -FLAT_PITCH_EPSILON: + return 2 + return 1 + + +## The point that must hold still in each regime, in the FOOT bone's local +## frame: the heel while the toes are up, the toe once the heel has lifted, and +## the ankle itself while the sole is flat (a flat foot must not move at all). +## +## Measuring the right point per regime is essential. A single fixed sample +## point reports the foot's honest heel-and-toe rocker as sliding, and a sample +## that slides continuously between heel and toe reports the changeover as a +## 200 mm teleport — both of which this harness did before, and both of which +## sent the tuning after problems the animation did not have. +func _regime_point_local(regime: int) -> Vector3: + match regime: + 0: return Vector3(0.0, -_ankle_rest_y, HEEL_BEHIND) + 2: return Vector3(0.0, -_ankle_rest_y, -TOE_AHEAD) + _: return Vector3.ZERO + + +## Height and normal of the collision surface beneath a point. One probe per +## foot per frame feeds both the ground-error measurement and the contact-regime +## test, which need the same surface. +func _ground_probe(foot_world: Vector3) -> Dictionary: + var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state + var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create( + foot_world + Vector3.UP * 1.0, foot_world + Vector3.DOWN * 1.5) + query.collision_mask = 1 + var hit: Dictionary = space.intersect_ray(query) + if hit.is_empty(): + return {"y": foot_world.y - _ankle_rest_y, "normal": Vector3.UP} + return {"y": (hit["position"] as Vector3).y, + "normal": (hit["normal"] as Vector3).normalized()} + + +func _save_shot(label: String) -> void: + if _out_dir == "": + return + var image: Image = get_viewport().get_texture().get_image() + if image == null: + return + _shots_saved += 1 + image.save_png(_out_dir.path_join("frame_%03d_%s.png" % [_shots_saved, label])) + + +# --- Report ------------------------------------------------------------------ + +func _finish() -> void: + _finished = true + _release_all() + _print_table() + _write_json() + get_tree().quit() + + +func _print_table() -> void: + print("") + print("=== LOCOMOTION LAB ===") + print("%-16s %7s %9s %9s %9s %8s %8s %8s %7s" % ["segment", "speed", + "slip/m mm", "slipmax mm", "grounderr", "ikerr mm", "posejerk", + "bodyjerk", "kneebk"]) + var totals: Dictionary = {"slip": 0.0, "distance": 0.0, "knee": 0, + "ground": 0.0, "planted": 0, "jerk": 0.0} + for entry in _stats: + var s: SegmentStats = entry + print("%-16s %7.2f %9.2f %10.2f %9.2f %8.2f %8.1f %8.0f %7d" % [ + s.name, s.mean_speed(), s.slip_per_metre_mm(), s.slip_max * 1000.0, + s.mean_ground_error_mm(), s.mean_ik_error_mm(), s.pose_jerk_max, + s.mean_body_jerk(), s.knee_backward_frames]) + totals["slip"] = float(totals["slip"]) + s.slip_total + totals["distance"] = float(totals["distance"]) + s.distance + totals["knee"] = int(totals["knee"]) + s.knee_backward_frames + totals["ground"] = float(totals["ground"]) + s.ground_error_total + totals["planted"] = int(totals["planted"]) + s.planted_frames + totals["jerk"] = maxf(float(totals["jerk"]), s.pose_jerk_max) + var overall_slip: float = 0.0 + if float(totals["distance"]) > 0.01: + overall_slip = float(totals["slip"]) / float(totals["distance"]) * 1000.0 + var overall_ground: float = 0.0 + if int(totals["planted"]) > 0: + overall_ground = float(totals["ground"]) / float(totals["planted"]) * 1000.0 + # Where the slip actually happens, summed across the whole run. + var heel: float = 0.0 + var flat: float = 0.0 + var toe: float = 0.0 + for entry in _stats: + var s: SegmentStats = entry + heel += s.slip_by_regime[0] + flat += s.slip_by_regime[1] + toe += s.slip_by_regime[2] + print("") + print("slip by contact: heel %.3f m | flat sole %.3f m | toe %.3f m" % [ + heel, flat, toe]) + print("solver shortfall: %.2f mm mean, clamped on %.1f%% of frames" % [ + _solver_shortfall_mm, 100.0 * _solver_clamped_frames + / maxf(1.0, float(_solver_samples))]) + print("OVERALL slip %.2f mm/m | ground error %.2f mm | worst pose jerk %.1f rad/s | backward-knee frames %d" % [ + overall_slip, overall_ground, float(totals["jerk"]), int(totals["knee"])]) + print("======================") + + +func _write_json() -> void: + if _out_dir == "": + return + var rows: Array = [] + for entry in _stats: + var s: SegmentStats = entry + rows.append({ + "segment": s.name, + "frames": s.frames, + "distance_m": s.distance, + "mean_speed": s.mean_speed(), + "slip_per_metre_mm": s.slip_per_metre_mm(), + "slip_max_mm": s.slip_max * 1000.0, + "ground_error_mean_mm": s.mean_ground_error_mm(), + "ground_error_max_mm": s.ground_error_max * 1000.0, + "pose_jerk_max": s.pose_jerk_max, + "body_jerk_mean": s.mean_body_jerk(), + "knee_backward_frames": s.knee_backward_frames, + "head_bob_mm": (s.head_y_max - s.head_y_min) * 1000.0 + if s.head_y_max > -1e8 else 0.0, + }) + var file: FileAccess = FileAccess.open( + _out_dir.path_join("locomotion_metrics.json"), FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify({"segments": rows}, " ")) + file.close() + print("LocomotionLab: wrote ", _out_dir.path_join("locomotion_metrics.json")) diff --git a/game/src/dev/locomotion_lab.gd.uid b/game/src/dev/locomotion_lab.gd.uid new file mode 100644 index 0000000..a5df383 --- /dev/null +++ b/game/src/dev/locomotion_lab.gd.uid @@ -0,0 +1 @@ +uid://bc07qs1yhw456 diff --git a/game/src/player/input_setup.gd b/game/src/player/input_setup.gd index 4f31e96..ad7b8f4 100644 --- a/game/src/player/input_setup.gd +++ b/game/src/player/input_setup.gd @@ -34,6 +34,13 @@ static func ensure() -> void: _pad_button(&"jump", JOY_BUTTON_A) _key(&"sprint", KEY_SHIFT) _pad_button(&"sprint", JOY_BUTTON_LEFT_STICK) + # Crouch is a HOLD (milestone 2 movement pass). C on keyboard; the pad uses + # the left shoulder, leaving the right one on block. + _key(&"crouch", KEY_C) + _pad_button(&"crouch", JOY_BUTTON_LEFT_SHOULDER) + # Walk modifier: forces the slow gait regardless of stick deflection, so a + # keyboard player can still walk rather than only jog or sprint. + _key(&"walk", KEY_ALT) # Combat (milestone 6). _mouse(&"attack", MOUSE_BUTTON_LEFT) _pad_button(&"attack", JOY_BUTTON_X) @@ -66,6 +73,11 @@ static func ensure() -> void: # Back/Select already opens the pack, so sharing it would toggle both UIs. _key(&"compendium", KEY_J) _pad_button(&"compendium", JOY_BUTTON_START) + # Emotes (movement pass): hold to open the wheel and pick with the stick. + # B on keyboard; the right stick click on a pad — the d-pad is already the + # quiz answer keys and Back/Start are the pack and the compendium. + _key(&"emote_wheel", KEY_B) + _pad_button(&"emote_wheel", JOY_BUTTON_RIGHT_STICK) static func _action(action: StringName) -> void: diff --git a/game/src/player/kern/kern_gear_builder.gd b/game/src/player/kern/kern_gear_builder.gd index 8204acc..6d9d910 100644 --- a/game/src/player/kern/kern_gear_builder.gd +++ b/game/src/player/kern/kern_gear_builder.gd @@ -385,7 +385,14 @@ static func _build_boot(foot_attach: BoneAttachment3D, right: bool) -> void: # Ankle cuff + shaft going up. var shaft: Array = [] var shaft_rows: Array = [ - [0.145, 0.052, 0.052], # cuff top (folded over) + # The cuff tops out just ABOVE the trouser cuff (which ends at model + # y 0.325, i.e. 0.210 above the ankle joint) so the two overlap. These + # rows used to stop at 0.145, leaving a ~65 mm ring of nothing between + # boot and trouser: invisible on a still figure, obvious the moment the + # knee bends in a stride. + [0.228, 0.050, 0.050], # cuff top (folded over) + [0.195, 0.051, 0.051], + [0.150, 0.050, 0.051], [0.120, 0.048, 0.048], [0.090, 0.050, 0.055], [0.045, 0.055, 0.070], # around the ankle diff --git a/game/src/player/kern_visual.gd b/game/src/player/kern_visual.gd index 25c157d..4bab8a9 100644 --- a/game/src/player/kern_visual.gd +++ b/game/src/player/kern_visual.gd @@ -13,9 +13,19 @@ extends Node3D ## hooks PlayerCombat drives; they now choreograph the arm+sword on the ## skeleton instead of a floating primitive. ## -## Animation is layered: a locomotion base (gait, torso counter-rotation, head -## carriage) is computed every frame, then combat can override the sword arm, -## and idle life (breathing, weight-shift, blinks, saccades) plays on top. +## **Animation.** Since the movement pass, the general work is done by +## `CreatureAnimator` (see `src/anim/`): a distance-phased gait, foot IK planted +## on the real collision world, terrain-adaptive pelvis, momentum lean, air and +## landing behaviour, idle fidgets and emotes. What stays here is what is +## genuinely Kern's own — the cloak spring, the sculpted head's blinks and +## saccades, the arcane awaken glow and the sword-arm combat overlay — plus the +## commit step, which is Kern-specific because he is the only character in the +## game driving TWO skeletons (his procedural rig and, when enabled, the +## imported base-mesh rig) from one pose. +## +## Layer order per frame: animator (locomotion + idle + emote) -> combat +## override on the sword arm -> commit to both skeletons -> cloak, head and +## glow, which read the committed pose rather than contributing to it. const BodyBuilder: GDScript = preload("res://src/player/kern/kern_body_builder.gd") const GearBuilder: GDScript = preload("res://src/player/kern/kern_gear_builder.gd") @@ -23,6 +33,10 @@ const HeadScene: GDScript = preload("res://src/player/kern/kern_head.gd") const KM: GDScript = preload("res://src/player/kern/kern_materials.gd") const BaseModel: GDScript = preload("res://src/player/kern/kern_base_model.gd") +## Kern's height in metres — the animator measures everything else off the rig, +## but scale-relative tuning needs the one number the body was authored to. +const BODY_HEIGHT: float = 1.78 + ## The First Model showing through: 0 = ordinary disguised traveller, 1 = fully ## lit. A faint rest ember, rising with the knowledge-charge meter (and, later, ## machinery proximity / hallucination zones). Set >= 0 to force a level @@ -68,9 +82,14 @@ var _base_retarget: Dictionary = {} var _head: KernHead var _sword: Node3D -var _phase: float = 0.0 # gait cycle +## The shared procedural-animation driver. Public so the dev locomotion lab can +## read its per-frame foot/gait state without a back-channel. +var animator: CreatureAnimator = CreatureAnimator.new() + var _idle_t: float = 0.0 -var _speed_smooth: float = 0.0 +var _hips_rest: Vector3 = Vector3.ZERO +## Tween for the cartoon squash/stretch accent on jumps and heavy landings. +var _scale_tween: Tween # Combat overlay. var _combat: int = Combat.NONE @@ -85,7 +104,6 @@ var _blink_t: float = 0.0 var _gaze: Vector2 = Vector2.ZERO var _gaze_target: Vector2 = Vector2.ZERO var _saccade_cd: float = 1.2 -var _head_look: Vector2 = Vector2.ZERO # Cloak spring (lags Kern's motion so it swings and settles). var _cloak_swing: float = 0.0 @@ -153,6 +171,12 @@ func _ready() -> void: if _body != null: _prev_pos = _body.global_position + # Hand the rig to the shared animator. Ground probes look at layer 1 (the + # world) and must skip Kern's own capsule, or every step lands on himself. + _hips_rest = _skeleton.get_bone_rest(_bones["Hips"]).origin + if _body != null: + animator.bind(self, _body, _skeleton, _bones, BODY_HEIGHT, 1, + [_body.get_rid()] as Array[RID]) # The magic answers to the knowledge-charge meter (Combat v1 owns it). if EventBus.knowledge_charge_changed and not \ @@ -172,20 +196,26 @@ func _head_pivot() -> Vector3: return _skeleton.get_bone_global_rest(idx).origin -func _process(delta: float) -> void: +## Animation runs on the PHYSICS tick, not the render tick. +## +## Everything the locomotion reads is physics state: the body's velocity, its +## floor contact, the distance it actually moved, and the raycasts the feet are +## planted with. On the render tick those are stale by up to a frame, and — far +## worse — `_measure_travel()` sees zero displacement on render frames where +## physics did not tick, so on any machine rendering faster than 60 Hz the gait +## advanced in bursts and the legs stuttered. Driving it here keeps the gait, +## the ground probes and the body in exact lockstep. +func _physics_process(delta: float) -> void: if _body == null or _skeleton == null: return - var speed: float = Vector2(_body.velocity.x, _body.velocity.z).length() - _speed_smooth = lerpf(_speed_smooth, speed, 1.0 - exp(-8.0 * delta)) - var moving: float = smoothstep(0.12, 2.2, _speed_smooth) - _phase += delta * lerpf(2.6, 9.0, clampf(_speed_smooth / 7.5, 0.0, 1.0)) _idle_t += delta - var pose: Dictionary = {} - _locomotion(pose, moving) - _idle_life(pose, delta, moving) + # The shared animator owns locomotion, foot planting, idle life and emotes. + var pose: PoseStack = animator.tick(delta, _body.velocity, + _body.is_on_floor(), _crouch_amount()) + var moving: float = clampf(animator.speed_smooth / 2.2, 0.0, 1.0) - # Combat overlay on the right arm + sword. + # Combat overlay on the right arm + sword, on top of everything else. var want_combat: float = 1.0 if _combat != Combat.NONE else 0.0 _combat_blend = lerpf(_combat_blend, want_combat, 1.0 - exp(-16.0 * delta)) if _combat_blend > 0.001: @@ -197,6 +227,68 @@ func _process(delta: float) -> void: _drive_awaken(delta) +## How crouched the body is. Read from the controller when there is one so the +## capsule and the pose can never disagree; the character studio has no +## controller, hence the fallback. +func _crouch_amount() -> float: + if _body != null and _body.has_method("crouch_amount"): + return float(_body.call("crouch_amount")) + return 0.0 + + +# --- Public hooks the controller and dev tools drive ------------------------ + +## Absorb a landing of `impact_speed` m/s with the knees and pelvis. +func notify_landing(impact_speed: float) -> void: + animator.notify_landing(impact_speed) + # Keep the old visual squash as a light accent on top of the real absorption. + if impact_speed > 4.0: + _play_scale(Vector3(1.08, 0.90, 1.08)) + + +## Cartoon stretch accent on the take-off frame of a jump. +func notify_jump() -> void: + _play_scale(Vector3(0.94, 1.08, 0.94)) + + +## Start an emote by id (see `emote_library.gd`). Returns false if unknown. +func play_emote(id: String) -> bool: + return animator.emotes.play(id) + + +## Begin blending the current emote out. +func stop_emote() -> void: + animator.emotes.stop() + + +## True while an emote is pinning the player in place. +func emote_locks_movement() -> bool: + return animator.emotes.locks_movement() + + +## True while any emote owns part of the body. +func emote_active() -> bool: + return animator.emotes.is_active() + + +## Reset every continuous animation state after a teleport or respawn. +func teleported() -> void: + animator.teleported() + + +## Visual-only squash/stretch accent, retained from the original feel pass for +## jumps and heavy landings. The real weight now comes from the animator's +## pelvis absorption; this is the cartoon garnish on top. +func _play_scale(from_scale: Vector3) -> void: + if _scale_tween and _scale_tween.is_valid(): + _scale_tween.kill() + scale = from_scale + _scale_tween = create_tween() + _scale_tween.set_trans(Tween.TRANS_BACK) + _scale_tween.set_ease(Tween.EASE_OUT) + _scale_tween.tween_property(self, "scale", Vector3.ONE, 0.18) + + ## Ease the arcane glow toward its target and push it to every magical material. func _drive_awaken(delta: float) -> void: var target: float = awaken_override @@ -247,80 +339,18 @@ func _orbit_shards(delta: float) -> void: shard.scale = Vector3(s, s, s) -# --- Locomotion ------------------------------------------------------------- - -func _locomotion(pose: Dictionary, moving: float) -> void: - var s: float = sin(_phase) - var c: float = cos(_phase) - var leg_amp: float = 0.62 * moving - var arm_amp: float = 0.52 * moving - - # Legs: thighs swing opposite; knees flex on the lifting (rear) swing. - pose["ThighL"] = Vector3(s * leg_amp, 0.0, 0.0) - pose["ThighR"] = Vector3(-s * leg_amp, 0.0, 0.0) - pose["ShinL"] = Vector3(maxf(0.0, -s) * 1.05 * moving + 0.05, 0.0, 0.0) - pose["ShinR"] = Vector3(maxf(0.0, s) * 1.05 * moving + 0.05, 0.0, 0.0) - # Ankles keep the feet roughly level through the stride. - pose["FootL"] = Vector3(-s * 0.28 * moving, 0.0, 0.0) - pose["FootR"] = Vector3(s * 0.28 * moving, 0.0, 0.0) - - # Arms counter-swing to the legs (right arm yields to combat later). - pose["UpperArmL"] = _n("UpperArmL") + Vector3(-s * arm_amp, 0.0, 0.0) - pose["UpperArmR"] = _n("UpperArmR") + Vector3(s * arm_amp, 0.0, 0.0) - pose["ForearmL"] = _n("ForearmL") + Vector3(maxf(0.0, s) * 0.5 * moving, 0.0, 0.0) - pose["ForearmR"] = _n("ForearmR") + Vector3(maxf(0.0, -s) * 0.5 * moving, 0.0, 0.0) - - # Torso counter-rotation + bob; hips lead, chest trails (spinal delay). - pose["Hips"] = Vector3(0.02 * moving, -s * 0.10 * moving, c * 0.05 * moving) - pose["Spine"] = Vector3(0.03 * moving, s * 0.05 * moving, 0.0) - pose["Chest"] = Vector3(0.02 * moving, s * 0.10 * moving, 0.0) - # Head stays level against the shoulder counter-rotation. - pose["Neck"] = Vector3(-0.02 * moving, -s * 0.06 * moving, 0.0) - - # Airborne: tuck the legs a touch and lift the arms for balance. - if _body != null and not _body.is_on_floor(): - var air: float = clampf(-_body.velocity.y * 0.02 + 0.3, 0.0, 1.0) - pose["ThighL"] = Vector3(0.5 * air, 0.0, 0.05) - pose["ThighR"] = Vector3(0.35 * air, 0.0, -0.05) - pose["ShinL"] = Vector3(0.8 * air, 0.0, 0.0) - pose["ShinR"] = Vector3(0.6 * air, 0.0, 0.0) - pose["UpperArmL"] = _n("UpperArmL") + Vector3(-0.4 * air, 0.0, 0.15) - pose["UpperArmR"] = _n("UpperArmR") + Vector3(-0.4 * air, 0.0, -0.15) - - func _n(bone_name: String) -> Vector3: return NEUTRAL.get(bone_name, Vector3.ZERO) -# --- Idle life -------------------------------------------------------------- - -func _idle_life(pose: Dictionary, _delta: float, moving: float) -> void: - var calm: float = 1.0 - moving - # Breathing: chest rises/opens on a slow cycle when standing. - var breath: float = sin(_idle_t * 1.5) - var chest: Vector3 = pose.get("Chest", Vector3.ZERO) - pose["Chest"] = chest + Vector3(-breath * 0.02 * calm, 0.0, 0.0) - var spine: Vector3 = pose.get("Spine", Vector3.ZERO) - pose["Spine"] = spine + Vector3(breath * 0.012 * calm, 0.0, 0.0) - # Slow weight-shift from foot to foot while idle. - var shift: float = sin(_idle_t * 0.55) - var hips: Vector3 = pose.get("Hips", Vector3.ZERO) - pose["Hips"] = hips + Vector3(0.0, 0.0, shift * 0.05 * calm) - # Gentle idle sway of the arms so they never freeze solid. - var la: Vector3 = pose.get("UpperArmL", _n("UpperArmL")) - var ra: Vector3 = pose.get("UpperArmR", _n("UpperArmR")) - pose["UpperArmL"] = la + Vector3(sin(_idle_t * 1.1) * 0.02 * calm, 0.0, 0.0) - pose["UpperArmR"] = ra + Vector3(sin(_idle_t * 1.1 + 0.5) * 0.02 * calm, 0.0, 0.0) - # Head carriage: a slow living drift plus a look toward travel. - var neck: Vector3 = pose.get("Neck", Vector3.ZERO) - pose["Neck"] = neck + Vector3( - _head_look.y + sin(_idle_t * 0.7) * 0.03 * calm, - _head_look.x + sin(_idle_t * 0.43) * 0.05 * calm, 0.0) - - # --- Combat overlay --------------------------------------------------------- -func _apply_combat(pose: Dictionary) -> void: +## Choreograph the sword arm over whatever the animator produced. +## +## Still hand-authored rather than moved into the framework: a sword combo is +## Kern's, not every creature's, and it is the one layer that has to stay in +## exact sync with `player_combat.gd`'s hit windows. +func _apply_combat(pose: PoseStack) -> void: var arm: Vector3 var fore: Vector3 var clav: Vector3 = _n("ClavicleR") @@ -349,35 +379,52 @@ func _apply_combat(pose: Dictionary) -> void: fore = Vector3(0.25, 0.1, 0.0).lerp(_n("ForearmR"), e2) # Blend from whatever locomotion had the arm doing into the combat pose. var b: float = _combat_blend - pose["UpperArmR"] = (pose.get("UpperArmR", _n("UpperArmR")) as Vector3).lerp(arm, b) - pose["ForearmR"] = (pose.get("ForearmR", _n("ForearmR")) as Vector3).lerp(fore, b) - pose["ClavicleR"] = (pose.get("ClavicleR", _n("ClavicleR")) as Vector3).lerp(clav, b) + pose.blend_euler("UpperArmR", arm, b) + pose.blend_euler("ForearmR", fore, b) + pose.blend_euler("ClavicleR", clav, b) # A little whole-body commitment: torso twists into the swing. if _combat == Combat.ATTACK: var twist: float = sin(clampf(_attack_phase / 0.62, 0.0, 1.0) * PI) * 0.18 * b - var chest: Vector3 = pose.get("Chest", Vector3.ZERO) - pose["Chest"] = chest + Vector3(0.0, twist, 0.0) + pose.add_euler("Chest", Vector3(0.0, twist, 0.0)) # --- Commit ----------------------------------------------------------------- -func _commit(pose: Dictionary) -> void: +## Write the frame's pose onto both skeletons. +## +## Kern is the only character driving two rigs from one animation (his +## code-built skeleton, plus the imported base-mesh rig when `--kern-base` is +## on), which is why the commit lives here and not in the shared framework. +func _commit(pose: PoseStack) -> void: # Ensure every neutral-offset bone is written even if animation skipped it. for bone_name in NEUTRAL: - if not pose.has(bone_name): - pose[bone_name] = _n(bone_name) - for bone_name in pose: + if not pose.rotations.has(bone_name): + pose.set_euler(String(bone_name), _n(bone_name)) + + # The pelvis translation carries the whole body: terrain drop, crouch + # depth, gait bob and sway, landing absorption and emote hops all arrive as + # this one offset. It must be applied as a bone POSITION — the leg IK has + # already solved against it, so rotating instead would leave the feet + # solving for a pelvis the mesh is not at. + var hips_idx: int = _bones.get("Hips", -1) + if hips_idx >= 0: + _skeleton.set_bone_pose_position(hips_idx, _hips_rest + pose.root_offset) + + # Emote spins turn the rig itself rather than the controller's facing, so a + # dance can rotate without the character-controller fighting it back. + _skeleton.rotation.y = pose.root_spin + + for bone_name in pose.rotations: + var rotation: Quaternion = pose.rotations[bone_name] var idx: int = _bones.get(bone_name, -1) if idx >= 0: - _skeleton.set_bone_pose_rotation(idx, - Quaternion.from_euler(pose[bone_name])) + _skeleton.set_bone_pose_rotation(idx, rotation) # Same pose onto the imported skeleton, re-expressed per bone frame. # The rest fix (T-pose -> hanging arms) applies first, then the frame's # animation delta on top, both in model space. var rt: Dictionary = _base_retarget.get(bone_name, {}) if not rt.is_empty(): - var delta: Basis = Basis.from_euler(pose[bone_name]) \ - * (rt["fix"] as Basis) + var delta: Basis = Basis(rotation) * (rt["fix"] as Basis) var local_delta: Basis = (rt["g_inv"] as Basis) * delta \ * (rt["g"] as Basis) _base_skeleton.set_bone_pose_rotation(rt["idx"], @@ -483,8 +530,9 @@ func _reskin_garments_to_base() -> void: # below uses the bone pose the garment was authored around (arms hanging), # not the T-pose rest — otherwise the runtime pose carries each garment # through the T-to-hang delta a second time (sleeves stick out sideways). - var neutral: Dictionary = {} - _locomotion(neutral, 0.0) + var neutral: PoseStack = PoseStack.new() + for bone_name in NEUTRAL: + neutral.set_euler(String(bone_name), _n(bone_name)) _commit(neutral) var moved: int = 0 for garment_name in RESKIN_GARMENTS: @@ -611,14 +659,9 @@ func _animate_head_extras(delta: float, moving: float) -> void: _gaze = _gaze.lerp(_gaze_target, 1.0 - exp(-22.0 * delta)) var jitter: Vector2 = Vector2(sin(_idle_t * 31.0), cos(_idle_t * 27.0)) * 0.006 _head.set_gaze(_gaze.x + jitter.x, _gaze.y + jitter.y) - - # Look slightly toward travel direction (anticipation). - var look_target: Vector2 = Vector2.ZERO - if moving > 0.1 and _body != null: - var lf: Vector3 = global_transform.basis.inverse() * Vector3( - _body.velocity.x, 0.0, _body.velocity.z) - look_target = Vector2(clampf(-lf.x * 0.03, -0.18, 0.18), 0.0) - _head_look = _head_look.lerp(look_target, 1.0 - exp(-6.0 * delta)) + # The neck's own turn toward travel now comes from the animator's + # anticipation term, so nothing more is needed here — the eyes lead, the + # neck follows, which is the order a real head does it in. # --- Combat pose API (unchanged signatures; PlayerCombat drives these) ------- diff --git a/game/src/player/player.gd b/game/src/player/player.gd index 91306aa..ffe47fa 100644 --- a/game/src/player/player.gd +++ b/game/src/player/player.gd @@ -1,44 +1,97 @@ class_name Player extends CharacterBody3D -## Kern's third-person character controller — Phase 1 milestone 2. +## Kern's third-person character controller — Phase 1 milestone 2, rebuilt in +## the movement pass. +## +## **Feel targets.** Analog speed with no gait thresholds (the stick's +## deflection IS the speed, and walk/jog/run/sprint blend continuously), +## responsive starts, no ice on stop, a BOTW-ish jump arc (floatier rise, +## heavier fall, early-release cut), coyote time and a jump buffer so hops are +## never stolen, camera-relative movement, turning that costs speed the way +## real momentum does, slope-aware pace, and step-up over small ledges. +## +## **What moved out.** The old version faked weight with a squash/stretch tween +## on the visual. The body's actual behaviour — landing absorption, crouch +## depth, momentum lean — now lives in `creature_animator.gd` and is driven by +## real physics state, so this script's job is only to decide where the capsule +## goes. It reports events (`notify_landing`) rather than posing anything. ## -## Feel targets (the "feel pass"): responsive starts (high ground accel), -## no ice on stop (decel above accel), a BOTW-ish jump arc (floatier rise, -## heavier fall, early-release cut), coyote time + a jump buffer so hops -## never feel stolen, camera-relative movement, the body turning smoothly -## toward travel direction, and a visual-only squash/stretch on jump/land. ## All tunables are consts up top — the numbers ARE the feel pass. -const WALK_SPEED: float = 4.0 -const RUN_SPEED: float = 7.5 -const GROUND_ACCEL: float = 30.0 -const GROUND_DECEL: float = 42.0 -const AIR_ACCEL: float = 12.0 -const JUMP_VELOCITY: float = 8.5 # ~1.5 m apex with RISE_GRAVITY +## Ground speeds, m/s. The controller interpolates freely between them rather +## than snapping between modes, so there is no walk/run threshold to feel. +const WALK_SPEED: float = 1.65 +const JOG_SPEED: float = 3.60 +const RUN_SPEED: float = 5.60 +const SPRINT_SPEED: float = 7.60 +const CROUCH_SPEED: float = 1.70 + +## Acceleration is expressed as the time to reach top speed, which keeps the +## feel identical when the speeds are retuned. +const GROUND_ACCEL: float = 26.0 +const GROUND_DECEL: float = 34.0 +const AIR_ACCEL: float = 11.0 +## Braking when the stick points against current travel — a hard cut, so +## reversing is crisp instead of a long skid. +const TURN_BRAKE: float = 46.0 + +const JUMP_VELOCITY: float = 7.4 # ~1.15 m apex with RISE_GRAVITY const RISE_GRAVITY: float = 24.0 const FALL_GRAVITY: float = 34.0 const MAX_FALL_SPEED: float = 40.0 const JUMP_CUT_FACTOR: float = 0.45 # early release trims the arc const COYOTE_TIME: float = 0.12 const JUMP_BUFFER: float = 0.15 -const TURN_SPEED: float = 12.0 + +## Turning. Rate falls off with speed: a sprint carves, a walk pivots. +const TURN_SPEED_STILL: float = 16.0 +const TURN_SPEED_FAST: float = 6.5 +## How much of top speed a hard direction change costs, 0..1. Momentum should +## be spent to change heading, or the character reads as weightless. +const TURN_SPEED_COST: float = 0.42 + +## Slopes. Climbing costs pace, descending gives a little back. +const SLOPE_UPHILL_PENALTY: float = 0.55 # fraction lost at max walkable angle +const SLOPE_DOWNHILL_BONUS: float = 0.16 +const MAX_CLIMB_ANGLE: float = 0.87 # ~50 degrees; matches floor_max_angle + +## Steps up to this height are climbed without a jump. +const STEP_HEIGHT: float = 0.42 + +## Crouch blend rate, per second. +const CROUCH_RATE: float = 6.0 +## Standing and crouched capsule heights, metres. +const STAND_HEIGHT: float = 1.70 +const CROUCH_HEIGHT: float = 1.05 + const SQUASH_MIN_AIR_TIME: float = 0.2 # no squash for curb-sized drops const KNOCKBACK_DECAY: float = 7.0 -const DOWNED_TIME: float = 1.4 # come-apart → reform beat +const DOWNED_TIME: float = 1.4 # come-apart -> reform beat const REFORM_IFRAMES: float = 1.6 var _coyote_left: float = 0.0 var _jump_buffer_left: float = 0.0 var _air_time: float = 0.0 var _was_on_floor: bool = true -var _scale_tween: Tween var _knockback: Vector3 = Vector3.ZERO var _downed: bool = false +## 0 standing, 1 fully crouched. Blended, so half-crouch is a real state. +var _crouch: float = 0.0 +## True while something overhead prevents standing back up. +var _crouch_blocked: bool = false +## Downward speed on the frame of the last landing, m/s — handed to the +## animator so the knees absorb proportionally. +var _land_impact: float = 0.0 + +## The radial emote picker, built on first use. +var _emote_wheel: EmoteWheel + @onready var _visual: Node3D = $Visual @onready var _rig: CameraRig = $CameraRig @onready var _health: Health = $Health @onready var _combat: PlayerCombat = $Combat +@onready var _collider: CollisionShape3D = $CollisionShape3D func _ready() -> void: @@ -51,6 +104,12 @@ func _ready() -> void: _health.changed.connect(_on_health_changed) _health.died.connect(_on_health_died) _combat.setup(self, _visual, _rig, _health) + # Let the body ride up small ledges instead of stopping dead on them. Godot + # resolves this inside move_and_slide, so it costs nothing per frame and + # removes the single most common "the world snagged me" complaint. + floor_max_angle = MAX_CLIMB_ANGLE + floor_snap_length = STEP_HEIGHT + floor_block_on_wall = false EventBus.player_spawned.emit(self) @@ -63,18 +122,26 @@ func _on_health_changed(current: float, max_hearts: float) -> void: EventBus.player_hearts_changed.emit(current, max_hearts) +## How crouched the body is, 0..1 — read by the animator every frame. +func crouch_amount() -> float: + return _crouch + + func _physics_process(delta: float) -> void: # The grass field parts around Kern (grass_field.gdshader trample). RenderingServer.global_shader_parameter_set(&"gf_player_pos", global_position) if _downed: _apply_gravity(delta) - var h: Vector2 = Vector2(velocity.x, velocity.z).move_toward(Vector2.ZERO, GROUND_DECEL * delta) + var h: Vector2 = Vector2(velocity.x, velocity.z).move_toward( + Vector2.ZERO, GROUND_DECEL * delta) velocity.x = h.x velocity.z = h.y move_and_slide() return _combat.tick(delta) + _tick_emotes() _tick_timers(delta) + _tick_crouch(delta) _apply_gravity(delta) if not _combat.blocks_jump(): _handle_jump() @@ -105,6 +172,38 @@ func _tick_timers(delta: float) -> void: _jump_buffer_left = JUMP_BUFFER +## Blend the crouch, resize the capsule, and refuse to stand up under a ceiling. +## +## The capsule is shortened from the TOP (its centre drops by half the height +## lost) so crouching never pushes the feet through the floor — resizing about +## the centre is the classic way a crouch ends up levitating the character. +func _tick_crouch(delta: float) -> void: + var wants: bool = Input.is_action_pressed(&"crouch") and is_on_floor() + _crouch_blocked = false + if not wants and _crouch > 0.01 and _ceiling_blocked(): + # Held down by geometry: stay crouched rather than clipping through it. + wants = true + _crouch_blocked = true + _crouch = move_toward(_crouch, 1.0 if wants else 0.0, CROUCH_RATE * delta) + + var capsule: CapsuleShape3D = _collider.shape as CapsuleShape3D + if capsule != null: + var height: float = lerpf(STAND_HEIGHT, CROUCH_HEIGHT, _crouch) + capsule.height = height + _collider.position.y = height * 0.5 + + +## True if there is not enough headroom to stand back up. +func _ceiling_blocked() -> bool: + var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state + var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create( + global_position + Vector3.UP * 0.2, + global_position + Vector3.UP * (STAND_HEIGHT + 0.12)) + query.collision_mask = collision_mask + query.exclude = [get_rid()] + return not space.intersect_ray(query).is_empty() + + func _apply_gravity(delta: float) -> void: if is_on_floor(): return @@ -114,14 +213,17 @@ func _apply_gravity(delta: float) -> void: func _handle_jump() -> void: if _jump_buffer_left > 0.0 and _coyote_left > 0.0: - velocity.y = JUMP_VELOCITY + # Crouched jumps are shorter — the legs are already loaded. + velocity.y = JUMP_VELOCITY * lerpf(1.0, 0.78, _crouch) _jump_buffer_left = 0.0 _coyote_left = 0.0 - _play_scale(Vector3(0.92, 1.1, 0.92)) + if _visual.has_method("notify_jump"): + _visual.call("notify_jump") if velocity.y > 0.0 and Input.is_action_just_released(&"jump"): velocity.y *= JUMP_CUT_FACTOR +## Decide this frame's horizontal velocity and facing. func _handle_move(delta: float) -> void: # A dodge roll drives velocity directly; skip normal steering this frame. if _combat.use_velocity_override: @@ -131,6 +233,14 @@ func _handle_move(delta: float) -> void: _face_yaw(_combat.facing_yaw, delta) return + # An emote that locks movement pins the body but still lets gravity run. + if _emote_locks_movement(): + var damped: Vector2 = Vector2(velocity.x, velocity.z).move_toward( + Vector2.ZERO, GROUND_DECEL * delta) + velocity.x = damped.x + velocity.z = damped.y + return + var input_vec: Vector2 = Input.get_vector( &"move_left", &"move_right", &"move_forward", &"move_back" ) @@ -142,17 +252,35 @@ func _handle_move(delta: float) -> void: right.y = 0.0 right = right.normalized() var dir: Vector3 = right * input_vec.x - forward * input_vec.y - if dir.length_squared() > 1.0: - dir = dir.normalized() + var input_strength: float = clampf(dir.length(), 0.0, 1.0) + if input_strength > 0.0001: + dir = dir / input_strength + + var top_speed: float = _target_speed(input_strength) * _combat.move_scale + top_speed *= _slope_factor(dir) - # Combat trims the top speed (0 mid-swing, a crawl while guarding). - var base_speed: float = RUN_SPEED if Input.is_action_pressed(&"sprint") else WALK_SPEED - var top_speed: float = base_speed * _combat.move_scale - var target: Vector2 = Vector2(dir.x, dir.z) * top_speed var horizontal: Vector2 = Vector2(velocity.x, velocity.z) - var accel: float = GROUND_ACCEL if is_on_floor() else AIR_ACCEL - if is_on_floor() and target.length_squared() < horizontal.length_squared(): - accel = GROUND_DECEL + var target: Vector2 = Vector2(dir.x, dir.z) * top_speed + + # Turning costs momentum: the sharper the direction change, the more speed + # is scrubbed. Without this a character can reverse at full pace, which + # reads as frictionless no matter how good the animation is. + if is_on_floor() and horizontal.length() > 0.5 and input_strength > 0.1: + var alignment: float = horizontal.normalized().dot(target.normalized()) + if alignment < 0.999: + var cost: float = (1.0 - alignment) * 0.5 * TURN_SPEED_COST + target *= 1.0 - cost + + var accel: float = AIR_ACCEL + if is_on_floor(): + if input_strength < 0.05: + accel = GROUND_DECEL + elif target.dot(horizontal) < 0.0: + accel = TURN_BRAKE + elif target.length_squared() < horizontal.length_squared(): + accel = GROUND_DECEL + else: + accel = GROUND_ACCEL horizontal = horizontal.move_toward(target, accel * delta) velocity.x = horizontal.x velocity.z = horizontal.y @@ -160,30 +288,107 @@ func _handle_move(delta: float) -> void: if _combat.lock_facing: # Face the swing/guard/aim direction chosen by combat. _face_yaw(_combat.facing_yaw, delta, 1.4) - elif dir.length_squared() > 0.0001: + elif input_strength > 0.02: # Model forward is -Z (Godot convention), hence the negations. _face_yaw(atan2(-dir.x, -dir.z), delta) +## Top speed for the current input strength and modifiers. +## +## Stick deflection maps CONTINUOUSLY onto the speed ladder, so a gentle push is +## a genuine walk rather than a walk-speed cap the animation has to pretend +## about. Sprint extends the ladder's top rather than replacing it. +func _target_speed(input_strength: float) -> float: + if _crouch > 0.5: + return CROUCH_SPEED * input_strength + if Input.is_action_pressed(&"walk"): + return WALK_SPEED * input_strength + var ceiling: float = SPRINT_SPEED if Input.is_action_pressed(&"sprint") \ + else RUN_SPEED + # Below half deflection the ladder runs walk -> jog; above it, jog -> ceiling. + if input_strength <= 0.5: + return lerpf(0.0, JOG_SPEED, input_strength * 2.0) + return lerpf(JOG_SPEED, ceiling, (input_strength - 0.5) * 2.0) + + +## Pace multiplier for the slope being walked into. +## +## Uses the dot of travel direction against the floor normal, so it responds to +## the slope actually being CLIMBED rather than to the ground's steepness in +## the abstract — traversing a hillside sideways correctly costs nothing. +func _slope_factor(direction: Vector3) -> float: + if not is_on_floor() or direction.length_squared() < 0.0001: + return 1.0 + var normal: Vector3 = get_floor_normal() + # Positive when moving uphill, negative downhill. + var climb: float = -direction.normalized().dot(normal) + var steepness: float = clampf(acos(clampf(normal.y, -1.0, 1.0)) + / MAX_CLIMB_ANGLE, 0.0, 1.0) + if climb > 0.0: + return 1.0 - SLOPE_UPHILL_PENALTY * steepness * climb + return 1.0 + SLOPE_DOWNHILL_BONUS * steepness * -climb + + +## Turn toward `yaw`, faster when slow and slower when fast, frame-rate +## independently. A sprinting character that can pivot instantly reads as +## having no mass; one that always turns slowly is infuriating to steer. func _face_yaw(yaw: float, delta: float, rate_mul: float = 1.0) -> void: - _visual.rotation.y = lerp_angle( - _visual.rotation.y, yaw, minf(1.0, TURN_SPEED * rate_mul * delta) - ) + var speed: float = Vector2(velocity.x, velocity.z).length() + var rate: float = lerpf(TURN_SPEED_STILL, TURN_SPEED_FAST, + clampf(speed / SPRINT_SPEED, 0.0, 1.0)) * rate_mul + var difference: float = wrapf(yaw - _visual.rotation.y, -PI, PI) + _visual.rotation.y += difference * (1.0 - exp(-rate * delta)) +## Detect touchdown and hand the impact to the animator. func _handle_landing() -> void: - if is_on_floor() and not _was_on_floor and _air_time > SQUASH_MIN_AIR_TIME: - _play_scale(Vector3(1.12, 0.85, 1.12)) + if is_on_floor() and not _was_on_floor: + if _air_time > SQUASH_MIN_AIR_TIME and _visual.has_method("notify_landing"): + _visual.call("notify_landing", absf(_land_impact)) + if not is_on_floor(): + _land_impact = velocity.y + + +## Open the emote wheel while the key is held, perform on release, and cancel a +## running emote the moment the player asks to move again. +## +## Cancel-on-input is the rule that keeps emotes from ever feeling like a trap: +## whatever Kern is in the middle of, a nudge of the stick returns control +## immediately and the emote blends out rather than having to finish. +func _tick_emotes() -> void: + if _emote_wheel == null: + _emote_wheel = EmoteWheel.build(self) + _emote_wheel.chosen.connect(_on_emote_chosen) + + if Input.is_action_just_pressed(&"emote_wheel"): + _emote_wheel.open() + elif Input.is_action_just_released(&"emote_wheel"): + _emote_wheel.close() + + if _emote_wheel.is_open(): + return + # Any movement, jump or combat intent releases a playing emote. + var wants_out: bool = Input.get_vector(&"move_left", &"move_right", + &"move_forward", &"move_back").length() > 0.15 \ + or Input.is_action_just_pressed(&"jump") \ + or Input.is_action_just_pressed(&"attack") \ + or Input.is_action_just_pressed(&"dodge") + if wants_out and _visual.has_method("emote_active") \ + and bool(_visual.call("emote_active")): + _visual.call("stop_emote") + + +func _on_emote_chosen(emote_id: String) -> void: + if emote_id == "" or _visual == null: + return + if _visual.has_method("play_emote"): + _visual.call("play_emote", emote_id) -func _play_scale(from_scale: Vector3) -> void: - if _scale_tween and _scale_tween.is_valid(): - _scale_tween.kill() - _visual.scale = from_scale - _scale_tween = create_tween() - _scale_tween.set_trans(Tween.TRANS_BACK) - _scale_tween.set_ease(Tween.EASE_OUT) - _scale_tween.tween_property(_visual, "scale", Vector3.ONE, 0.18) +## True while an emote is holding the player in place. +func _emote_locks_movement() -> bool: + return _visual != null and _visual.has_method("emote_locks_movement") \ + and bool(_visual.call("emote_locks_movement")) # --- Taking damage (group "hittable"; called by enemy melee & projectiles) --- @@ -226,4 +431,6 @@ func _reform() -> void: _visual.visible = true _visual.scale = Vector3.ONE _downed = false + if _visual.has_method("teleported"): + _visual.call("teleported") EventBus.player_reformed.emit() diff --git a/game/src/ui/emote_wheel.gd b/game/src/ui/emote_wheel.gd new file mode 100644 index 0000000..4e6ec13 --- /dev/null +++ b/game/src/ui/emote_wheel.gd @@ -0,0 +1,157 @@ +class_name EmoteWheel +extends CanvasLayer +## The radial emote picker: hold the emote key to open it, aim with the stick or +## mouse, release to perform. +## +## **Why a wheel and not a menu.** Emotes are social, spontaneous and used mid- +## play, so the interaction has to survive being done without looking. A radial +## selector is muscle-memorable — the third dance is always up-and-right — and +## the hold-aim-release shape means an emote costs one gesture rather than a +## menu round trip. Everything is drawn in code, per the project's +## generated-assets rule (CLAUDE.md § Conventions). +## +## **Time does not stop.** The wheel is deliberately non-modal: the world keeps +## running behind it, because pausing for a dance would make emoting feel like +## opening the inventory instead of like an expressive act. +## +## Architecture: pure presentation. It reads `EmoteLibrary.defs()` for its +## contents and emits `chosen` with an emote id; `player.gd` owns the input hold +## and hands the result to `KernVisual.play_emote()`. Nothing here touches the +## rig. See `docs/ARCHITECTURE.md`. + +## Emitted on release with the highlighted emote's id, or "" if none. +signal chosen(emote_id: String) + +## Radius of the ring of options, pixels. +const RADIUS: float = 168.0 + +## Dead zone at the centre, in pixels for the mouse and 0..1 for a stick, inside +## which nothing is selected — so opening and releasing without aiming cancels. +const DEAD_ZONE_PIXELS: float = 46.0 +const DEAD_ZONE_STICK: float = 0.35 + +const BACKDROP: Color = Color(0.04, 0.05, 0.08, 0.55) +const SLICE_IDLE: Color = Color(0.16, 0.19, 0.26, 0.82) +const SLICE_HOT: Color = Color(0.36, 0.62, 0.95, 0.95) +const TEXT_IDLE: Color = Color(0.80, 0.85, 0.93) +const TEXT_HOT: Color = Color(1.0, 1.0, 1.0) +const DANCE_TINT: Color = Color(0.95, 0.72, 0.38) + +var _root: Control +var _defs: Array = [] +var _selected: int = -1 +var _open: bool = false +## Aim direction while a gamepad is driving, kept between frames because a stick +## returned to centre should HOLD the last choice rather than deselect — letting +## go of the stick to press the button must not cancel the pick. +var _stick_aim: Vector2 = Vector2.ZERO + + +## Build the wheel under `parent` and return it, hidden. +static func build(parent: Node) -> EmoteWheel: + var wheel: EmoteWheel = EmoteWheel.new() + wheel.name = "EmoteWheel" + parent.add_child(wheel) + return wheel + + +func _ready() -> void: + layer = 12 + _defs = EmoteLibrary.defs() + _root = Control.new() + _root.name = "WheelRoot" + _root.set_anchors_preset(Control.PRESET_FULL_RECT) + _root.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_root) + _root.draw.connect(_on_draw) + visible = false + + +## Show the wheel and start tracking aim. +func open() -> void: + if _open: + return + _open = true + _selected = -1 + _stick_aim = Vector2.ZERO + visible = true + _root.queue_redraw() + + +## Hide the wheel and emit whatever was highlighted. +func close() -> void: + if not _open: + return + _open = false + visible = false + var id: String = "" + if _selected >= 0 and _selected < _defs.size(): + id = (_defs[_selected] as EmoteLibrary.EmoteDef).id + chosen.emit(id) + + +## True while the wheel is showing. +func is_open() -> bool: + return _open + + +func _process(_delta: float) -> void: + if not _open: + return + _update_selection() + _root.queue_redraw() + + +## Work out which slice is aimed at, from either stick or mouse. +func _update_selection() -> void: + var stick: Vector2 = Input.get_vector(&"cam_left", &"cam_right", + &"cam_up", &"cam_down") + var aim: Vector2 = Vector2.ZERO + if stick.length() > DEAD_ZONE_STICK: + _stick_aim = stick + if _stick_aim.length() > DEAD_ZONE_STICK: + # Stick Y is inverted relative to screen space. + aim = Vector2(_stick_aim.x, _stick_aim.y) + else: + var centre: Vector2 = _root.size * 0.5 + var offset: Vector2 = _root.get_local_mouse_position() - centre + if offset.length() > DEAD_ZONE_PIXELS: + aim = offset + + if aim.length() < 0.001: + _selected = -1 + return + # Slice 0 sits at the top and they run clockwise. + var angle: float = fposmod(atan2(aim.x, -aim.y), TAU) + var count: int = _defs.size() + _selected = int(floor(angle / TAU * float(count) + 0.5)) % count + + +func _on_draw() -> void: + var centre: Vector2 = _root.size * 0.5 + _root.draw_rect(Rect2(Vector2.ZERO, _root.size), BACKDROP) + var count: int = _defs.size() + var font: Font = ThemeDB.fallback_font + for i in count: + var def: EmoteLibrary.EmoteDef = _defs[i] + var angle: float = TAU * float(i) / float(count) + var dir: Vector2 = Vector2(sin(angle), -cos(angle)) + var at: Vector2 = centre + dir * RADIUS + var hot: bool = i == _selected + var fill: Color = SLICE_HOT if hot else SLICE_IDLE + if def.category == "dance" and not hot: + fill = SLICE_IDLE.lerp(DANCE_TINT, 0.22) + _root.draw_circle(at, 34.0 if hot else 29.0, fill) + var label: String = def.display_name + var width: float = font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, + -1, 14).x + _root.draw_string(font, at + Vector2(-width * 0.5, 52.0), label, + HORIZONTAL_ALIGNMENT_LEFT, -1, 14, + TEXT_HOT if hot else TEXT_IDLE) + var hint: String = "aim and release" + if _selected >= 0: + hint = (_defs[_selected] as EmoteLibrary.EmoteDef).display_name + var hint_width: float = font.get_string_size(hint, HORIZONTAL_ALIGNMENT_LEFT, + -1, 18).x + _root.draw_string(font, centre + Vector2(-hint_width * 0.5, 6.0), hint, + HORIZONTAL_ALIGNMENT_LEFT, -1, 18, TEXT_HOT) diff --git a/game/src/ui/emote_wheel.gd.uid b/game/src/ui/emote_wheel.gd.uid new file mode 100644 index 0000000..ffd8dbb --- /dev/null +++ b/game/src/ui/emote_wheel.gd.uid @@ -0,0 +1 @@ +uid://bvv5lmdhsibrg