OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / arm_physics.gd

225 lines · gd ref:
@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)