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