@tool
class_name ArmPhysics extends RefCounted
## Overgrowth-style arm secondary physics using Verlet integration.
## Based on DrawArms() lines 12420-12698 from aschar.as.
##
## Each arm is 3 particles (shoulder, elbow, wrist) connected by distance
## constraints. Physics simulation adds follow-through and wobble.
## Stiffness varies by context: loose when idle, stiff in combat/flipping.
# Bone names (Rigify DEF- convention)
const BONES := {
"shoulder_l": "DEF-upper_arm.L",
"elbow_l": "DEF-forearm.L",
"wrist_l": "DEF-hand.L",
"shoulder_r": "DEF-upper_arm.R",
"elbow_r": "DEF-forearm.R",
"wrist_r": "DEF-hand.R",
}
# Physics parameters (Overgrowth lines 12591-12615, tuned stiffer)
const ARM_DRAG: float = 0.85 ## Velocity damping per frame
const STIFFNESS_LOOSE: float = 0.85 ## Loose arms (idle/airborne) — tracks pose closely
const STIFFNESS_STIFF: float = 0.7 ## Stiff arms (combat/flipping) — snaps to pose fast
const CONSTRAINT_STRENGTH: float = 0.9 ## Distance constraint solver strength
# Per-arm state: [left, right], each has [shoulder, elbow, wrist]
var _points: Array = [[], []] ## Current positions
var _old_points: Array = [[], []] ## Previous positions (for Verlet)
var _upper_length: Array = [0.0, 0.0] ## Shoulder→elbow rest length
var _lower_length: Array = [0.0, 0.0] ## Elbow→wrist rest length
var _initialized: bool = false
# Context (set by animator)
var character_velocity: Vector3 = Vector3.ZERO
var max_speed: float = 8.0
var is_grounded: bool = true
var is_flipping: bool = false
var is_grappling: bool = false ## When true: skip right arm, left arm trails freely
var local_up: Vector3 = Vector3.UP ## Gravity direction for dangling
var grapple_anchor_dir: Vector3 = Vector3.UP ## Direction from player toward grapple anchor
var _turbulence_time: float = 0.0 ## Phase for flag-like oscillation
## Initialize from current skeleton pose.
func _init_from_skeleton(skeleton: Skeleton3D, gx: Transform3D) -> void:
for side in range(2):
var suffix: String = "_l" if side == 0 else "_r"
var shoulder_idx: int = skeleton.find_bone(BONES["shoulder" + suffix])
var elbow_idx: int = skeleton.find_bone(BONES["elbow" + suffix])
var wrist_idx: int = skeleton.find_bone(BONES["wrist" + suffix])
if shoulder_idx < 0 or elbow_idx < 0 or wrist_idx < 0:
continue
var s: Vector3 = (gx * skeleton.get_bone_global_pose(shoulder_idx)).origin
var e: Vector3 = (gx * skeleton.get_bone_global_pose(elbow_idx)).origin
var w: Vector3 = (gx * skeleton.get_bone_global_pose(wrist_idx)).origin
_points[side] = [s, e, w]
_old_points[side] = [s, e, w]
_upper_length[side] = s.distance_to(e)
_lower_length[side] = e.distance_to(w)
_initialized = true
## Apply arm physics. Call after poses are written to skeleton.
func apply(skeleton: Skeleton3D, delta: float) -> void:
if skeleton == null:
return
var gx: Transform3D = skeleton.global_transform
if not _initialized:
_init_from_skeleton(skeleton, gx)
if not _initialized:
return
var frames: float = delta * 60.0
if frames < 0.001:
return
if is_grappling:
_turbulence_time += delta * (3.0 + character_velocity.length() * 0.5)
# Get animated target positions from current pose
for side in range(2):
if _points[side].size() != 3:
continue
var suffix: String = "_l" if side == 0 else "_r"
var is_right: bool = (side == 1)
var shoulder_idx: int = skeleton.find_bone(BONES["shoulder" + suffix])
var elbow_idx: int = skeleton.find_bone(BONES["elbow" + suffix])
var wrist_idx: int = skeleton.find_bone(BONES["wrist" + suffix])
if shoulder_idx < 0 or elbow_idx < 0 or wrist_idx < 0:
continue
var shoulder: Vector3 = (gx * skeleton.get_bone_global_pose(shoulder_idx)).origin
var elbow: Vector3 = (gx * skeleton.get_bone_global_pose(elbow_idx)).origin
var wrist: Vector3 = (gx * skeleton.get_bone_global_pose(wrist_idx)).origin
# During grapple: skip right arm entirely (grapple IK handles it)
if is_grappling and is_right:
_upper_length[side] = shoulder.distance_to(elbow)
_lower_length[side] = elbow.distance_to(wrist)
_points[side] = [shoulder, elbow, wrist]
_old_points[side] = [shoulder, elbow, wrist]
continue
# During grapple: left arm handled by LegPhysics._apply_trail_arm()
# (must run AFTER spine Verlet rotates the chest parent bone)
if is_grappling and not is_right:
_points[side] = [shoulder, elbow, wrist]
_old_points[side] = [shoulder, elbow, wrist]
continue
# Update rest lengths for normal mode
_upper_length[side] = shoulder.distance_to(elbow)
_lower_length[side] = elbow.distance_to(wrist)
# ---- Normal (non-grapple) arm physics ----
# Determine looseness (Overgrowth lines 12593-12610)
var arm_loose: float = 1.0 - character_velocity.length() / maxf(max_speed, 0.1)
arm_loose = clampf(arm_loose, 0.0, 1.0)
if not is_grounded:
# Airborne: stiff arms so they track the jump/fall pose closely
# instead of trailing behind from Verlet inertia
arm_loose = 0.15
if is_flipping:
# During roll/flip: skip verlet, snap arms to animated pose
_points[side][0] = shoulder
_points[side][1] = elbow
_points[side][2] = wrist
_old_points[side] = [shoulder, elbow, wrist]
continue
var stiffness: float = lerpf(STIFFNESS_STIFF, STIFFNESS_LOOSE, arm_loose)
# Save old positions
var prev_shoulder: Vector3 = _points[side][0]
var prev_elbow: Vector3 = _points[side][1]
var prev_wrist: Vector3 = _points[side][2]
_old_points[side] = [prev_shoulder, prev_elbow, prev_wrist]
# Shoulder always tracks animation exactly
_points[side][0] = shoulder
# Verlet integration for elbow and wrist (Overgrowth lines 12622-12628)
var full_vel_offset: Vector3 = character_velocity * delta
for i in range(1, 3):
var vel: Vector3 = _points[side][i] - _old_points[side][i]
var relative_vel: Vector3 = vel - full_vel_offset
var damped_vel: Vector3 = relative_vel * pow(ARM_DRAG, frames) + full_vel_offset
_points[side][i] += damped_vel
# Spring force toward animated positions (Overgrowth lines 12632-12637)
var spring_factor: float = 1.0 - pow(stiffness, frames)
_points[side][1] += (elbow - _points[side][1]) * spring_factor
_points[side][2] += (wrist - _points[side][2]) * spring_factor
# Distance constraints (Overgrowth lines 12664-12698)
var constraint_str: float = CONSTRAINT_STRENGTH * (1.0 - arm_loose * 0.5)
for _iter in range(2):
# Upper arm: shoulder → elbow
var s_to_e: Vector3 = _points[side][1] - _points[side][0]
if s_to_e.length_squared() > 0.0001:
var target_e: Vector3 = _points[side][0] + s_to_e.normalized() * _upper_length[side]
_points[side][1] += (target_e - _points[side][1]) * constraint_str
# Lower arm: elbow → wrist
var e_to_w: Vector3 = _points[side][2] - _points[side][1]
if e_to_w.length_squared() > 0.0001:
var target_w: Vector3 = _points[side][1] + e_to_w.normalized() * _lower_length[side]
_points[side][2] += (target_w - _points[side][2]) * constraint_str
# Write back to skeleton using RotateBoneToMatchVec approach
# Rotate upper arm to point from shoulder toward physics elbow
_orient_bone_toward(skeleton, gx, shoulder_idx, _points[side][0], _points[side][1])
# Rotate forearm to point from elbow toward physics wrist
_orient_bone_toward(skeleton, gx, elbow_idx, _points[side][1], _points[side][2])
## Rotate a bone so it points from start toward end.
## Same concept as ChainSolver._rotate_bone_to_match_vec but simplified.
func _orient_bone_toward(skeleton: Skeleton3D, gx: Transform3D,
bone_idx: int, start: Vector3, end: Vector3) -> void:
var target_dir: Vector3 = (end - start).normalized()
if target_dir.length_squared() < 0.001:
return
var bone_global: Transform3D = gx * skeleton.get_bone_global_pose(bone_idx)
var current_dir: Vector3 = bone_global.basis.y.normalized() # Rigify: Y = bone direction
# Compute minimal rotation
var dot: float = current_dir.dot(target_dir)
if dot > 0.9999:
return
var axis: Vector3
var angle: float
if dot < -0.9999:
axis = Vector3.UP.cross(current_dir)
if axis.length_squared() < 0.001:
axis = Vector3.RIGHT.cross(current_dir)
axis = axis.normalized()
angle = PI
else:
axis = current_dir.cross(target_dir).normalized()
angle = current_dir.angle_to(target_dir)
var rotate: Quaternion = Quaternion(axis, angle)
# Apply in bone's parent space
var parent_idx: int = skeleton.get_bone_parent(bone_idx)
if parent_idx < 0:
var current_rot: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
skeleton.set_bone_pose_rotation(bone_idx, rotate * current_rot)
return
var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
var local_rot: Quaternion = (parent_global.basis.inverse() * Basis(rotate) * 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)