@tool
class_name ProceduralAnimator extends SkeletonModifier3D
## Overgrowth-style procedural animation system.
##
## Full pipeline (matches aschar.as line 14109-14610):
## 1. Write pose to skeleton bones
## 2. Extract 7 key_transforms from posed skeleton
## 3. ChestIK — spring-damper rotation on key_transforms[kChestKey]
## 4. HeadIK — spring-damper rotation on key_transforms[kHeadKey]
## 5. COM balance — compensate horizontal shift from look rotation
## 6. FootIK — raycasts, ground offsets on key_transforms[kLeftLegKey/kRightLegKey]
## 7. HipIK — lower hips, tilt pelvis
## 8. Final COM — compensate in air
## 9. Chain solvers — DrawLeg, DrawBody, DrawHead, DrawArms
## (write key_transforms back to actual bones via FABRIK)
const PoseLibraryClass := preload("res://scenes/player/procedural/pose_library.gd")
const FootIKClass := preload("res://scenes/player/procedural/foot_ik.gd")
const ChestIKClass := preload("res://scenes/player/procedural/chest_ik.gd")
const HeadIKClass := preload("res://scenes/player/procedural/head_ik.gd")
const TorsoCounterRollClass := preload("res://scenes/player/procedural/torso_counter_roll.gd")
const ChainSolverClass := preload("res://scenes/player/procedural/chain_solver.gd")
const FlipRollClass := preload("res://scenes/player/procedural/flip_roll.gd")
const ArmPhysicsClass := preload("res://scenes/player/procedural/arm_physics.gd")
const LegPhysicsClass := preload("res://scenes/player/procedural/leg_physics.gd")
const TwoBoneIKClass := preload("res://scenes/creatures/two_bone_ik.gd")
# ---- Resources ----
@export var pose_lib: Resource: set = _set_pose_lib
# ---- Locomotion Config ----
@export_group("Locomotion")
@export var walk_stride_length: float = 4.0
@export var run_stride_length: float = 6.0
@export var walk_speed_threshold: float = 0.6
@export var run_speed_threshold: float = 5.0
# ---- IK Systems ----
@export_group("IK")
@export var enable_foot_ik: bool = true
@export var enable_chest_ik: bool = true
@export var enable_head_ik: bool = true
## DrawLeg: keep the feet on foot IK's keys when the pelvis moves (idle sway, look
## balance). Was false "bone axis assumption needs fixing" -- the axis was fine, the
## joints were read one bone too high; ChainSolver.solve_leg_rigify is the fix.
@export var enable_chain_solvers: bool = true
# ---- State (fed by player controller) ----
var horizontal_speed: float = 0.0
var vertical_velocity: float = 0.0
var velocity: Vector3 = Vector3.ZERO ## Full 3D velocity (for acceleration tilt)
var is_grounded: bool = true
var is_jumping: bool = false
var duck_amount: float = 0.0
var facing_direction: Vector3 = Vector3.FORWARD ## Model forward (for idle look)
var wall_run_active: bool = false
var wall_run_normal: Vector3 = Vector3.ZERO
var model_up: Vector3 = Vector3.UP ## the MODEL node's up, for the torso counter-roll
var local_up: Vector3 = Vector3.UP ## Override for spherical gravity
var on_ledge: bool = false
var ledge_normal: Vector3 = Vector3.ZERO
var is_climbing_ledge: bool = false
var is_pre_jumping: bool = false
## STEP HOP (docs/player-step-up-design.md §4): crouch on the LEAD leg, push, land on the
## step. Fed by the controller (derived for remote players); STEP_HOP replaces
## JUMP/FALL/LAND for it, so hopping up a flight never plays a fall.
var is_step_hopping: bool = false
var step_crouching: bool = false
var step_lead: int = 0 # 0 = left leg leads, 1 = right
var is_wall_climbing: bool = false
var climb_grip_positions: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO]
var climb_grip_weights: Array[float] = [1.0, 1.0, 1.0, 1.0]
var climb_grip_normals: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO]
var climb_wall_normal: Vector3 = Vector3.ZERO
var is_grappling: bool = false
var grapple_anchor_direction: Vector3 = Vector3.ZERO ## Direction from player to anchor
var grapple_swing_speed: float = 0.0
var aim_direction: Vector3 = Vector3.ZERO ## Camera aim direction (for arm IK when not grappling)
var surface_up: Vector3 = Vector3.UP ## Current surface up from controller (for wall climb)
## The BODY's smoothed contact normal, from the controller. FootIK's swing
## clearance needs the slope of the ground the character is walking on, and the
## swing foot -- the one that needs clearing -- has no contact to read it from.
var ground_normal: Vector3 = Vector3.UP
## 1 while the ski key is held, 0 otherwise, smoothed here rather than in the
## controller so the pose eases and the physics does not.
var ski_holding: bool = false
var ski_amount: float = 0.0
# ---- Hip Drop (Overgrowth: duck_amount → physical hip offset, line 8458) ----
var hip_drop_max: float = 0.45 ## Max hip drop at duck_amount=1.0 (meters)
# ---- Climb Undulation (lizard body wave) ----
var _climb_wave_time: float = 0.0
const CLIMB_WAVE_AMPLITUDE: float = 0.12 ## ~7 degrees lateral wave
const CLIMB_WAVE_FREQUENCY: float = 3.0 ## Cycles per unit speed
const CLIMB_WAVE_SPINE_PHASE: float = 0.7 ## Phase offset between spine segments
# ---- Transition speeds (crossfade) ----
var fade_speed_idle: float = 6.0
var fade_speed_walk: float = 8.0
var fade_speed_jump: float = 10.0
var fade_speed_land: float = 12.0
var fade_speed_roll: float = 14.0
var fade_speed_fall: float = 12.0
var fade_speed_wall_run: float = 10.0
var fade_speed_ledge: float = 8.0
# ---- Breathing (Overgrowth Breathe() lines 2322-2350) ----
var _breath_time: float = 0.0
var _breath_speed: float = 0.9
var _breath_amount: float = 0.0
# ---- Idle weight shift (Overgrowth lines 14408-14447) ----
var _idle_stance_amount: float = 0.0 ## 0=moving, 1=standing still
var _idle_time: float = 0.0
var _com_offset: Vector3 = Vector3.ZERO
var _target_com_offset: Vector3 = Vector3.ZERO
var _com_offset_vel: Vector3 = Vector3.ZERO
var _com_shift_timer: float = 0.0
# ---- Idle look around (Overgrowth UpdateEyeLookTarget lines 4247-4313) ----
var _random_look_dir: Vector3 = Vector3.ZERO
var _random_look_delay: float = 1.0
# ---- Acceleration Tilt (Overgrowth: HandleAccelTilt, lines 7920-7938) ----
## Two components: velocity-based tilt + acceleration tilt.
## Applied as rotation of ALL bones around COM (like flip/roll).
## tilt_axis = cross(UP, tilt_direction), angle = length(tilt) / 180 * PI
const TILT_INERTIA_GROUND: float = 0.9 ## Overgrowth exact
const TILT_INERTIA_AIR: float = 0.95 ## Overgrowth exact
const ACCEL_TILT_INERTIA: float = 0.95 ## Overgrowth: pow(0.95, frames) for accel blend
const ACCEL_TILT_DECAY: float = 0.8 ## Overgrowth: pow(0.8, frames) when not moving
## THE HARD CEILING ON BODY LEAN, IN DEGREES.
## _tilt's magnitude IS the rotation applied to every bone about the COM: the
## angle is tilt_length/180*PI radians, so |_tilt| == degrees. Nothing bounded it.
##
## `accel = (velocity - _old_velocity) * 120.0 / frames` with frames ~= 1, so any
## single-frame velocity change is multiplied by 120 -- a landing or a wall impact
## injects hundreds. MEASURED in play (F10 dump, /tmp/hypertps-diag.log):
## f=700 falling |tilt| = 994.24 (2.8 full turns)
## f=737 just landed |tilt| = 936.06
## f=1161 mid wall run |tilt| = 836.41
## f=1232 STANDING, hspd=0 |tilt| = 224.33
## and _accel_tilt decays slowly, so one impact leaves the character rotated for
## seconds afterwards. That is the reported spin: the MODEL node stays upright
## (modeltilt 2.6 in the same frames) while the BONES are turned hundreds of
## degrees, which is why every model-space gate read it as fine.
##
## 20 degrees is a lean. Overgrowth's own numbers are reproduced faithfully above
## and the divergence is in units, not in the formula, so the ceiling goes here
## rather than in the constants.
const MAX_TILT_DEG: float = 20.0
## How far the PELVIS may end up from the body's own up, in degrees, after every
## other system has had its say. 0 disables the guard. See Phase 12.
@export var max_pelvis_deviation_deg: float = 35.0
## WALL HUG. During a wall run, bring the pelvis and the upper torso closer to
## the wall, as a fraction of the capsule radius (0.3 m) -- so 0.2 is 6 cm each.
## A wall runner is not a standing figure translated sideways; the mass leads and
## the body is nearer the surface than the feet alone would put it.
@export_range(0.0, 1.0, 0.05) var wall_hug_hip: float = 0.2
@export_range(0.0, 1.0, 0.05) var wall_hug_chest: float = 0.2
const WALL_HUG_REFERENCE := 0.3 ## capsule radius, player_procedural.tscn
## How much of FootIK's planting authority survives on a WALL. At 1.0 the feet are
## pinned to the wall plane and whichever locomotion cycle is playing is flattened
## into a shuffle -- measured 0.68 m of along-wall foot travel against a run
## stride of 1.5-2 m. Below 1.0 the cycle shows through at the cost of the feet
## tracking the surface less exactly.
@export_range(0.0, 1.0, 0.05) var wall_foot_plant_weight: float = 0.45
## How far the ARMS go toward the fall pose during a wall run. The legs keep the
## run cycle regardless; this only touches the arm chain.
@export_range(0.0, 1.0, 0.05) var wall_arm_trail: float = 0.75
const ARM_BONES: Array[String] = [
"DEF-shoulder.L", "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-shoulder.R", "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
]
var _tilt: Vector3 = Vector3.ZERO ## Current tilt (smoothed)
var _target_tilt: Vector3 = Vector3.ZERO ## Target tilt
var _accel_tilt: Vector3 = Vector3.ZERO ## Acceleration component
var _old_velocity: Vector3 = Vector3.ZERO ## Previous frame velocity for accel calc
# ---- Internal Animation State ----
var _cycle_phase: float = 0.0
@warning_ignore("unused_private_class_variable")
var _distance_traveled: float = 0.0 # Used for future stride sync
# Crossfade
var _active_pose: Dictionary = {}
var _fading_poses: Array = []
# State tracking
enum AnimState { IDLE, WALK, RUN, JUMP, FALL, LAND, ROLL, WALL_RUN, LEDGE_HANG, LEDGE_CLIMB, WALL_CLIMB, GRAPPLE, STEP_HOP }
## The stride at the moment a step hop began: its legs are what the hop bends.
var _hop_stride: Dictionary = {}
var _anim_state: int = AnimState.IDLE
var _land_timer: float = 0.0
var LAND_DURATION: float = 0.2
# Leg stride carry-over: freeze locomotion leg pose at jump time, blend out in air
var _jump_leg_pose: Dictionary = {} # Locomotion pose captured at jump start
var _jump_leg_blend: float = 0.0 # 1.0 at jump, fades to 0.0
const JUMP_LEG_FADE_SPEED: float = 2.5 # How fast the stride legs blend out (per second)
const LEG_BONES: Array = [
"DEF-thigh.L", "DEF-shin.L", "DEF-foot.L", "DEF-toe.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-foot.R", "DEF-toe.R",
]
# IK systems (spring-damper state holders)
var _foot_ik: RefCounted = null
var _chest_ik: RefCounted = null
var _head_ik: RefCounted = null
var _torso_roll: RefCounted = null
# Look direction (set by controller)
var look_direction: Vector3 = Vector3.ZERO
# Flip/Roll system (Overgrowth fliproll.as)
var _flip_roll: RefCounted = null
## Cached at load: the bone-rotation diagnostic runs every frame.
static var _dbg_bones_on: bool = OS.has_environment("HTPS_MODELLOG")
# Arm secondary physics (Overgrowth DrawArms Verlet, lines 12420-12698)
var _arm_physics: RefCounted = null
# Leg secondary physics (Verlet dangle during grapple)
var _leg_physics: RefCounted = null
# Output: world-space right hand bone position (for grapple rope origin)
var right_hand_world_pos: Vector3 = Vector3.ZERO
# Key transform indices (Overgrowth line 13631-13639)
enum KeyTransform { HEAD, LEFT_ARM, RIGHT_ARM, LEFT_LEG, RIGHT_LEG, CHEST, HIP, COUNT }
# Bone name mapping for Rigify DEF- convention
const BONE_MAP := {
"hip": "DEF-spine",
"spine": "DEF-spine.001",
"chest": "DEF-spine.003",
"neck": "DEF-spine.005",
"head": "DEF-spine.006",
"thigh_l": "DEF-thigh.L",
"shin_l": "DEF-shin.L",
"foot_l": "DEF-foot.L",
"toe_l": "DEF-toe.L",
"thigh_r": "DEF-thigh.R",
"shin_r": "DEF-shin.R",
"foot_r": "DEF-foot.R",
"toe_r": "DEF-toe.R",
"shoulder_l": "DEF-shoulder.L",
"upper_arm_l": "DEF-upper_arm.L",
"forearm_l": "DEF-forearm.L",
"hand_l": "DEF-hand.L",
"shoulder_r": "DEF-shoulder.R",
"upper_arm_r": "DEF-upper_arm.R",
"forearm_r": "DEF-forearm.R",
"hand_r": "DEF-hand.R",
}
# Key masses for COM estimate (Overgrowth-style approximate weights)
const KEY_MASSES: Array[float] = [
0.08, # HEAD
0.06, # LEFT_ARM
0.06, # RIGHT_ARM
0.16, # LEFT_LEG
0.16, # RIGHT_LEG
0.32, # CHEST
0.16, # HIP
]
func _set_pose_lib(value: PoseLibrary) -> void:
pose_lib = value
if pose_lib:
print("[ProceduralAnimator] PoseLibrary set with ", pose_lib.get_pose_names().size(), " poses: ", pose_lib.get_pose_names())
# Pre-create foot IK so exclude_rid can be set before first _process_modification
if _foot_ik == null and enable_foot_ik:
_foot_ik = _make_foot_ik()
## A foot touched down (0 left, 1 right) — FootIK's swing falling back to planted while
## grounded. The player plays its footstep on this, so steps follow the stride.
signal foot_planted(foot: int)
func _make_foot_ik() -> RefCounted:
var f: RefCounted = FootIKClass.new()
f.set("on_touchdown", func(foot: int) -> void: foot_planted.emit(foot))
return f
## Start a ground roll in the given direction.
func start_roll(direction: Vector3) -> void:
if _flip_roll == null:
_flip_roll = FlipRollClass.new()
_flip_roll.start_roll(direction)
## Start an air flip (double jump).
func start_flip(facing_direction: Vector3) -> void:
if _flip_roll == null:
_flip_roll = FlipRollClass.new()
_flip_roll.start_flip(facing_direction)
## Jump during a ground roll -> a handspring (FlipRoll.start_handspring). False when the
## roll is not far enough in, or there is no roll.
func start_handspring() -> bool:
return _flip_roll != null and _flip_roll.start_handspring()
func can_handspring() -> bool:
return _flip_roll != null and _flip_roll.can_handspring()
## Whether the character is currently flipping/rolling.
func is_flipping() -> bool:
return _flip_roll != null and _flip_roll.flipping
## Get roll velocity override (for player controller to apply).
func get_roll_velocity() -> Vector3:
if _flip_roll == null:
return Vector3.ZERO
return _flip_roll.get_roll_velocity()
## ⛔ TAKE THE DELTA, DO NOT INVENT ONE.
##
## This overrode the DEPRECATED `_process_modification()`, which is passed
## nothing, and then called get_physics_process_delta_time() -- a constant 1/60
## regardless of frame rate. But Skeleton3D.modifier_callback_mode_process is
## left at Godot's default (IDLE), so the callback fires once per RENDERED FRAME.
## Every spring, damper and lerp below therefore advanced at fps/60 of real time.
##
## MEASURED, gait cycles per real second at a constant 8.00 m/s ground speed:
## 25 fps -> 0.5519 60 fps -> 1.3296 144 fps -> 3.1941
## exactly proportional to frame rate (0.415 vs 25/60 = 0.417; 2.402 vs
## 144/60 = 2.400) -- the limbs cycled 5.8x faster at 144 than at 25 while
## covering the SAME ground. Movement itself is frame-rate independent (0.33%
## across 12 runs), so this layer alone is what reads as "speed changes with fps".
##
## The engine already computes the right delta and hands it over
## (scene/3d/skeleton_3d.cpp): NOTIFICATION_INTERNAL_PROCESS advances by
## get_process_delta_time(), NOTIFICATION_INTERNAL_PHYSICS_PROCESS by
## get_physics_process_delta_time(), Skeleton3D::advance() ACCUMULATES it, and
## _process_modifiers() passes the total and resets it. So the correct delta
## follows modifier_callback_mode_process automatically -- switch that to PHYSICS
## and this function keeps working, with no edit here.
##
## Substituting our own delta was wrong twice over: once as the physics constant
## (ran fast with frame rate), once as get_process_delta_time() clamped to 1/30
## (ran SLOW below 30 fps, because the clamp bound). Both are guesses where the
## engine supplies a fact.
func _process_modification_with_delta(delta: float) -> void:
var skeleton: Skeleton3D = get_skeleton()
if not skeleton or not pose_lib:
return
var gx: Transform3D = skeleton.global_transform
# ---- Phase 1: Pose selection + write to bones ----
_update_cycle_phase(delta)
_update_anim_state(delta)
var target_pose: Dictionary = _compute_current_pose()
var final_pose: Dictionary = _blend_with_fading(target_pose, delta)
_apply_pose_to_skeleton(skeleton, final_pose)
# ---- Phase 1.5: Hip drop handled by player controller ----
# Overgrowth lowers the collision sphere offset, not skeleton bones.
# We lower the model node (Node3D) in player_procedural.gd so the whole
# skeleton moves down while feet stay at ground level via foot IK.
# ---- Phase 1.6: Breathing (Overgrowth Breathe() lines 2322-2350) ----
_update_breathing(skeleton, delta)
# ---- Phase 1.7: Ledge hang arm raise ----
if on_ledge:
_apply_ledge_arm_raise(skeleton, gx)
# ---- Phase 1.8: Acceleration tilt (Overgrowth HandleAccelTilt + UpdateTilt) ----
_update_acceleration_tilt(delta)
# ---- Phase 1.9: Idle systems (weight shift, stance amount) ----
_update_idle_systems(delta)
# ---- Phase 2: Extract key_transforms from posed skeleton ----
var key_transforms: Array[Transform3D] = []
key_transforms.resize(KeyTransform.COUNT)
_extract_key_transforms(skeleton, gx, key_transforms)
# ---- Phase 3: COM estimate (before IK) ----
var original_com: Vector3 = _estimate_com(key_transforms)
# ---- Phase 4: ChestIK — spring-damper on key_transforms[CHEST] ----
# Pre-IK snapshot for feedback prevention
var pre_ik_globals: Dictionary = _snapshot_bone_globals(skeleton)
# Compute effective look direction (blend with idle random gaze)
var effective_look: Vector3 = _compute_effective_look()
# Disable chest IK during wall climb (fights prone spine), keep head IK
if enable_chest_ik and not is_wall_climbing:
if _chest_ik == null:
_chest_ik = ChestIKClass.new()
_chest_ik.look_direction = effective_look
_chest_ik.idle_amount = _idle_stance_amount
var chest_rot: Quaternion = _chest_ik.apply(skeleton, delta, pre_ik_globals)
key_transforms[KeyTransform.CHEST] = Transform3D(
Basis(chest_rot) * key_transforms[KeyTransform.CHEST].basis,
key_transforms[KeyTransform.CHEST].origin)
# ---- Phase 5: HeadIK — spring-damper on key_transforms[HEAD] ----
if enable_head_ik:
if _head_ik == null:
_head_ik = HeadIKClass.new()
_head_ik.look_direction = effective_look
_head_ik.idle_amount = _idle_stance_amount
_head_ik.apply(skeleton, delta, pre_ik_globals)
# Re-extract head transform after head IK modified bones
var head_idx: int = skeleton.find_bone(BONE_MAP["head"])
if head_idx >= 0:
key_transforms[KeyTransform.HEAD] = gx * skeleton.get_bone_global_pose(head_idx)
# ---- Phase 5.5: Torso counter-roll — keep the head level with the GROUND ----
# Wall running tilts the whole body toward the wall; without this the head
# tilts with it. ChestIK and HeadIK cannot do this: both are yaw+pitch in the
# skeleton's own frame and neither has a roll axis. Runs on the SAME pre-IK
# snapshot so it composes rather than feeding back.
if _torso_roll == null:
_torso_roll = TorsoCounterRollClass.new()
# Gated on the LEAN, not on the state: with wall_run_tilt at 0 the body does
# not tilt, so there is nothing to counter and this must not touch the spine.
var want_roll: bool = wall_run_active and model_up.angle_to(Vector3.UP) > 0.05
_torso_roll.apply(skeleton, delta, want_roll, pre_ik_globals,
model_up, facing_direction)
# ---- Phase 5.6: Wall hug — pelvis and upper torso nearer the surface ----
if wall_run_active and wall_run_normal.length_squared() > 0.01 \
and (wall_hug_hip > 0.0 or wall_hug_chest > 0.0):
# wall_run_normal points INTO the wall, so it is already the direction to
# move toward it. Converted into each bone's PARENT frame, like every other
# bone write here, so it composes rather than fighting the pose.
var toward: Vector3 = wall_run_normal.normalized()
for pair in [["hip", wall_hug_hip], ["chest", wall_hug_chest]]:
var amt: float = float(pair[1]) * WALL_HUG_REFERENCE
if amt <= 0.0:
continue
var bidx: int = skeleton.find_bone(BONE_MAP[String(pair[0])])
if bidx < 0:
continue
var par: int = skeleton.get_bone_parent(bidx)
var pbasis: Basis = (gx * skeleton.get_bone_global_pose(par)).basis if par >= 0 else gx.basis
skeleton.set_bone_pose_position(bidx,
skeleton.get_bone_pose_position(bidx) + pbasis.inverse() * (toward * amt))
var head_idx_cr: int = skeleton.find_bone(BONE_MAP["head"])
if head_idx_cr >= 0:
key_transforms[KeyTransform.HEAD] = gx * skeleton.get_bone_global_pose(head_idx_cr)
# ---- Phase 6: COM compensation from look rotation ----
# Track accumulated body offset for fallback application when chain solvers disabled
var _body_offset_accum: Vector3 = Vector3.ZERO
if is_grounded:
var post_look_com: Vector3 = _estimate_com(key_transforms)
var balance_offset: Vector3 = original_com - post_look_com
balance_offset.y = 0.0 # Only horizontal compensation
key_transforms[KeyTransform.HEAD].origin += balance_offset
key_transforms[KeyTransform.CHEST].origin += balance_offset
key_transforms[KeyTransform.HIP].origin += balance_offset
key_transforms[KeyTransform.LEFT_ARM].origin += balance_offset
key_transforms[KeyTransform.RIGHT_ARM].origin += balance_offset
_body_offset_accum += balance_offset
# ---- Phase 6.5: Idle weight shift — apply COM sway to key_transforms ----
if _idle_stance_amount > 0.01 and is_grounded:
var sway: Vector3 = _com_offset * _idle_stance_amount
# Overgrowth aschar.as:14423: target_com = (right_foot - left_foot) * com_offset.x
# + facing * com_offset.z -- x is a FRACTION OF THE STANCE, not metres. Read
# as metres it swung the body +/-5 cm instead of +/-5% of the foot spacing
# (~1.5 cm), 3x what Overgrowth does.
var feet: Vector3 = key_transforms[KeyTransform.RIGHT_LEG].origin - key_transforms[KeyTransform.LEFT_LEG].origin
feet -= local_up * feet.dot(local_up)
var body_offset: Vector3 = feet * sway.x + local_up * sway.y + facing_direction * sway.z
key_transforms[KeyTransform.HIP].origin += body_offset
key_transforms[KeyTransform.CHEST].origin += body_offset
key_transforms[KeyTransform.HEAD].origin += body_offset
_body_offset_accum += body_offset
# ---- Phase 7: FootIK — raycasts + ground offsets ----
# Disable foot IK during wall climb (it sweeps relative to gravity, not surface)
if enable_foot_ik and not is_wall_climbing:
if _foot_ik == null:
_foot_ik = _make_foot_ik()
# A wall run IS footed contact, just not with the ground: FootIK's first
# act is `if not is_grounded: fade out; return`, and is_grounded is
# is_on_floor(), which is false on a wall. And it sweeps along local_up, so
# it has to be pointed at the WALL or it looks for ground below the player.
var ik_grounded: bool = is_grounded or wall_run_active
# -wall_run_normal, NOT wall_run_normal. _detect_walls stores
# `-col.get_normal()`, and Godot's slide normal points OUT of the surface
# toward the body, so wall_run_normal points INTO the wall. FootIK sweeps
# from +up to -up, so "up" has to be the direction AWAY from the wall for
# the sweep to travel toward it.
# GROUND CONTACT WINS. wall_run_active can latch while you are, to every
# appearance, walking: _detect_walls runs in AIRBORNE and sphere-sweeps 2 m
# whenever sprint is held, is_on_floor() flickers as you cross steep ground,
# and anything past 60 degrees passes its wall test. When that happened the
# feet were sent hunting SIDEWAYS along the wall normal while the player stood
# on a floor -- measured in wall_run_ground_gate as both feet missing
# everything, so FootIK planted on nothing at all.
#
# A wall run is footed contact with a WALL, which by definition is not
# happening while you are standing on the ground. So real ground beats a
# latched flag, and only an ungrounded wall run redirects the sweep.
var ik_up: Vector3 = local_up
if wall_run_active and not is_grounded:
ik_up = (-wall_run_normal).normalized()
# On a wall, ik_up IS the surface normal, so the slope relative to it is
# zero and the gait clearance correctly contributes nothing -- a wall run
# is not a climb up a hill.
var ik_surface: Vector3 = ik_up if wall_run_active else ground_normal
# On a wall, hand FootIK less planting authority so the run cycle survives.
_foot_ik.plant_weight = wall_foot_plant_weight if (wall_run_active and not is_grounded) else 1.0
var ski_target: float = 1.0 if (ski_holding and is_grounded) else 0.0
ski_amount = lerpf(ski_target, ski_amount, pow(0.05, delta * 60.0))
_foot_ik.ski_amount = ski_amount
_foot_ik.apply(skeleton, delta, ik_grounded, horizontal_speed, ik_up, ik_surface)
# Re-extract leg/hip transforms after foot IK
for i in range(2):
var foot_bone: String = BONE_MAP["foot_l"] if i == 0 else BONE_MAP["foot_r"]
var foot_idx: int = skeleton.find_bone(foot_bone)
if foot_idx >= 0:
key_transforms[KeyTransform.LEFT_LEG + i] = gx * skeleton.get_bone_global_pose(foot_idx)
var hip_idx: int = skeleton.find_bone(BONE_MAP["hip"])
if hip_idx >= 0:
key_transforms[KeyTransform.HIP] = gx * skeleton.get_bone_global_pose(hip_idx)
# ---- Phase 8: Final COM compensation (in air) ----
if not is_grounded:
var final_com: Vector3 = _estimate_com(key_transforms)
var balance_offset: Vector3 = original_com - final_com
for i in range(KeyTransform.COUNT):
key_transforms[i].origin += balance_offset
_body_offset_accum += balance_offset
# ---- Phase 9: the body offset onto the pelvis, then DrawLeg ----
# Overgrowth adds body_offset to hip_offset and DrawLeg solves each leg to its
# UNCHANGED foot key, so the body sways over planted feet. Chest IK, head IK and
# the arm physics already write their bones directly, so of the four chain
# solvers only DrawLeg has anything left to do -- and the old one read the joints
# one bone too high (see ChainSolver.solve_leg_rigify). With it off, shifting the
# pelvis carried both feet: they slid 12-26 mm under the idle sway alone.
#
# ⛔ NO 1 cm DEAD ZONE. This was `length_squared() > 0.0001`, i.e. offsets
# under 1 cm were DROPPED: as the idle sway crossed zero the whole body popped
# 1 cm to the origin and back (probe_stand_wobble track: -0.0097 -> 0.0000,
# 0.0000 -> 0.0100). A continuous offset must be applied continuously.
if _body_offset_accum != Vector3.ZERO:
var hip_idx: int = skeleton.find_bone(BONE_MAP["hip"])
if hip_idx >= 0:
# ⛔ IN THE PARENT BONE'S FRAME, not the skeleton's. A pose position is
# relative to the bone's PARENT, and DEF-spine's parent is `root`, which
# is rotated (its +Z runs up the chain). This used gx.basis.inverse(), so
# every offset landed on the wrong axes -- measured: a 5 mm drop moved the
# hip socket 5 mm with 0.00 mm of it downward. The sway's "up" pushed the
# pelvis sideways, which is what put the straight idle legs out of reach.
_shift_bone_world(skeleton, gx, hip_idx, _body_offset_accum)
# The legs back onto the foot keys captured after Phase 7 (before this
# shift). Grounded only: in the air Phase 8 moves the feet WITH the body
# on purpose. Not on a wall (grip IK owns the limbs) or mid-flip
# (Phase 10 turns the whole body after this).
if enable_chain_solvers and is_grounded and not is_wall_climbing and not is_flipping():
# Hips down just enough that both ankles stay within reach (DoHipIK).
var up: Vector3 = gx.basis.y.normalized()
var dl: float = ChainSolverClass.hip_drop_for_reach(skeleton, gx, BONE_MAP["thigh_l"], BONE_MAP["shin_l"],
BONE_MAP["foot_l"], key_transforms[KeyTransform.LEFT_LEG], up)
var dr: float = ChainSolverClass.hip_drop_for_reach(skeleton, gx, BONE_MAP["thigh_r"], BONE_MAP["shin_r"],
BONE_MAP["foot_r"], key_transforms[KeyTransform.RIGHT_LEG], up)
var drop: float = maxf(dl, dr)
if drop > 0.0:
_shift_bone_world(skeleton, gx, hip_idx, -up * drop)
ChainSolverClass.solve_leg_rigify(skeleton, gx, BONE_MAP["thigh_l"], BONE_MAP["shin_l"],
BONE_MAP["foot_l"], key_transforms[KeyTransform.LEFT_LEG])
ChainSolverClass.solve_leg_rigify(skeleton, gx, BONE_MAP["thigh_r"], BONE_MAP["shin_r"],
BONE_MAP["foot_r"], key_transforms[KeyTransform.RIGHT_LEG])
# ---- Phase 9.5: Arm secondary physics (Verlet) ----
if _arm_physics == null:
_arm_physics = ArmPhysicsClass.new()
_arm_physics.character_velocity = velocity
_arm_physics.max_speed = 8.0
_arm_physics.is_grounded = is_grounded
_arm_physics.is_flipping = (_flip_roll != null and _flip_roll.flipping) or is_wall_climbing
_arm_physics.is_grappling = is_grappling
_arm_physics.local_up = local_up
_arm_physics.grapple_anchor_dir = grapple_anchor_direction
_arm_physics.apply(skeleton, delta)
# ---- Phase 9.6: Spine + leg + trail arm Verlet (dangle during grapple) ----
if _leg_physics == null:
_leg_physics = LegPhysicsClass.new()
_leg_physics.character_velocity = velocity
_leg_physics.local_up = local_up
_leg_physics.is_grappling = is_grappling
_leg_physics.grapple_tension_dir = grapple_anchor_direction
_leg_physics.grapple_anchor_dir = grapple_anchor_direction
_leg_physics.apply(skeleton, delta)
# ---- Phase 9.7: Grapple arm IK (AFTER spine Verlet — chest parent is final) ----
if is_grappling and grapple_anchor_direction != Vector3.ZERO:
_apply_grapple_ik(skeleton, delta)
# Output right hand world position (for grapple rope visual origin)
var _hand_r_idx: int = skeleton.find_bone(BONE_MAP["hand_r"])
if _hand_r_idx >= 0:
right_hand_world_pos = (gx * skeleton.get_bone_global_pose(_hand_r_idx)).origin
# ---- Phase 10: Tilt + Flip/Roll — rotate ALL bones around COM ----
# Overgrowth: flip_modifier = flip_rotation * tilt_rotation * flip_modifier (line 13931)
if _flip_roll == null:
_flip_roll = FlipRollClass.new()
var flip_rot: Quaternion = _flip_roll.update(delta)
# Build tilt rotation (Overgrowth lines 13928-13931)
# Skip tilt during grapple — Verlet body physics handles orientation,
# and tilt fights the trailing arm/leg positions.
var tilt_rot: Quaternion = Quaternion.IDENTITY
if not is_grappling:
var tilt_length: float = _tilt.length()
if tilt_length > 0.001:
var tilt_axis: Vector3 = Vector3.UP.cross(_tilt).normalized()
if tilt_axis.length_squared() > 0.001:
tilt_rot = Quaternion(tilt_axis, tilt_length / 180.0 * PI)
# Combined rotation
var combined_rot: Quaternion = flip_rot * tilt_rot
# BONE-SPACE ROTATION IS INVISIBLE TO EVERY MODEL-SPACE CHECK. This rotates ALL
# bones about the COM and never touches the Model node, so a play session that
# reproduced the body fully inverted printed NO [model] lines and NO [wall-run]
# lines at all -- the Model stayed upright and no wall run was ever entered.
# Whatever turns the character over lives here, not in update_model_orientation,
# and every gate in scenes/test/ measures the Model node.
#
# Triggered on the symptom: only once the combined rotation is past ~25 degrees.
if OS.has_environment("HTPS_MODELLOG"):
var deg: float = rad_to_deg(2.0 * acos(clampf(absf(combined_rot.w), 0.0, 1.0)))
if deg > 25.0:
print("[bones] f=%d TOTAL=%5.1f deg flip=%5.1f (flipping=%s) tilt=%5.1f (|_tilt|=%.2f) grounded=%s wr=%s"
% [Engine.get_physics_frames(), deg,
rad_to_deg(2.0 * acos(clampf(absf(flip_rot.w), 0.0, 1.0))), str(is_flipping()),
rad_to_deg(2.0 * acos(clampf(absf(tilt_rot.w), 0.0, 1.0))), _tilt.length(),
str(is_grounded), str(wall_run_active)])
if not combined_rot.is_equal_approx(Quaternion.IDENTITY):
_apply_flip_rotation(skeleton, gx, combined_rot)
# ---- Phase 10.5: Climb body undulation (lateral spine wave) ----
if is_wall_climbing and skeleton:
_apply_climb_undulation(skeleton, delta)
# ---- Phase 11: Wall climb — spine deformation + limb IK ----
if is_wall_climbing and skeleton:
var gx_inv: Transform3D = gx.affine_inverse()
# Per-spine-joint surface conforming via ROTATION (not position).
# Each spine bone rotates to face the blended normal between
# hand grips (front) and foot grips (back). This curves the spine
# around floor→wall transitions without fighting the model position.
var hand_normal: Vector3 = Vector3.ZERO
var hand_count: int = 0
var foot_normal: Vector3 = Vector3.ZERO
var foot_count: int = 0
for i in range(2):
if climb_grip_weights[i] > 0.1:
hand_normal += climb_grip_normals[i]
hand_count += 1
for i in range(2, 4):
if climb_grip_weights[i] > 0.1:
foot_normal += climb_grip_normals[i]
foot_count += 1
if hand_count > 0:
hand_normal = hand_normal.normalized()
else:
hand_normal = surface_up
if foot_count > 0:
foot_normal = foot_normal.normalized()
else:
foot_normal = surface_up
# Only apply spine conforming when normals actually differ (surface transition)
if hand_normal.dot(foot_normal) < 0.95:
var spine_blend: Array[float] = [0.0, 0.3, 0.7, 0.9]
var spine_bones_conform: Array[String] = [
BONE_MAP["hip"], BONE_MAP["spine"], BONE_MAP["chest"], BONE_MAP["neck"]
]
for si in range(spine_bones_conform.size()):
var bone_idx: int = skeleton.find_bone(spine_bones_conform[si])
if bone_idx < 0:
continue
var parent_idx: int = skeleton.get_bone_parent(bone_idx)
if parent_idx < 0:
continue
# Blend normal for this bone
var bone_normal: Vector3 = foot_normal.slerp(hand_normal, spine_blend[si])
# Compute rotation from current surface_up toward this bone's local normal
var rot_angle: float = surface_up.angle_to(bone_normal)
if rot_angle < 0.01:
continue
var rot_axis: Vector3 = surface_up.cross(bone_normal)
if rot_axis.length_squared() < 0.001:
continue
rot_axis = rot_axis.normalized()
var conform_rot := Quaternion(rot_axis, rot_angle * 0.5) # 50% blend
var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
var local_rot: Quaternion = (parent_global.basis.inverse() * Basis(conform_rot) * parent_global.basis).get_rotation_quaternion()
var current_rot: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
skeleton.set_bone_pose_rotation(bone_idx, local_rot * current_rot)
# Limb IK — hands and feet reach toward grip positions
# Left hand (grip 0)
if climb_grip_weights[0] > 0.1:
var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[0])).origin
_two_bone_ik(skeleton, _bone_idx(skeleton, "upper_arm_l"),
_bone_idx(skeleton, "forearm_l"), _bone_idx(skeleton, "hand_l"), target_local)
# Right hand (grip 1)
if climb_grip_weights[1] > 0.1:
var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[1])).origin
_two_bone_ik(skeleton, _bone_idx(skeleton, "upper_arm_r"),
_bone_idx(skeleton, "forearm_r"), _bone_idx(skeleton, "hand_r"), target_local)
# Left foot (grip 2)
if climb_grip_weights[2] > 0.1:
var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[2])).origin
_two_bone_ik(skeleton, _bone_idx(skeleton, "thigh_l"),
_bone_idx(skeleton, "shin_l"), _bone_idx(skeleton, "foot_l"), target_local)
# Right foot (grip 3)
if climb_grip_weights[3] > 0.1:
var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[3])).origin
_two_bone_ik(skeleton, _bone_idx(skeleton, "thigh_r"),
_bone_idx(skeleton, "shin_r"), _bone_idx(skeleton, "foot_r"), target_local)
# ---- Phase 12: THE PELVIS MAY NOT END UP SIDEWAYS ----
#
# A bound on the composed result, deliberately, and not a root-cause fix.
# SIX things can rotate this body -- the rest pose, the animation pose, FootIK's
# hip solve and trunk counter, Chest/Head IK, the counter-roll, and Phase 10's
# rotation of every bone about the COM -- and the visible orientation is their
# PRODUCT. Chasing which one is responsible has failed repeatedly, and while it
# failed the character stayed broken. A ceiling on the product is cheap, it is
# checkable, and it holds no matter which contributor misbehaves.
#
# It is also a diagnostic: if the pelvis stops going sideways with this in, the
# cause is upstream of here and _dbg_record's per-frame HIP= column says by how
# much it was being pulled.
#
# Measured against the MODEL's up, not the world's, so a legitimate wall-run
# lean (45 degrees by spec) and spherical gravity are untouched -- this only
# catches the pelvis departing from the body it belongs to.
# ⛔ NOT DURING A FLIP OR ROLL. Phase 10 turns the whole body a full 360 degrees
# about the COM on purpose, and this clamp pulled the pelvis -- the parent of the
# legs and spine -- back to 35 degrees every frame, so neither ever turned over
# (gate_flip_roll: peak exactly 35.0 deg). It landed 2026-08-31; the roll and
# double jump were reported broken the next day, alongside the Jolt switch.
var flip_rotating: bool = _flip_roll != null and (_flip_roll.flipping or _flip_roll.settling)
if max_pelvis_deviation_deg > 0.0 and not flip_rotating:
var hip_i: int = skeleton.find_bone(BONE_MAP["hip"])
if hip_i >= 0:
var body_up: Vector3 = gx.basis.y.normalized()
var hip_up: Vector3 = (gx * skeleton.get_bone_global_pose(hip_i)).basis.y.normalized()
var dev: float = hip_up.angle_to(body_up)
var lim: float = deg_to_rad(max_pelvis_deviation_deg)
if dev > lim:
var axis: Vector3 = hip_up.cross(body_up)
if axis.length_squared() > 1e-8:
# Rotate the pelvis back to the limit, in the bone's PARENT frame so
# this composes with whatever produced the excess instead of
# replacing it.
var corr := Quaternion(axis.normalized(), dev - lim)
var par: int = skeleton.get_bone_parent(hip_i)
var pb: Basis = (gx * skeleton.get_bone_global_pose(par)).basis if par >= 0 else gx.basis
var loc: Quaternion = (pb.inverse() * Basis(corr) * pb).get_rotation_quaternion()
skeleton.set_bone_pose_rotation(hip_i,
loc * skeleton.get_bone_pose_rotation(hip_i))
## Move a bone by a WORLD-space offset. Pose positions are in the PARENT bone's
## frame, so the offset is converted through the parent's global basis (as the wall
## hug in Phase 5.6 does), never through the skeleton's.
func _shift_bone_world(skeleton: Skeleton3D, gx: Transform3D, bone: int, world_offset: Vector3) -> void:
var par: int = skeleton.get_bone_parent(bone)
var pb: Basis = (gx * skeleton.get_bone_global_pose(par)).basis if par >= 0 else gx.basis
skeleton.set_bone_pose_position(bone, skeleton.get_bone_pose_position(bone) + pb.inverse() * world_offset)
func _bone_idx(skeleton: Skeleton3D, key: String) -> int:
return skeleton.find_bone(BONE_MAP[key])
## Two-bone IK: rotate upper and lower bones so the tip reaches target.
## target_pos is in skeleton-local space (NOT world space).
func _two_bone_ik(skeleton: Skeleton3D, upper_idx: int, lower_idx: int,
tip_idx: int, target_pos: Vector3) -> void:
TwoBoneIK.solve(skeleton, upper_idx, lower_idx, tip_idx, target_pos)
## Apply flip/roll rotation to ALL bones around center of mass.
## Matches Overgrowth's TransformAllFrameMats(flip_modifier) (aschar.as line 13936).
func _apply_flip_rotation(skeleton: Skeleton3D, gx: Transform3D,
flip_rotation: Quaternion) -> void:
# Compute COM from current bone poses
var com: Vector3 = Vector3.ZERO
var bone_count: int = skeleton.get_bone_count()
for i in range(bone_count):
com += (gx * skeleton.get_bone_global_pose(i)).origin
com /= float(bone_count)
# Rotate each root bone's position around COM, and add rotation to orientation
for i in range(bone_count):
var parent_idx: int = skeleton.get_bone_parent(i)
if parent_idx >= 0:
continue # Only transform root bones; children follow
var bone_global: Transform3D = gx * skeleton.get_bone_global_pose(i)
# Rotate position around COM
var offset: Vector3 = bone_global.origin - com
var new_pos: Vector3 = com + flip_rotation * offset
var pos_delta: Vector3 = gx.basis.inverse() * (new_pos - bone_global.origin)
skeleton.set_bone_pose_position(i,
skeleton.get_bone_pose_position(i) + pos_delta)
# Add rotation to orientation
var current_rot: Quaternion = skeleton.get_bone_pose_rotation(i)
var local_rot: Quaternion = (gx.basis.inverse() * Basis(flip_rotation) * gx.basis).get_rotation_quaternion()
skeleton.set_bone_pose_rotation(i, local_rot * current_rot)
## Extract 7 key transforms from the currently posed skeleton.
## These are world-space transforms of the key joints.
func _extract_key_transforms(skeleton: Skeleton3D, gx: Transform3D,
key_transforms: Array[Transform3D]) -> void:
# Legs
var foot_l_idx: int = skeleton.find_bone(BONE_MAP["foot_l"])
var foot_r_idx: int = skeleton.find_bone(BONE_MAP["foot_r"])
if foot_l_idx >= 0:
key_transforms[KeyTransform.LEFT_LEG] = gx * skeleton.get_bone_global_pose(foot_l_idx)
if foot_r_idx >= 0:
key_transforms[KeyTransform.RIGHT_LEG] = gx * skeleton.get_bone_global_pose(foot_r_idx)
# Arms
var hand_l_idx: int = skeleton.find_bone(BONE_MAP["hand_l"])
var hand_r_idx: int = skeleton.find_bone(BONE_MAP["hand_r"])
if hand_l_idx >= 0:
key_transforms[KeyTransform.LEFT_ARM] = gx * skeleton.get_bone_global_pose(hand_l_idx)
if hand_r_idx >= 0:
key_transforms[KeyTransform.RIGHT_ARM] = gx * skeleton.get_bone_global_pose(hand_r_idx)
# Chest
var chest_idx: int = skeleton.find_bone(BONE_MAP["chest"])
if chest_idx >= 0:
key_transforms[KeyTransform.CHEST] = gx * skeleton.get_bone_global_pose(chest_idx)
# Hip
var hip_idx: int = skeleton.find_bone(BONE_MAP["hip"])
if hip_idx >= 0:
key_transforms[KeyTransform.HIP] = gx * skeleton.get_bone_global_pose(hip_idx)
# Head
var head_idx: int = skeleton.find_bone(BONE_MAP["head"])
if head_idx >= 0:
key_transforms[KeyTransform.HEAD] = gx * skeleton.get_bone_global_pose(head_idx)
## Estimate center of mass from key transforms (Overgrowth line 13644).
## Weighted average of limb midpoints.
func _estimate_com(key_transforms: Array[Transform3D]) -> Vector3:
var com: Vector3 = Vector3.ZERO
var total_mass: float = 0.0
# Each limb COM ≈ midpoint between its root and tip
# Legs: midpoint between hip and foot
for i in range(2):
var foot_pos: Vector3 = key_transforms[KeyTransform.LEFT_LEG + i].origin
var hip_pos: Vector3 = key_transforms[KeyTransform.HIP].origin
var mass: float = KEY_MASSES[KeyTransform.LEFT_LEG + i]
com += (hip_pos + foot_pos) * 0.5 * mass
total_mass += mass
# Arms: midpoint between chest and hand
for i in range(2):
var hand_pos: Vector3 = key_transforms[KeyTransform.LEFT_ARM + i].origin
var chest_pos: Vector3 = key_transforms[KeyTransform.CHEST].origin
var mass: float = KEY_MASSES[KeyTransform.LEFT_ARM + i]
com += (chest_pos + hand_pos) * 0.5 * mass
total_mass += mass
# Torso: midpoint between hip and chest
var torso_mass: float = KEY_MASSES[KeyTransform.CHEST]
com += (key_transforms[KeyTransform.HIP].origin + key_transforms[KeyTransform.CHEST].origin) * 0.5 * torso_mass
total_mass += torso_mass
# Head: midpoint between chest and head
var head_mass: float = KEY_MASSES[KeyTransform.HEAD]
com += (key_transforms[KeyTransform.CHEST].origin + key_transforms[KeyTransform.HEAD].origin) * 0.5 * head_mass
total_mass += head_mass
# Hip mass at hip position
var hip_mass: float = KEY_MASSES[KeyTransform.HIP]
com += key_transforms[KeyTransform.HIP].origin * hip_mass
total_mass += hip_mass
if total_mass > 0.0:
com /= total_mass
return com
# ---- Pose selection (unchanged) ----
func _update_cycle_phase(delta: float) -> void:
var speed: float = horizontal_speed
# During wall climb, use full velocity magnitude (surface-tangent speed)
if is_wall_climbing:
speed = velocity.length()
if speed < walk_speed_threshold:
return
var stride: float = walk_stride_length
if speed >= run_speed_threshold:
stride = run_stride_length
elif speed > walk_speed_threshold:
var t: float = (speed - walk_speed_threshold) / (run_speed_threshold - walk_speed_threshold)
stride = lerpf(walk_stride_length, run_stride_length, clampf(t, 0.0, 1.0))
var distance_this_frame: float = speed * delta
_cycle_phase += distance_this_frame / stride
_cycle_phase = fmod(_cycle_phase, 1.0)
func _update_anim_state(delta: float) -> void:
var new_state: int = _anim_state
if _land_timer > 0.0:
_land_timer -= delta
if _land_timer <= 0.0:
new_state = AnimState.IDLE
# Fade out jump leg stride carry-over
if _jump_leg_blend > 0.0:
_jump_leg_blend = maxf(0.0, _jump_leg_blend - JUMP_LEG_FADE_SPEED * delta)
# Traversal overrides (highest priority)
if is_grappling:
new_state = AnimState.GRAPPLE
elif is_wall_climbing:
new_state = AnimState.WALL_CLIMB
elif on_ledge:
new_state = AnimState.LEDGE_HANG
elif is_climbing_ledge:
new_state = AnimState.LEDGE_CLIMB
elif _flip_roll and _flip_roll.flipping:
new_state = AnimState.ROLL
elif wall_run_active:
new_state = AnimState.WALL_RUN
elif is_step_hopping:
new_state = AnimState.STEP_HOP
elif not is_grounded:
if vertical_velocity > 0.5:
new_state = AnimState.JUMP
else:
new_state = AnimState.FALL
elif _land_timer > 0.0:
new_state = AnimState.LAND
elif horizontal_speed >= run_speed_threshold:
new_state = AnimState.RUN
elif horizontal_speed >= walk_speed_threshold:
new_state = AnimState.WALK
else:
new_state = AnimState.IDLE
if _anim_state == AnimState.FALL or _anim_state == AnimState.JUMP:
if is_grounded:
new_state = AnimState.LAND
_land_timer = LAND_DURATION
if new_state != _anim_state:
# Capture locomotion leg pose when jumping from walk/run
if new_state == AnimState.JUMP and (_anim_state == AnimState.RUN or _anim_state == AnimState.WALK):
if _anim_state == AnimState.RUN and pose_lib.has_pose("run_a"):
_jump_leg_pose = _compute_locomotion_cycle("run_a", "run_b")
else:
_jump_leg_pose = _compute_locomotion_cycle("walk_a", "walk_b")
_jump_leg_blend = 1.0
if new_state == AnimState.STEP_HOP:
_hop_stride = _compute_locomotion_cycle("walk_a", "walk_b") \
if horizontal_speed < run_speed_threshold or not pose_lib.has_pose("run_a") \
else _compute_locomotion_cycle("run_a", "run_b")
_transition_to(new_state)
_anim_state = new_state
func _transition_to(new_state: int) -> void:
if _active_pose.is_empty():
return
var fade_speed: float = fade_speed_walk
match new_state:
AnimState.IDLE:
fade_speed = fade_speed_idle
AnimState.WALK, AnimState.RUN:
fade_speed = fade_speed_walk
AnimState.JUMP:
fade_speed = fade_speed_jump
AnimState.LAND:
fade_speed = fade_speed_land
AnimState.ROLL:
fade_speed = fade_speed_roll
AnimState.FALL:
fade_speed = fade_speed_fall
AnimState.WALL_RUN:
fade_speed = fade_speed_wall_run
AnimState.LEDGE_HANG:
fade_speed = fade_speed_ledge
AnimState.LEDGE_CLIMB:
fade_speed = 14.0 # Fast snap
AnimState.WALL_CLIMB:
fade_speed = 10.0
AnimState.GRAPPLE:
fade_speed = 10.0
AnimState.STEP_HOP:
fade_speed = fade_speed_jump
_fading_poses.append({
"pose": _active_pose.duplicate(),
"opacity": 1.0,
"fade_speed": fade_speed,
})
func _compute_current_pose() -> Dictionary:
# Jump anticipation: blend current pose toward launch crouch during pre-jump
var base_pose: Dictionary = _compute_base_pose()
if is_pre_jumping and pose_lib.has_pose("jump_launch"):
return PoseLibraryClass.lerp_pose(base_pose, _get_pose_safe("jump_launch"), duck_amount)
return base_pose
func _compute_base_pose() -> Dictionary:
match _anim_state:
AnimState.IDLE:
return _get_pose_safe("idle")
AnimState.WALK:
return _compute_locomotion_cycle("walk_a", "walk_b")
AnimState.RUN:
if pose_lib.has_pose("run_a"):
var walk_pose: Dictionary = _compute_locomotion_cycle("walk_a", "walk_b")
var run_pose: Dictionary = _compute_locomotion_cycle("run_a", "run_b")
var t: float = clampf((horizontal_speed - run_speed_threshold) / (run_speed_threshold * 0.5), 0.0, 1.0)
return PoseLibraryClass.lerp_pose(walk_pose, run_pose, t)
return _compute_locomotion_cycle("walk_a", "walk_b")
AnimState.JUMP:
# Compute normal jump pose (jump_air → fall based on velocity)
var jump_pose: Dictionary
if pose_lib.has_pose("jump_air") and pose_lib.has_pose("fall"):
var t: float = 1.0 - clampf(vertical_velocity / 2.0, 0.0, 1.0)
jump_pose = PoseLibraryClass.lerp_pose(
_get_pose_safe("jump_air"),
_get_pose_safe("fall"), t)
elif pose_lib.has_pose("jump_air"):
jump_pose = _get_pose_safe("jump_air")
else:
jump_pose = _get_pose_safe("jump_launch")
# Legs: if jumped from walk/run, blend frozen stride → fall directly
# (skip jump_air legs entirely — stride IS the jump leg pose)
if not _jump_leg_pose.is_empty():
var fall_pose: Dictionary = _get_pose_safe("fall")
var leg_t: float = 1.0 - _jump_leg_blend # 0=stride, 1=fall
for bone_name in LEG_BONES:
if _jump_leg_pose.has(bone_name) and fall_pose.has(bone_name):
jump_pose[bone_name] = _jump_leg_pose[bone_name].interpolate_with(
fall_pose[bone_name], leg_t)
return jump_pose
AnimState.FALL:
return _get_pose_safe("fall")
AnimState.LAND:
return _compute_land_pose()
AnimState.ROLL:
# A handspring goes over the top OPEN — the airborne jump pose, not the tuck.
if _flip_roll != null and _flip_roll.handspring:
return _get_pose_safe("jump_air")
return _get_pose_safe("roll_start")
AnimState.WALL_RUN:
# THE RUN CYCLE, WHICH IS WHAT THE COMMENT ALWAYS CLAIMED.
# This said "Run cycle with wall tilt applied procedurally" and then
# returned walk_a/walk_b. A wall run only happens above the wall-run entry
# speed -- measured at 12 m/s in play, against a run threshold of 5 -- so
# the walk cycle was never the right pose for any frame of it, and it reads
# on screen as a character walking sideways along a wall.
#
# Blended the same way AnimState.RUN blends, rather than hard-switched, so
# entering a wall run slowly does not snap the legs.
var wr_base: Dictionary
if pose_lib.has_pose("run_a"):
var wr_walk: Dictionary = _compute_locomotion_cycle("walk_a", "walk_b")
var wr_run: Dictionary = _compute_locomotion_cycle("run_a", "run_b")
var wr_t: float = clampf((horizontal_speed - run_speed_threshold)
/ (run_speed_threshold * 0.5), 0.0, 1.0)
wr_base = PoseLibraryClass.lerp_pose(wr_walk, wr_run, wr_t)
else:
wr_base = _compute_locomotion_cycle("walk_a", "walk_b")
# THE ARMS TRAIL, THE LEGS RUN. Blends ONLY the arm bones toward the fall
# pose -- the same swept-back arms a falling player already has -- and
# leaves the run cycle driving everything else. A pumping run-arm swing is
# wrong against a wall; the arms are along for the ride.
return _blend_bones(wr_base, _get_pose_safe("fall"), ARM_BONES, wall_arm_trail)
AnimState.LEDGE_HANG:
# Fall pose as base — arms raised procedurally in Phase 1.8
return _get_pose_safe("fall")
AnimState.LEDGE_CLIMB:
# Blend from fall to jump_launch (pulling up)
return PoseLibraryClass.lerp_pose(
_get_pose_safe("fall"), _get_pose_safe("jump_launch"), 0.7)
AnimState.WALL_CLIMB:
# Crouched idle as base; grip IK overrides all four limbs.
# Blend locomotion cycle when moving for better spine motion.
var climb_base: Dictionary = _get_pose_safe("idle")
if pose_lib.has_pose("land"):
climb_base = PoseLibraryClass.lerp_pose(climb_base, _get_pose_safe("land"), 0.5)
if horizontal_speed > 0.3:
var walk_cycle: Dictionary = _compute_locomotion_cycle("walk_a", "walk_b")
var t: float = clampf(horizontal_speed / 3.0, 0.0, 0.6)
climb_base = PoseLibraryClass.lerp_pose(climb_base, walk_cycle, t)
return climb_base
AnimState.GRAPPLE:
# Fall pose as base, right arm IK overrides toward anchor
return _get_pose_safe("fall")
AnimState.STEP_HOP:
return _compute_step_hop_pose()
return _get_pose_safe("idle")
## THE STEP HOP (docs/player-step-up-design.md §4, the user's "slight single leg crouch
## at each step that creates the animation of generating force in that leg"):
## crouching the LEAD leg bends toward the launch crouch (it is loading); the trail
## leg keeps its stride — one leg works, not both
## airborne the lead leg extends behind (the push), the trail leg tucks up and
## forward to reach the step (the launch pose's bent knee)
## Over the stride captured when the hop began, so the upper body keeps running.
func _compute_step_hop_pose() -> Dictionary:
var base: Dictionary = _hop_stride if not _hop_stride.is_empty() \
else _compute_locomotion_cycle("walk_a", "walk_b")
var lead_side := ".L" if step_lead == 0 else ".R"
var trail_side := ".R" if step_lead == 0 else ".L"
var lead: Array[String] = []
var trail: Array[String] = []
for b: String in LEG_BONES:
if b.ends_with(lead_side):
lead.append(b)
elif b.ends_with(trail_side):
trail.append(b)
var crouch: Dictionary = _get_pose_safe("jump_launch")
if crouch.is_empty():
return base
if step_crouching:
return _blend_bones(base, crouch, lead, clampf(0.5 + duck_amount, 0.0, 0.9))
var pose := _blend_bones(base, _get_pose_safe("fall"), lead, 0.5)
return _blend_bones(pose, crouch, trail, 0.7)
## Blend ONLY `bones` from `a` toward `b`, leaving every other bone in `a` alone.
## Poses are bone_name -> Transform3D, so a subset blend is just a subset of keys.
static func _blend_bones(a: Dictionary, b: Dictionary, bones: Array[String],
t: float) -> Dictionary:
if t <= 0.0:
return a
var out: Dictionary = a.duplicate()
for bn in bones:
if a.has(bn) and b.has(bn):
out[bn] = (a[bn] as Transform3D).interpolate_with(b[bn], t)
return out
func _compute_land_pose() -> Dictionary:
# Overgrowth: landing compression comes from the crouch spring, not a separate pose.
# The duck_amount is set on impact in _on_land() and the spring bounces back.
# We blend based on duck_amount so the pose follows the spring.
var recovery_pose: Dictionary = _get_pose_safe("idle")
if horizontal_speed >= walk_speed_threshold:
recovery_pose = _compute_locomotion_cycle("walk_a", "walk_b")
# If we have a land pose, blend it by duck_amount (spring-driven)
if pose_lib.has_pose("land"):
return PoseLibraryClass.lerp_pose(recovery_pose, _get_pose_safe("land"), duck_amount)
return recovery_pose
func _compute_locomotion_cycle(pose_a_name: String, pose_b_name: String) -> Dictionary:
var pa: Dictionary = _get_pose_safe(pose_a_name)
var pb: Dictionary = _get_pose_safe(pose_b_name)
var pa_m: Dictionary = pose_lib.mirror_pose(pose_a_name)
var pb_m: Dictionary = pose_lib.mirror_pose(pose_b_name)
var frames: Array = [pa, pb, pa_m, pb_m]
var segment: int = int(_cycle_phase * 4.0) % 4
var local_t: float = fmod(_cycle_phase * 4.0, 1.0)
var i0: int = (segment - 1 + 4) % 4
var i1: int = segment
var i2: int = (segment + 1) % 4
var i3: int = (segment + 2) % 4
return PoseLibraryClass.catmull_rom_pose(frames[i0], frames[i1], frames[i2], frames[i3], local_t)
func _get_pose_safe(pose_name: String) -> Dictionary:
if pose_lib and pose_lib.has_pose(pose_name):
return pose_lib.get_pose(pose_name)
return {}
func _blend_with_fading(target: Dictionary, delta: float) -> Dictionary:
_active_pose = target
var result: Dictionary = target.duplicate()
var i: int = _fading_poses.size() - 1
while i >= 0:
var fp: Dictionary = _fading_poses[i]
fp["opacity"] -= delta * fp["fade_speed"]
if fp["opacity"] <= 0.0:
_fading_poses.remove_at(i)
i -= 1
continue
result = PoseLibraryClass.lerp_pose(result, fp["pose"], fp["opacity"])
i -= 1
return result
func _snapshot_bone_globals(skeleton: Skeleton3D) -> Dictionary:
var gx: Transform3D = skeleton.global_transform
var result: Dictionary = {}
for i in range(skeleton.get_bone_count()):
result[skeleton.get_bone_name(i)] = gx * skeleton.get_bone_global_pose(i)
return result
func _apply_pose_to_skeleton(skeleton: Skeleton3D, pose: Dictionary) -> void:
for bone_name: String in pose:
var bone_idx: int = skeleton.find_bone(bone_name)
if bone_idx >= 0:
var xform: Transform3D = pose[bone_name]
skeleton.set_bone_pose_position(bone_idx, xform.origin)
skeleton.set_bone_pose_rotation(bone_idx, xform.basis.get_rotation_quaternion())
skeleton.set_bone_pose_scale(bone_idx, xform.basis.get_scale())
## Overgrowth acceleration tilt (HandleAccelTilt lines 7920-7938, UpdateTilt lines 4236-4241).
## Two components: velocity-based lean + acceleration-based lean.
## Applied as rotation of ALL bones around COM in Phase 10 (not per-bone).
func _update_acceleration_tilt(delta: float) -> void:
var frames: float = delta * 60.0
# During wall climb, suppress normal tilt (undulation handles body motion)
if is_wall_climbing:
_target_tilt = Vector3.ZERO
_accel_tilt *= pow(ACCEL_TILT_DECAY, frames)
_tilt = _tilt * pow(TILT_INERTIA_GROUND, frames)
_old_velocity = velocity
return
if is_grounded:
# Overgrowth: target_tilt = velocity * 0.5 / char_scale
_target_tilt = velocity * 0.5
_target_tilt.y = 0.0 # Only horizontal tilt
# Acceleration tilt: (velocity - old_vel) * 120 / frames, blended with inertia
var accel: Vector3 = (velocity - _old_velocity) * 120.0 / maxf(frames, 0.1)
_accel_tilt = _accel_tilt.lerp(accel, 1.0 - pow(ACCEL_TILT_INERTIA, frames))
if horizontal_speed < 0.1:
# Overgrowth: faster decay when not moving
_target_tilt = Vector3.ZERO
_accel_tilt *= pow(ACCEL_TILT_DECAY, frames)
_target_tilt += _accel_tilt
_target_tilt.y = 0.0
else:
_accel_tilt = Vector3.ZERO
_old_velocity = velocity
# Wall run: lean into wall (tilt toward wall normal direction)
if wall_run_active:
# Lean body toward wall — tilt perpendicular to wall surface
var wall_lean: Vector3 = -wall_run_normal * 8.0 # 8 degrees into wall
wall_lean.y = 0.0
_target_tilt += wall_lean
# Smooth tilt toward target (Overgrowth UpdateTilt lines 4236-4241)
var inertia: float = TILT_INERTIA_GROUND if is_grounded else TILT_INERTIA_AIR
_tilt = _tilt * pow(inertia, frames) + _target_tilt * (1.0 - pow(inertia, frames))
if _tilt.length() > MAX_TILT_DEG:
_tilt = _tilt.normalized() * MAX_TILT_DEG
# ---- Breathing (Overgrowth Breathe() lines 2322-2350) ----
## Sinusoidal chest expansion. Frequency increases with speed, amplitude subtle.
func _update_breathing(skeleton: Skeleton3D, delta: float) -> void:
var frames: float = delta * 60.0
var speed_ratio: float = clampf(horizontal_speed / maxf(run_speed_threshold * 2.0, 0.1), 0.0, 1.0)
# Overgrowth: target_breath_speed = 0.9 + 4.0 * speed_ratio
var target_speed: float = 0.9 + 4.0 * speed_ratio
var breath_inertia: float = lerpf(0.999, 0.99, speed_ratio)
_breath_speed = lerpf(target_speed, _breath_speed, pow(breath_inertia, frames))
_breath_speed = minf(_breath_speed, 5.0)
_breath_time += delta * _breath_speed
# Overgrowth: breath_amount = (sin(breath_time) * 0.5 + 0.5) * lerp(0.5, 1.0, speed/5.0)
_breath_amount = (sin(_breath_time) * 0.5 + 0.5) * lerpf(0.5, 1.0, clampf(horizontal_speed / 5.0, 0.0, 1.0))
# Apply subtle chest scale (DEF-spine.003)
var chest_idx: int = skeleton.find_bone(BONE_MAP["chest"])
if chest_idx >= 0:
var current_scale: Vector3 = skeleton.get_bone_pose_scale(chest_idx)
var breath_scale: float = _breath_amount * 0.02 # Very subtle
skeleton.set_bone_pose_scale(chest_idx, current_scale + Vector3(breath_scale * 0.7, breath_scale, breath_scale * 0.7))
# ---- Idle systems (weight shift + stance amount) ----
func _update_idle_systems(delta: float) -> void:
var frames: float = delta * 60.0
# Update idle stance amount (Overgrowth lines 12079-12083)
if horizontal_speed < 0.3:
_idle_time += delta
_idle_stance_amount = lerpf(1.0, _idle_stance_amount, pow(0.94, frames))
else:
_idle_time = 0.0
_idle_stance_amount = lerpf(0.0, _idle_stance_amount, pow(0.98, frames))
if _idle_stance_amount < 0.01:
return
# Random COM target (every 0.7-4.0 seconds)
_com_shift_timer -= delta
if _com_shift_timer <= 0.0:
_target_com_offset = Vector3(
randf_range(-0.05, 0.05),
randf_range(-0.005, 0.005),
randf_range(-0.01, 0.01))
_com_shift_timer = randf_range(0.7, 4.0)
# Spring toward target (Overgrowth aschar.as:14420-14422).
# ⛔ `* delta`, NOT `* frames`. Overgrowth's term is `5.0f * time_step *
# num_frames` -- the elapsed SECONDS. `frames` is delta*60, which made this
# spring 60x stiffer (k = 300/s^2, damping ratio ~0.05): a 2.8 Hz ring on
# every new random target instead of a slow weight shift.
_com_offset_vel *= pow(0.97, frames)
_com_offset_vel += (_target_com_offset - _com_offset) * 5.0 * delta
_com_offset += _com_offset_vel * delta
# Idle look around: update random gaze direction
_random_look_delay -= delta
if _random_look_delay <= 0.0:
# Pick random direction biased toward facing (Overgrowth lines 4295-4310)
var rand_dir: Vector3 = Vector3(
randf_range(-1.0, 1.0),
randf_range(-0.3, 0.3),
randf_range(-1.0, 1.0)).normalized()
# Ensure roughly forward-facing
if rand_dir.dot(facing_direction) < 0.0:
rand_dir = -rand_dir
_random_look_dir = facing_direction.lerp(rand_dir, 0.5).normalized()
_random_look_delay = randf_range(0.8, 2.0)
## Compute effective look direction, blending camera look with idle random gaze.
func _compute_effective_look() -> Vector3:
if _idle_stance_amount < 0.01 or look_direction.length_squared() < 0.001:
return look_direction
if _random_look_dir.length_squared() < 0.001:
return look_direction
# Blend: more idle = more random gaze, but never fully override camera
return look_direction.lerp(_random_look_dir, _idle_stance_amount * 0.4).normalized()
## Raise arms procedurally for ledge hang (no authored pose needed).
## Rotates upper arms upward to simulate hands gripping ledge.
func _apply_ledge_arm_raise(skeleton: Skeleton3D, gx: Transform3D) -> void:
for side in ["upper_arm_l", "upper_arm_r"]:
var idx: int = skeleton.find_bone(BONE_MAP[side])
if idx < 0:
continue
# Rotate upper arm to point upward (~150 degrees from rest)
var parent_idx: int = skeleton.get_bone_parent(idx)
if parent_idx < 0:
continue
var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
# Target: arms pointing up and slightly forward
var up_rot: Quaternion = Quaternion(Vector3.RIGHT, -2.6) # ~150 degrees forward-up
var local_rot: Quaternion = (parent_global.basis.inverse() * Basis(up_rot) * parent_global.basis).get_rotation_quaternion()
var current_rot: Quaternion = skeleton.get_bone_pose_rotation(idx)
# Blend 70% toward raised position (not fully, to keep some pose influence)
skeleton.set_bone_pose_rotation(idx, current_rot.slerp(local_rot * current_rot, 0.7))
func _apply_grapple_ik(skeleton: Skeleton3D, _delta: float) -> void:
var gx: Transform3D = skeleton.global_transform
# Right arm IK toward grapple anchor direction
var shoulder_idx: int = skeleton.find_bone(BONE_MAP["upper_arm_r"])
var elbow_idx: int = skeleton.find_bone(BONE_MAP["forearm_r"])
var hand_idx: int = skeleton.find_bone(BONE_MAP["hand_r"])
if shoulder_idx < 0 or elbow_idx < 0 or hand_idx < 0:
return
var shoulder_pos: Vector3 = (gx * skeleton.get_bone_global_pose(shoulder_idx)).origin
var elbow_pos: Vector3 = (gx * skeleton.get_bone_global_pose(elbow_idx)).origin
var hand_pos: Vector3 = (gx * skeleton.get_bone_global_pose(hand_idx)).origin
# Measure actual arm length from current bone chain (respects model scale)
var upper_len: float = shoulder_pos.distance_to(elbow_pos)
var lower_len: float = elbow_pos.distance_to(hand_pos)
var arm_length: float = (upper_len + lower_len) * 0.95 # 95% reach — keeps slight bend
# Target: extend arm toward anchor/aim direction (world space)
var target_world: Vector3 = shoulder_pos + grapple_anchor_direction * arm_length
# Convert to skeleton-local space (TwoBoneIK expects skeleton-local, not world)
var gx_inv: Transform3D = gx.affine_inverse()
var target_pos: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, target_world)).origin
# Use TwoBoneIK for shoulder→elbow→hand
TwoBoneIKClass.solve(skeleton, shoulder_idx, elbow_idx, hand_idx, target_pos)
# Spine + leg dangle handled by LegPhysics Verlet (Phase 9.7).
# Left arm trails freely via ArmPhysics grapple mode (Phase 9.5).
## Snake-like lateral spine undulation during lizard walk.
## Spine sways side-to-side in the surface plane, with a traveling wave
## from tail to head. Automatically aligns to whatever surface the body is on.
func _apply_climb_undulation(skeleton: Skeleton3D, delta: float) -> void:
# Use full velocity for speed (not just horizontal — works on walls too)
var climb_speed: float = velocity.length()
if climb_speed < 0.1:
# Smoothly decay wave when stopping
_climb_wave_time *= 0.95
return
_climb_wave_time += delta * climb_speed * CLIMB_WAVE_FREQUENCY
var gx: Transform3D = skeleton.global_transform
# Wave axis = surface_up (perpendicular to surface).
# Rotating spine bones around this axis creates LEFT-RIGHT sway
# within the surface plane — like a snake slithering on the ground.
# On walls: surface_up is horizontal, so sway is still lateral.
# On ceilings: surface_up is DOWN, sway is still in the ceiling plane.
var wave_axis: Vector3 = surface_up
if wave_axis.length_squared() < 0.5:
wave_axis = Vector3.UP
# All spine bones including neck for full snake-like wave
var spine_bones: Array[String] = [
BONE_MAP["hip"], BONE_MAP["spine"], BONE_MAP["chest"], BONE_MAP["neck"]
]
for i in range(spine_bones.size()):
var bone_idx: int = skeleton.find_bone(spine_bones[i])
if bone_idx < 0:
continue
var parent_idx: int = skeleton.get_bone_parent(bone_idx)
if parent_idx < 0:
continue
# Traveling wave: phase offset per bone creates wave from tail→head
var phase: float = _climb_wave_time - float(i) * CLIMB_WAVE_SPINE_PHASE
var angle: float = sin(phase) * CLIMB_WAVE_AMPLITUDE
# Convert world wave axis to bone-local space
var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
var local_axis: Vector3 = (parent_global.basis.inverse() * wave_axis).normalized()
if local_axis.length_squared() < 0.01:
continue
var wave_rot := Quaternion(local_axis, angle)
var current_rot: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
skeleton.set_bone_pose_rotation(bone_idx, wave_rot * current_rot)