Files
Papay-Shooter/characters/skinned_player_model.gd

2632 lines
113 KiB
GDScript

extends Node3D
class_name SkinnedPlayerModel
const MECHA_KIT_SCRIPT := preload("res://characters/anime_mecha_kit.gd")
## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
## with a Mixamo-compatible skeleton and the canonical animation set.
##
## Drop-in replacement for the procedural HumanoidModel:
## - update_state(state, speed, is_crouching) — drives animation selection
## - set_weapon(script_path) — third-person weapon in hand
## - shadows_only — legacy local-player mode
##
## View modes (for the LOCAL player only):
## - first_person_mode = true → model renders shadows-only for the owner, so
## the camera (which sits inside the head) never shows the inside of the
## mesh. Still fully animated; still visible to other players and in shadows.
## - Press the third-person toggle → set_owner_visible(true) makes the full
## animated model visible to the owner too (over-the-shoulder camera).
## - first_person_mode = false → full third-person model for other players.
@export var model_path: String = ""
@export var mecha_theme: String = ""
@export var first_person_mode: bool = false
@export var shadows_only: bool = false
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z
## Ground speed (m/s) encoded by each root-motion source at 1x playback.
## Measured with tools/audit_animation_speeds.py, not eyeballed. The shipped
## clips are in-place, but these reference velocities let the BlendSpace and
## TimeScale reproduce the source stride length without foot sliding.
@export var walk_anim_reference_speed: float = 0.975
@export var run_anim_reference_speed: float = 5.26148
@export var sprint_anim_reference_speed: float = 8.25
## Low-speed authored combat steps and the high-speed anime run are separate
## clips. The old setup put a 1.8 m/s side-step in the high-speed RunLeft/Right
## slots, then time-scaled it more than 6x at the game's 11 m/s ground speed.
@export var strafe_walk_anim_reference_speed: float = 3.0
@export var strafe_run_anim_reference_speed: float = 8.25
@export var wall_run_anim_reference_speed: float = 8.25
@export var crouch_anim_reference_speed: float = 0.75
## Canonical clip names -> fallback chain. First clip that exists wins, so a
## model with only Idle/Walk/Run still animates in every movement state.
const CLIP_FALLBACKS := {
"Idle": ["Idle"],
"Walk": ["Walk", "Run", "Idle"],
"Run": ["Run", "Walk", "Idle"],
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
"Jump": ["Jump", "Fall", "Idle"],
"JumpAlt": ["JumpAlt", "Jump", "Fall", "Idle"],
"Fall": ["Fall", "Jump", "Idle"],
"FallAlt": ["FallAlt", "Fall", "Jump", "Idle"],
"Land": ["Land", "Idle"],
"Stop": ["Stop", "Land", "Idle"],
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
"SlideStart": ["SlideStart", "Slide"],
"Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"],
"SlideExit": ["SlideExit", "Slide"],
"WallRunStart": ["WallRunStart", "WallRunLeft", "Fall"],
"WallRunLeft": ["WallRunLeft", "Fall"],
"WallRunRight": ["WallRunRight", "Fall"],
"WallRunExit": ["WallRunExit", "WallRunLeft", "Fall"],
"WallCling": ["WallCling", "CrouchIdle", "Idle"],
"WallClimb": ["WallClimb", "WallCling", "Jump"],
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
"Dash": ["Dash", "Sprint", "Run", "Idle"],
"StrafeWalkForward": ["StrafeWalkForward", "Walk", "Run", "Idle"],
"StrafeWalkBackward": ["StrafeWalkBackward", "RunBackward", "Walk", "Idle"],
"StrafeWalkLeft": ["StrafeWalkLeft", "RunLeft", "Walk", "Idle"],
"StrafeWalkRight": ["StrafeWalkRight", "RunRight", "Walk", "Idle"],
"RunForward": ["RunForward", "Run", "Walk"],
"RunBackward": ["RunBackward", "Run", "Walk"],
"RunLeft": ["RunLeft", "Run", "Walk"],
"RunRight": ["RunRight", "Run", "Walk"],
"Roll": ["Roll", "Dash"],
"Death": ["Death", "Fall"],
"Hit": ["Hit", "Idle"],
"Dance": ["Dance", "Idle"],
"EmoteStretch": ["EmoteStretch", "Dance", "Idle"],
"EmoteCall": ["EmoteCall", "Dance", "Idle"],
"EmoteYes": ["EmoteYes", "Dance", "Idle"],
"EmoteNo": ["EmoteNo", "Dance", "Idle"],
"PistolIdle": ["PistolIdle", "Idle"],
"PistolShoot": ["PistolShoot"],
"PistolReload": ["PistolReload"],
"Throw": ["Throw", "Hit"],
}
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "FallAlt", "Crouch",
"CrouchIdle", "CrouchWalk", "Slide", "WallRunLeft", "WallRunRight",
"WallCling", "Grapple",
"StrafeWalkForward", "StrafeWalkBackward", "StrafeWalkLeft", "StrafeWalkRight",
"RunForward", "RunBackward", "RunLeft", "RunRight", "Dance",
"EmoteStretch", "EmoteCall", "EmoteYes", "EmoteNo", "PistolIdle"]
const BLEND_TIME := 0.36
## Per-clip blend overrides. Reaction moves still need to read as instant, but
## nothing cuts hard any more — every clip cross-fades. Locomotion gets the
## longest fades because Idle<->Walk<->Run<->Sprint switch constantly as speed
## drifts across their thresholds, and that is where hard cuts were most
## visible.
const BLEND_TIMES := {
"Dash": 0.20, "Jump": 0.12, "JumpAlt": 0.12,
"Fall": 0.12, "FallAlt": 0.12, "Hit": 0.14, "Land": 0.28,
"Stop": 0.12, "SlideStart": 0.12, "Slide": 0.30, "Death": 0.34,
"Throw": 0.20, "PistolReload": 0.30,
"Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40,
"CrouchIdle": 0.40, "CrouchWalk": 0.40,
"WallRunLeft": 0.12, "WallRunRight": 0.12,
"WallCling": 0.32, "Grapple": 0.32,
}
const GROUND_STATE_BLEND := 0.30
## Named gameplay actions -> (clip, lock seconds). Networked via the
## controller's synced action counter.
const ACTIONS := {
"reload": ["PistolReload", 1.15],
"throw": ["Throw", 0.55],
"shoot": ["PistolShoot", 0.2],
"melee": ["Throw", 0.45], # overhand arm swing doubles as the knife slash
}
var skeleton: Skeleton3D
var animation_player: AnimationPlayer
var loaded: bool = false
## Coordinate frame for authored traversal clips. It only aligns the imported
## performance's forward axis with gameplay travel; it never rotates bones or
## manufactures poses.
var _motion_root: Node3D
var _wall_glide_velocity_world := Vector3.ZERO
var _wall_surface_normal_world := Vector3.ZERO
var _wall_surface_point_world := Vector3.ZERO
var _motion_yaw := 0.0
var _motion_was_wall_run := false
var _stop_entry_speed := 0.0
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
var _current_clip: String = ""
## True only for the ignored local evaluation builds made from the copyrighted
## pilot reference. Shipping characters retain the original jet-glide fallback.
var _has_titanfall_motion_reference := false
var _weapon_attachment: BoneAttachment3D
var _grapple_attachment: BoneAttachment3D
var _grapple_shoulder_attachment: BoneAttachment3D
## [child_bone, helper_bone] pairs driven every frame by the pose modifier.
var _joint_helpers: Array = []
## Contents of <model>.rig.json — resolved bone roles, cloth chains, twist pairs
## and leg colliders, written by tools/retarget.py. Empty for a model that was
## rebound onto the library skeleton instead of keeping its own rig.
var _rig_info: Dictionary = {}
## What each surface of this model IS — body, cloth, hair, accessory. Read from
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
var _mecha_kit: AnimeMechaKit
var is_holding_weapon: bool = false
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
## by `_process` (a blade releases the off arm) and by the checks.
var hold_style: String = WeaponHoldProfiles.RIFLE
## Which skin this is, so per-character hold tuning can be looked up. Set by
## whoever spawns the model; falls back to the GLB's basename.
var skin_id: String = ""
## Live hold overrides. Written by debug/rig_lab.gd while tuning and by
## set_weapon() from the saved table otherwise.
var hold_tune: Dictionary = {}
## Live anchor overrides — where the grip sits in the palm, and how the gun
## rolls in the fingers. Same two sources as hold_tune. See RigAnchors.
var anchors: Dictionary = {}
## The weapon's local transform in the hand as `_measure_weapon` left it, before
## the wrist counter-rotation. See `ShooterPoseModifier.wrist_comp_r`.
var _weapon_seat: Transform3D = Transform3D.IDENTITY
# Animation blending: authored locomotion cycles play through synchronized
# speed/direction blend spaces, while traversal states use their own clips;
# gameplay one-shots (reload/throw/shoot/hit) play through an
# AnimationNodeOneShot FILTERED to upper-body bones, so the legs keep
# sliding/running underneath instead of popping to the one-shot's standing legs.
var _anim_tree: AnimationTree
var _state_trans: AnimationNodeTransition
var _loco_blend: AnimationNodeBlendSpace2D
var _crouch_blend: AnimationNodeBlendSpace1D
var _upper_anim: AnimationNodeAnimation
var _current_state_node: String = ""
var _upper_lock: float = 0.0 # seconds the one-shot owns the ARMS
var _upper_total: float = 0.0 # its full duration, for progress 0..1
var _upper_action: String = "" # which ACTIONS entry is playing
## Bone-name fragments that belong to the upper-body one-shot layer.
##
## NECK AND HEAD ARE DELIBERATELY EXCLUDED. The shared library's action clips
## were authored for a character looking at their own hands: PistolReload alone
## dives the head 33° and bends the whole neck chain 67° (measured). At that
## depth Taila's head/hair weights pinch and the skull visibly changes shape —
## which is what "reloading squishes the headshape" was. Nothing is actually
## scaled; no bone in any clip deviates from the rest pose by more than a
## millimetre. Leaving neck+head on the locomotion layer keeps the character
## looking downrange through a reload, which is also what a shooter wants.
const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb",
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003"]
# Mechanical shooter pose layer (aim, recoil, and weapon IK), applied after the
# authored animation by a SkeletonModifier3D.
var _pose_mod: ShooterPoseModifier
var _target_strafe: float = 0.0
var _target_fwd: float = 0.0
var _target_ads: float = 0.0
var _target_wall: float = 0.0
var _loco_blend_target := Vector2.ZERO
var _loco_blend_visual := Vector2.ZERO
var _loco_scale_target := 1.0
var _loco_scale_visual := 1.0
var _crouch_blend_target := 0.0
var _crouch_blend_visual := 0.0
var _cur_ads: float = 0.0
var _cur_wall: float = 0.0
var _owner_visible: bool = false
const POSE_SMOOTH := 10.0
func _ready() -> void:
if model_path != "":
load_model(model_path)
func load_model(path: String) -> void:
for child in get_children():
child.queue_free()
skeleton = null
animation_player = null
_motion_root = null
_wall_glide_velocity_world = Vector3.ZERO
_wall_surface_normal_world = Vector3.ZERO
_wall_surface_point_world = Vector3.ZERO
_motion_yaw = 0.0
_motion_was_wall_run = false
_stop_entry_speed = 0.0
_loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0
_loco_scale_visual = 1.0
_crouch_blend_target = 0.0
_crouch_blend_visual = 0.0
loaded = false
_resolved_clips.clear()
_current_clip = ""
_has_titanfall_motion_reference = false
_current_state_node = ""
_slide_start_remaining = 0.0
_weapon_attachment = null
_grapple_attachment = null
_grapple_shoulder_attachment = null
_mecha_kit = null
# Cleared, not left standing: a model with no skeleton never reaches the
# branch that reloads these, and would otherwise be described by the LAST
# character's sidecar.
_rig_info = {}
_surfaces = null
var scene := GLBLoader.load(path)
if not scene:
push_warning("SkinnedPlayerModel: failed to load '%s'" % path)
return
_motion_root = Node3D.new()
_motion_root.name = "MotionOrientation"
add_child(_motion_root)
_motion_root.add_child(scene)
if facing_flip:
scene.rotation_degrees.y = 180.0
skeleton = _find_node_of_type(scene, "Skeleton3D") as Skeleton3D
animation_player = _find_node_of_type(scene, "AnimationPlayer") as AnimationPlayer
if not skeleton:
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
else:
_ensure_meshes_bound(scene)
_rig_info = _load_rig_info(path)
_apply_rig_animation_speeds()
# `weights_authored` is MEASURED at build time, not inferred from which
# pipeline branch ran: a model that arrives unrigged still gets a
# sidecar, and its nearest-bone weights still need the repair below.
if not _rig_info.get("weights_authored", false):
# Weights were solved, not painted — every vertex went to its
# nearest four bones with no idea which limb it belongs to.
# SkinLegRepair exists solely to undo that, and it is destructive
# (it snaps weights and deletes triangles), so a model that kept its
# ARTIST weights must never be put through it.
var fixed := SkinLegRepair.repair(scene, skeleton)
if fixed[0] > 0 or fixed[1] > 0:
print("SkinnedPlayerModel: '%s' — snapped %d cross-leg vertices, dropped %d bridging triangles"
% [path.get_file(), fixed[0], fixed[1]])
# Joint subdivision runs for EVERY model, however it was rigged. It is
# not a weight repair: linear-blend skinning collapses any joint by
# cos(angle/2) no matter how good the weights are, and dropping it from
# the authored-weight path measured 0.77 at the knee against 0.99 with
# it. See SkinJointHelper.
_joint_helpers = SkinJointHelper.install(scene, skeleton)
_pose_mod = ShooterPoseModifier.new()
_pose_mod.joint_helpers = _joint_helpers
_pose_mod.roles = _rig_info.get("roles", {})
_pose_mod.fingers = _rig_info.get("fingers", {})
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
var grapple_hand := _role_bone(
"hand.L", ["hand.L", "Left wrist", "Left hand"])
var grapple_shoulder := _role_bone(
"upper_arm.L", ["upper_arm.L", "Left upper arm"])
if grapple_hand >= 0:
_grapple_attachment = BoneAttachment3D.new()
_grapple_attachment.name = "GrappleHandSocket"
_grapple_attachment.bone_idx = grapple_hand
skeleton.add_child(_grapple_attachment)
if grapple_shoulder >= 0:
_grapple_shoulder_attachment = BoneAttachment3D.new()
_grapple_shoulder_attachment.name = "GrappleShoulderSocket"
_grapple_shoulder_attachment.bone_idx = grapple_shoulder
skeleton.add_child(_grapple_shoulder_attachment)
# Cloth and hair last, so the springs react to the FINAL body pose —
# authored animation plus the weapon-mechanics layer.
if not _rig_info.is_empty():
_spring_mod = SpringBones.new()
_spring_mod.name = "SpringBones"
skeleton.add_child(_spring_mod)
var driven := _spring_mod.setup(skeleton, _rig_info,
_cloth_hulls(scene))
if driven == 0:
_spring_mod.queue_free()
_spring_mod = null
else:
print("SkinnedPlayerModel: '%s' — %d cloth/hair bones on springs"
% [path.get_file(), driven])
# Cel-shaded look: toon shading over the imported textures...
LevelMaterials.apply_toon_recursive(scene)
# ...then the character-only pass, which is where the surface table earns
# its keep: it says which surfaces are hair, cloth, body or an accessory, so
# each can take its own outline weight and banding instead of all of them
# taking numbers calibrated on skin. It also identifies the model's own
# line-work by name and weight rather than by "is it nearly black", which is
# what used to render a flat-coloured model as a black silhouette.
_surfaces = SkinSurfaces.from_rig_info(_rig_info)
LevelMaterials.apply_character_look(scene, _surfaces)
if skeleton and mecha_theme != "":
_apply_mecha_base_suit(scene)
_mecha_kit = MECHA_KIT_SCRIPT.new()
_mecha_kit.name = "AnimeMechaKit"
add_child(_mecha_kit)
_mecha_kit.setup(skeleton, _rig_info.get("roles", {}), mecha_theme)
if animation_player:
_index_animations()
_setup_anim_tree(scene)
else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
# The local owner renders shadows-only (the camera is inside the head, so
# showing the mesh would show the inside of it). Other players see it fully.
# The third-person toggle calls set_owner_visible(true) to reveal it.
if shadows_only or first_person_mode:
_set_shadows_recursive(self)
loaded = true
_play_ground_locomotion(0.0, false)
## The imported garment is the actual zero-clearance synthetic skin. Recoloring
## that authored, skinned surface is what makes the robotics follow every curve
## and deformation; the procedural kit then only has to add seams and tools.
func _apply_mecha_base_suit(scene: Node) -> void:
if mecha_theme != "sakura" or _surfaces == null:
return
for mesh_node in scene.find_children("*", "MeshInstance3D", true, false):
var mesh_instance := mesh_node as MeshInstance3D
if mesh_instance == null or mesh_instance.mesh == null:
continue
for surface_index in mesh_instance.mesh.get_surface_count():
var source := mesh_instance.mesh.surface_get_material(surface_index) as BaseMaterial3D
if source == null:
continue
var resolved: Array = _surfaces.resolve(
mesh_instance.name, surface_index, source)
var surface_class := str(resolved[0])
var is_garment := surface_class == SkinSurfaces.CLOTH
# Sakura's import keeps the face on material "Head" and the remaining
# anatomy on material "Body". Turning only that second surface into the
# membrane closes the waist, thigh, arm and hand gaps without tinting her
# face or eyes.
var is_body_membrane := surface_class == SkinSurfaces.BODY \
and source.resource_name.to_lower() == "body"
if not is_garment and not is_body_membrane:
continue
var current := mesh_instance.get_surface_override_material(surface_index)
if current is ShaderMaterial:
var membrane := (current as ShaderMaterial).duplicate(true) as ShaderMaterial
membrane.set_shader_parameter("albedo_color",
Color("303852") if is_garment else Color("3b4563"))
membrane.set_shader_parameter("shadow_color", Color("171b31"))
membrane.set_shader_parameter("mid_tone", 0.86)
membrane.set_shader_parameter("rim_strength", 0.13)
mesh_instance.set_surface_override_material(surface_index, membrane)
## Collision hulls for the cloth solver, taken from the MESH rather than from
## the sidecar: bone name -> the points that bone drives, in its own rest space.
##
## The sidecar carries ten farthest-point samples per cloth bone, which is a good
## description of a panel's OUTLINE and a poor one of a panel. Farthest-point
## sampling lands on corners, edges and the hem; a thigh comes up through the
## MIDDLE of a panel, between every sample, and the solver reported each frame's
## contacts fully resolved while 158 vertices sat 95 mm inside a leg.
##
## The runtime has the actual mesh, so it does not have to guess. Every vertex a
## cloth bone dominates is binned into a ~16 mm grid and one representative per
## cell is kept, which covers a panel evenly for a bounded number of points —
## unlike keeping every vertex, which would be thousands of collision tests per
## frame for no extra accuracy at the scale a limb is shaped.
const HULL_CELL := 0.020
const HULL_MAX := 14
func _cloth_hulls(scene: Node) -> Dictionary:
var cloth := {}
for c in _rig_info.get("chains", []):
for n in c.get("bones", []):
var bi := skeleton.find_bone(String(n))
if bi >= 0:
cloth[bi] = true
if cloth.is_empty():
return {}
# bone -> cell key -> the vertex nearest that cell's centre.
var cells := {}
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null or mi.skin == null:
continue
var skin: Skin = mi.skin
var bone_of := {}
for b in skin.get_bind_count():
var bi := skin.get_bind_bone(b)
if bi < 0:
bi = skeleton.find_bone(skin.get_bind_name(b))
bone_of[b] = bi
for s in mi.mesh.get_surface_count():
var arrays: Array = mi.mesh.surface_get_arrays(s)
var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
var bones: PackedInt32Array = arrays[Mesh.ARRAY_BONES]
var weights: PackedFloat32Array = arrays[Mesh.ARRAY_WEIGHTS]
if bones.is_empty() or verts.is_empty():
continue
var per: int = bones.size() / verts.size()
for v in verts.size():
# A vertex belongs to whichever bone holds the largest share of
# it — that is the bone whose motion actually decides where it
# ends up, and so the bone that has to keep it out of a leg.
var best := 0.0
var bind := -1
for k in per:
var w: float = weights[v * per + k]
if w > best:
best = w
bind = bones[v * per + k]
if bind < 0 or best < 0.5:
continue
var bi: int = bone_of.get(bind, -1)
if not cloth.has(bi):
continue
# The bind pose maps a vertex straight into its bone's rest
# space, which is exactly the frame the solver poses hulls in.
var local: Vector3 = skin.get_bind_pose(bind) * verts[v]
var key := "%d_%d_%d" % [
int(round(local.x / HULL_CELL)),
int(round(local.y / HULL_CELL)),
int(round(local.z / HULL_CELL))]
if not cells.has(bi):
cells[bi] = {}
if not cells[bi].has(key):
cells[bi][key] = local
var out := {}
var total := 0
for bi in cells:
var pts: Array = cells[bi].values()
if pts.size() > HULL_MAX:
# Keep the OUTERMOST cells. What clips is the part of a panel
# furthest from the bone it hangs on, and the grid has already made
# sure those are spread over the whole sheet rather than clustered.
pts.sort_custom(func(a, b): return a.length_squared() > b.length_squared())
pts = pts.slice(0, HULL_MAX)
var packed := PackedVector3Array()
for p in pts:
packed.append(p)
out[skeleton.get_bone_name(bi)] = packed
total += packed.size()
print("SkinnedPlayerModel: cloth hulls from mesh — %d bones, %d points"
% [out.size(), total])
return out
## Character-specific authored gait speeds, measured from source root motion
## before it is stripped for gameplay. This keeps native clips at their
## original cadence instead of imposing the generic library's timing.
func _apply_rig_animation_speeds() -> void:
var speeds: Dictionary = _rig_info.get("animation_speeds", {})
if speeds.is_empty():
return
walk_anim_reference_speed = maxf(
float(speeds.get("walk", walk_anim_reference_speed)), 0.01)
run_anim_reference_speed = maxf(
float(speeds.get("run", run_anim_reference_speed)), 0.01)
sprint_anim_reference_speed = maxf(
float(speeds.get("sprint", sprint_anim_reference_speed)), 0.01)
strafe_walk_anim_reference_speed = maxf(
float(speeds.get("strafe_walk", strafe_walk_anim_reference_speed)), 0.01)
strafe_run_anim_reference_speed = maxf(
float(speeds.get("strafe_run", strafe_run_anim_reference_speed)), 0.01)
print("SkinnedPlayerModel: native gait speeds walk=%.2f run=%.2f sprint=%.2f strafe=%.2f/%.2f m/s" % [
walk_anim_reference_speed, run_anim_reference_speed,
sprint_anim_reference_speed, strafe_walk_anim_reference_speed,
strafe_run_anim_reference_speed,
])
## Read the rig sidecar that tools/retarget.py writes next to the GLB.
##
## Its presence is also the signal that this model kept its OWN skeleton and
## authored weights, which is what decides whether the load-time weight repair
## below is needed at all.
func _load_rig_info(model_path: String) -> Dictionary:
var side := model_path.get_basename() + ".rig.json"
if not FileAccess.file_exists(side):
return {}
var text := FileAccess.get_file_as_string(side)
var parsed = JSON.parse_string(text)
if typeof(parsed) != TYPE_DICTIONARY:
push_warning("SkinnedPlayerModel: could not parse '%s'" % side)
return {}
return parsed
## Every [mesh, surface index] of this model belonging to one surface class —
## SkinSurfaces.BODY, CLOTH, HAIR or ACCESSORY.
##
## The point of separating a character into a body, garments and hair is that
## the game can then treat them differently, and it can only do that if it can
## ask which is which. This is that question. It is used by the rig lab to let
## an artist isolate a class, and it is what a damage flash on skin only, or a
## hidden hat, or a per-class LOD would be built on.
func surfaces_of(surface_class: String) -> Array:
var out: Array = []
if _surfaces == null:
return out
for mi in find_children("*", "MeshInstance3D", true, false):
if mi.mesh == null:
continue
for s in mi.mesh.get_surface_count():
var src: BaseMaterial3D = mi.mesh.surface_get_material(s) as BaseMaterial3D
if _surfaces.resolve(mi.name, s, src)[0] == surface_class:
out.append([mi, s])
return out
## The surface table, for tools that want to show or edit it. May be null.
func surface_table() -> SkinSurfaces:
return _surfaces
## Make sure every skinned MeshInstance3D is actually driven by the skeleton.
## A correctly-exported GLB binds automatically, but if one imports with a skin
## resource whose `skeleton` NodePath doesn't resolve, the mesh renders its bind
## pose (a permanent T-pose) while the skeleton animates invisibly. This repairs
## that at load time so a bad export degrades gracefully instead of T-posing.
func _ensure_meshes_bound(scene: Node) -> void:
for mi in scene.find_children("*", "MeshInstance3D", true, false):
if mi.skin == null:
continue # not a skinned mesh
if mi.skeleton.is_empty() or mi.get_node_or_null(mi.skeleton) != skeleton:
mi.skeleton = mi.get_path_to(skeleton)
## Map canonical clip names to whatever actually shipped in the GLB and set
## loop modes (glTF has no loop flag, so we set it here).
func _index_animations() -> void:
var available := animation_player.get_animation_list()
# ReviewWallHangFront is unique to the ignored local Titanfall motion lab.
# A canonical WallRunLeft/Right pair also exists in shipping animation packs
# and must not opt those rigs into the Titanfall-specific side convention.
_has_titanfall_motion_reference = \
_find_clip(available, "ReviewWallHangFront") != ""
_normalize_rotation_track_signs()
for canonical in CLIP_FALLBACKS:
for candidate in CLIP_FALLBACKS[canonical]:
var match_name := _find_clip(available, candidate)
if match_name != "":
_resolved_clips[canonical] = match_name
break
for canonical in LOOPING_CLIPS:
if _resolved_clips.has(canonical):
var anim := animation_player.get_animation(_resolved_clips[canonical])
if anim:
anim.loop_mode = Animation.LOOP_LINEAR
## Quaternions q and -q encode the same pose, but interpolation does not know
## that unless adjacent keys use a consistent sign. A few imported traversal
## tracks contain those sign changes, so Godot interpolates the long arc and
## briefly turns the waist/legs upside down on wall-run entry. Normalize every
## rotation track once after GLB import so the authored pose is preserved while
## interpolation always takes the shortest arc between keys.
func _normalize_rotation_track_signs() -> void:
for animation_name in animation_player.get_animation_list():
var animation := animation_player.get_animation(animation_name)
if animation == null:
continue
for track_index in animation.get_track_count():
if animation.track_get_type(track_index) != Animation.TYPE_ROTATION_3D:
continue
var previous := Quaternion.IDENTITY
var has_previous := false
for key_index in animation.track_get_key_count(track_index):
var value = animation.track_get_key_value(track_index, key_index)
if not value is Quaternion:
continue
var current: Quaternion = value.normalized()
if has_previous and previous.dot(current) < 0.0:
current = -current
animation.track_set_key_value(track_index, key_index, current)
previous = current
has_previous = true
## Runtime blend tree:
##
## authored cycles -> direction/speed BlendSpaces \
## authored traversal/action clips ----------------> state Transition
## -> TimeScale
## -> upper OneShot -> output
##
## The ground blend position is the measured horizontal velocity, so a speed
## change affects the legs on this frame. Cyclic sync keeps every active gait on
## the same foot phase while Idle/Walk/Run/Sprint and cardinal strafes blend.
## There is no delayed tier ladder and no procedural leg pose.
func _setup_anim_tree(scene: Node) -> void:
var bt := AnimationNodeBlendTree.new()
_state_trans = AnimationNodeTransition.new()
_state_trans.xfade_time = BLEND_TIME
_state_trans.allow_transition_to_self = true # lets Land restart itself
bt.add_node("state", _state_trans, Vector2(-120, 0))
_loco_blend = AnimationNodeBlendSpace2D.new()
_loco_blend.min_space = Vector2(-sprint_anim_reference_speed,
-sprint_anim_reference_speed)
_loco_blend.max_space = Vector2(sprint_anim_reference_speed,
sprint_anim_reference_speed)
_loco_blend.snap = Vector2(0.1, 0.1)
_loco_blend.sync_mode = AnimationNodeBlendSpace2D.SYNC_MODE_CYCLIC_MUTABLE
var ground_points := [
["Idle", Vector2.ZERO],
["Walk", Vector2(0.0, walk_anim_reference_speed)],
["StrafeWalkForward", Vector2(0.0, strafe_walk_anim_reference_speed)],
["RunForward", Vector2(0.0, run_anim_reference_speed)],
["Sprint", Vector2(0.0, sprint_anim_reference_speed)],
["StrafeWalkBackward", Vector2(0.0, -strafe_walk_anim_reference_speed)],
["RunBackward", Vector2(0.0, -strafe_run_anim_reference_speed)],
["StrafeWalkLeft", Vector2(-strafe_walk_anim_reference_speed, 0.0)],
["RunLeft", Vector2(-strafe_run_anim_reference_speed, 0.0)],
["StrafeWalkRight", Vector2(strafe_walk_anim_reference_speed, 0.0)],
["RunRight", Vector2(strafe_run_anim_reference_speed, 0.0)],
]
for point in ground_points:
var canonical: String = point[0]
if not _resolved_clips.has(canonical):
continue
var anim_node := AnimationNodeAnimation.new()
anim_node.animation = _resolved_clips[canonical]
_loco_blend.add_blend_point(anim_node, point[1], -1, canonical)
bt.add_node("loco_blend", _loco_blend, Vector2(-520, -100))
_state_trans.add_input("Locomotion")
bt.connect_node("state", 0, "loco_blend")
_crouch_blend = AnimationNodeBlendSpace1D.new()
_crouch_blend.min_space = 0.0
_crouch_blend.max_space = crouch_anim_reference_speed
_crouch_blend.snap = 0.1
_crouch_blend.sync_mode = AnimationNodeBlendSpace1D.SYNC_MODE_CYCLIC_MUTABLE
for point in [["Crouch", 0.0], ["CrouchWalk", crouch_anim_reference_speed]]:
var canonical: String = point[0]
if not _resolved_clips.has(canonical):
continue
var anim_node := AnimationNodeAnimation.new()
anim_node.animation = _resolved_clips[canonical]
_crouch_blend.add_blend_point(anim_node, point[1], -1, canonical)
bt.add_node("crouch_blend", _crouch_blend, Vector2(-520, 80))
_state_trans.add_input("CrouchLocomotion")
bt.connect_node("state", 1, "crouch_blend")
# Direct inputs preserve authored traversal and debug clip playback.
var seen := {}
var idx := 2
for canonical in _resolved_clips:
var clip_name: String = _resolved_clips[canonical]
if seen.has(clip_name):
continue
seen[clip_name] = true
var an := AnimationNodeAnimation.new()
an.animation = clip_name
var node_id := "clip_%d" % idx
bt.add_node(node_id, an, Vector2(-500, idx * 60))
_state_trans.add_input(clip_name)
bt.connect_node("state", idx, node_id)
idx += 1
var ts := AnimationNodeTimeScale.new()
bt.add_node("loco_scale", ts, Vector2(0, 0))
bt.connect_node("loco_scale", 0, "state")
var upper := AnimationNodeOneShot.new()
# The upper-body one-shot pops in and out over the locomotion clip, so its
# own fades matter as much as the locomotion cross-fade.
upper.fadein_time = 0.20
upper.fadeout_time = 0.30
upper.filter_enabled = true
_upper_anim = AnimationNodeAnimation.new()
bt.add_node("upper_clip", _upper_anim, Vector2(0, 240))
bt.add_node("upper", upper, Vector2(220, 0))
bt.connect_node("upper", 0, "loco_scale")
bt.connect_node("upper", 1, "upper_clip")
bt.connect_node("output", 0, "upper")
# Filter = every track whose bone is upper-body. Track paths are identical
# across the library's clips, so sample any one of them.
var sample: Animation = animation_player.get_animation(_resolved_clips.values()[0])
for t in sample.get_track_count():
var p := sample.track_get_path(t)
var bone := String(p.get_concatenated_subnames())
for hint in UPPER_BONE_HINTS:
if bone.findn(hint) != -1:
upper.set_filter_path(p, true)
break
_anim_tree = AnimationTree.new()
_anim_tree.name = "AnimTree"
_anim_tree.tree_root = bt
scene.add_child(_anim_tree)
_anim_tree.anim_player = _anim_tree.get_path_to(animation_player)
_anim_tree.active = true
func _find_clip(available: PackedStringArray, wanted: String) -> String:
for name in available:
if name == wanted:
return name
# Tolerate library prefixes ("mixamo/Run") and case differences.
var wanted_lower := wanted.to_lower()
for name in available:
var base := name.get_slice("/", name.get_slice_count("/") - 1).to_lower()
if base == wanted_lower:
return name
return ""
# ── View modes ────────────────────────────────────────────────────────────────
## Show or hide the model to its OWNER. In first person we render shadows-only
## (on=false) so the camera doesn't see the inside of the mesh; the third-person
## toggle calls this with on=true to reveal the full animated model. Either way
## the model keeps casting shadows and stays visible to other players.
func set_owner_visible(on: bool) -> void:
_owner_visible = on
var mode := GeometryInstance3D.SHADOW_CASTING_SETTING_ON if on \
else GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
_set_shadow_mode_recursive(self, mode)
func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
if node is GeometryInstance3D:
node.cast_shadow = mode
for child in node.get_children():
_set_shadow_mode_recursive(child, mode)
# ── Animation state ───────────────────────────────────────────────────────────
var _prev_state: String = ""
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _slide_start_remaining: float = 0.0
var _dancing: bool = false
## Which authored entry in DanceRoutines.ROUTINES is playing.
var _dance_index: int = 0
## Play a one-shot clip over locomotion for `lock_time` seconds.
## Whole-body moments (Land) briefly own the full skeleton; everything else
## (reload/throw/shoot/hit) plays on the UPPER BODY ONLY through the filtered
## OneShot node, so the legs keep doing whatever the movement state says —
## you can reload mid-slide and stay sliding.
const FULL_BODY_ONESHOTS := ["Land"]
func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
if not loaded or not _resolved_clips.has(canonical):
return
if canonical in FULL_BODY_ONESHOTS or not _anim_tree:
_oneshot_lock = lock_time
_play_clip(canonical, true)
return
_upper_lock = lock_time
_upper_total = lock_time
_upper_anim.animation = _resolved_clips[canonical]
_anim_tree.set("parameters/upper/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)
## Emote toggle (Dance). Shown while grounded and near-idle; any real
## movement breaks it (the controller clears the flag too).
##
## `which` selects one of five separately authored library clips. No runtime
## bone oscillation or procedural emote modifier is involved.
func set_dancing(on: bool, which: int = -1) -> void:
_dancing = on
if which >= 0:
_dance_index = which
# The authored emotes own the full body. Leaving the live weapon attached
# makes otherwise-correct gestures drive a rifle through the face and chest.
# Hide only the third-person prop; the gameplay loadout remains unchanged
# and comes back immediately when the emote ends.
if _weapon_attachment and _weapon_attachment.get_child_count() > 0:
var held_weapon := _weapon_attachment.get_child(0) as Node3D
if held_weapon:
held_weapon.visible = not on
## Play a named gameplay action (reload / throw / shoot) as a one-shot.
func play_action(action: String) -> void:
if ACTIONS.has(action):
_upper_action = action
play_oneshot(ACTIONS[action][0], ACTIONS[action][1])
## Aim pitch in radians (up positive) — the upper body follows the camera.
func set_aim_pitch(pitch: float) -> void:
if _pose_mod:
_pose_mod.aim_pitch = clampf(pitch, -1.2, 1.2)
## Kick the pose recoil (fires on every shot, local echo or remote replay).
func add_gun_recoil(strength: float = 1.0) -> void:
if _pose_mod:
_pose_mod.recoil = minf(_pose_mod.recoil + strength, 1.5)
## Same contract as HumanoidModel.update_state(). Called by the movement
## controller each frame with either local or network-synced state.
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
if not loaded or not animation_player:
return
var previous_state := _prev_state
var exiting_wall_run := previous_state == "wall_run" and state != "wall_run"
# One-shots (Land, Hit) own playback briefly.
if _oneshot_lock > 0.0:
_oneshot_lock -= get_process_delta_time()
if _oneshot_lock > 0.0:
_prev_state = state
return
# A heavy landing plays the Land one-shot before locomotion resumes.
if state in ["ground", "idle"] and _prev_state in ["air", "air_alt"] \
and _vertical_speed() < -12.0 and _resolved_clips.has("Land"):
_oneshot_lock = 0.25
_play_clip("Land")
_prev_state = state
return
_prev_state = state
if state != "slide":
_slide_start_remaining = 0.0
if state == "stop" and previous_state != "stop":
_stop_entry_speed = speed
elif state != "stop":
_stop_entry_speed = 0.0
var clip := "Idle"
match state:
"ground", "idle":
if _dancing and speed < 0.5 and not is_crouching:
clip = DanceRoutines.clip_of(_dance_index)
else:
_play_ground_locomotion(speed, is_crouching)
clip = ""
"air":
# Rising = jump, falling = the fall loop.
clip = "Jump" if _vertical_speed() > 0.5 else "Fall"
"air_alt":
# Offline-mirrored source clips swap the leading knee and arm while
# preserving the exact Titanfall timing and silhouette.
clip = "JumpAlt" if _vertical_speed() > 0.5 else "FallAlt"
"stop":
clip = "Stop"
_loco_scale_target = 1.0
"slide":
if previous_state != "slide":
_slide_start_remaining = _animation_length_if_distinct(
"SlideStart", "Slide") / _slide_playback_scale(speed)
if _slide_start_remaining > 0.0:
clip = "SlideStart"
else:
clip = "Slide"
"wall_run":
# The jets still own propulsion, but the authored pilot pack supplies a
# distinct compact wall-performance for each side. This is body motion,
# not gameplay root motion or procedural feet planted against the wall.
if _has_titanfall_motion_reference:
# The imported pair is mirrored relative to gameplay's wall-side
# convention: the clip named Right is the left-wall performance and
# vice versa. Keep that conversion here rather than swapping physics
# wall_side values, which would break camera tilt and networking.
clip = "WallRunRight" if _target_wall < 0.0 else "WallRunLeft"
else:
clip = "Grapple"
"wall_cling":
clip = "WallCling"
"wall_climb":
clip = "WallClimb"
"grapple":
clip = "Grapple"
"dash":
clip = "Dash"
"death":
clip = "Death"
if clip != "":
var blend_override := -1.0
if state in ["wall_run", "slide", "air", "air_alt", "stop"] \
or exiting_wall_run:
blend_override = _speed_scaled_transition(speed)
_play_clip(clip, false, blend_override)
if _pose_mod:
_pose_mod.state = state
if state != "grapple":
_pose_mod.grapple_active = false
# Traversal clips are authored loops too. Above their reference velocity,
# time-scale the whole cycle so their planted foot/hand cadence still tracks
# gameplay speed; never synthesize a different pose.
if _anim_tree and clip != "":
var scale := 1.0
if clip in ["SlideStart", "Slide"]:
scale = _slide_playback_scale(speed)
elif clip == "Stop":
scale = _stop_playback_scale(_stop_entry_speed)
elif clip == "Grapple" or clip in ["WallRunLeft", "WallRunRight"]:
# Grapple is held, and the wall clips are compact propulsion loops.
# Neither should become a frantic treadmill at high traversal speed.
scale = 1.0
elif clip == "Dash":
scale = clampf(speed / 13.0, 0.75, 1.8)
_anim_tree.set("parameters/loco_scale/scale", scale)
## Drives the authored direction BlendSpace and live ADS mechanic.
## strafe: -1 (moving left) .. +1 (moving right), relative to facing
## fwd: -1 (moving back) .. +1 (moving forward), relative to facing
## ads: 0 (hip) .. 1 (aiming down sights)
func set_locomotion(strafe: float, fwd: float, ads: float) -> void:
_target_strafe = clampf(strafe, -1.0, 1.0)
_target_fwd = clampf(fwd, -1.0, 1.0)
_target_ads = clampf(ads, 0.0, 1.0)
## Wall side during a wall glide: -1 wall on left, +1 wall on right, 0 none.
func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0)
## Retain the live wall plane as traversal telemetry. The authored animation
## remains untouched; this is available to presentation/debug consumers only.
## `normal_world` points from the wall toward the player.
func set_wall_surface(normal_world: Vector3,
contact_point_world: Vector3 = Vector3.ZERO) -> void:
_wall_surface_normal_world = normal_world.normalized() \
if normal_world.length_squared() > 0.0001 else Vector3.ZERO
_wall_surface_point_world = contact_point_world
## Gameplay velocity aligns the held glide silhouette with the live wall
## tangent. The mechanic still reports `wall_run` internally for save/network
## compatibility, but the character presentation is entirely propulsion-based.
func set_wall_glide_motion(velocity_world: Vector3) -> void:
_wall_glide_velocity_world = velocity_world
## Compatibility entry point for older controller scenes and debug tools.
func set_wall_run_motion(velocity_world: Vector3) -> void:
set_wall_glide_motion(velocity_world)
## Regression telemetry: the authored model's forward direction in world space.
func wall_glide_forward_debug() -> Vector3:
if not is_instance_valid(_motion_root):
return Vector3.ZERO
var source_forward := Vector3.FORWARD if facing_flip else Vector3.BACK
return (
global_transform.basis * _motion_root.transform.basis * source_forward
).normalized()
func wall_run_forward_debug() -> Vector3:
return wall_glide_forward_debug()
func set_helmet_closed(closed: bool, immediate: bool = false) -> void:
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("set_helmet_closed"):
_mecha_kit.set_helmet_closed(closed, immediate)
func toggle_helmet() -> void:
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("toggle_helmet"):
_mecha_kit.toggle_helmet()
func helmet_closed_debug() -> bool:
return _mecha_kit.helmet_closed() \
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("helmet_closed") \
else false
func helmet_progress_debug() -> float:
return _mecha_kit.helmet_progress_debug() \
if is_instance_valid(_mecha_kit) and _mecha_kit.has_method("helmet_progress_debug") \
else 0.0
## Live grapple mechanics layered over the authored airborne performance.
## The clip supplies weight and secondary motion; gameplay supplies the one
## constraint an authored fixed pose cannot know: the actual cable direction.
func set_grapple_target(point_world: Vector3,
velocity_world: Vector3 = Vector3.ZERO) -> void:
if not _pose_mod:
return
_pose_mod.grapple_target_world = point_world
_pose_mod.grapple_velocity_world = velocity_world
_pose_mod.grapple_active = true
## Cable socket after animation + grapple IK. BoneAttachment3D observes the
## final modified skeleton pose (unlike a raw pose query made after the
## modifier pass, which has already been restored for the next animation tick).
func get_grapple_hand_world_position() -> Vector3:
if is_instance_valid(_grapple_attachment):
return _grapple_attachment.global_position
if not skeleton:
return global_position
var hand := _role_bone("hand.L", ["hand.L", "Left wrist", "Left hand"])
if hand < 0:
return global_position
return (skeleton.global_transform * skeleton.get_bone_global_pose(hand)).origin
## Test/lab telemetry: hand-to-line error in metres and the physical body
## alignment factor (0 hanging under gravity, 1 aligned into the rope).
func grapple_debug() -> Dictionary:
if not _pose_mod or not skeleton:
return {}
var shoulder_world := _grapple_shoulder_attachment.global_position \
if is_instance_valid(_grapple_shoulder_attachment) \
else global_position
var hand_world := get_grapple_hand_world_position()
var line := _pose_mod.grapple_target_world - shoulder_world
var reach := hand_world - shoulder_world
var error := 0.0
if line.length_squared() > 0.000001:
error = reach.cross(line.normalized()).length()
return {
"line_error": error,
"alignment": _pose_mod.grapple_alignment,
"active": _pose_mod.grapple_active,
"body_angle": _pose_mod._grapple_body.angle_to(Quaternion.IDENTITY),
"body_rotation": _pose_mod._grapple_body,
"shoulder": shoulder_world,
"hand": hand_world,
"target": _pose_mod.grapple_target_world,
}
## How far away each cloth detail level starts, in metres. See SpringBones.lod —
## the solver is expensive enough that only the character being looked at can
## afford the full thing.
const CLOTH_LOD_RANGES := [6.0, 14.0, 28.0]
var _lod_timer: float = 0.0
func _process(delta: float) -> void:
_update_cloth_lod(delta)
_update_motion_orientation(delta)
if _slide_start_remaining > 0.0:
_slide_start_remaining = maxf(_slide_start_remaining - delta, 0.0)
_update_mecha_nozzles()
_update_animation_smoothing(delta)
if not _pose_mod:
return
_hold_weapon_still()
var t := 1.0 - exp(-POSE_SMOOTH * delta)
_cur_ads = lerpf(_cur_ads, _target_ads, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, t)
_pose_mod.ads = _cur_ads
if _upper_lock > 0.0:
_upper_lock -= delta
if _upper_lock <= 0.0:
# Explicitly retire the one-shot. Its `active` parameter does NOT
# reliably clear on its own, and anything still treating the shot
# as live permanently disables the rifle hold — after one reload
# the character would hold the gun with clip arms forever.
_upper_action = ""
if _anim_tree:
_anim_tree.set("parameters/upper/request",
AnimationNodeOneShot.ONE_SHOT_REQUEST_FADE_OUT)
# Two-hand rifle hold: owns the arms whenever a weapon is held, EXCEPT when
# a one-shot clip (reload/throw/hit) or a full-body moment (Land lock,
# dance, death) needs the authored animation to read through. The upper
# one-shot's own `active` flag is the truth for how long it owns the arms.
var st: String = _pose_mod.state
# `_upper_lock` (a timer we own) is the authority on how long the one-shot
# owns the arms — NOT the OneShot node's `active` flag, which can stay
# true indefinitely and would strand the arms on the clip forever.
var reloading := _upper_action == "reload" and _upper_lock > 0.0
# A RELOAD must never hand the right arm to the clip: the gun is parented
# to that hand, and the library's pistol-reload rotates the wrist — which
# flipped the rifle upside-down (mag pointing at the sky) while the hand
# reached "down" for it. During a reload the hold keeps the gun steady and
# the support hand does the magazine work at the real mag well instead.
var clip_owns_arms := (_oneshot_lock > 0.0 or _upper_lock > 0.0 \
or _dancing or st == "death") and not reloading
var hold_r := 0.0
var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms:
hold_r = 1.0
# A one-handed weapon RELEASES the off arm back to the animation, so it
# swings with the run cycle instead of gripping a handguard that is not
# there. That released arm is most of what makes a knife read as a knife
# from across a map.
hold_l = 0.0 if _pose_mod.support_mode == WeaponHoldProfiles.SUPPORT_FREE \
else 1.0
match st:
"slide":
hold_l = 0.0 # trailing arm braces the ground
"wall_run":
# A glide is stabilized by the suit; neither hand needs to touch
# the wall, so weapon posture can remain coherent.
pass
"grapple":
hold_l = 0.0 # left hand rides the grapple line
_pose_mod.hold_r_target = hold_r
_pose_mod.hold_l_target = hold_l
# Reload progress drives the support hand's trip to the mag well.
var rl_target := 0.0
if reloading and _upper_total > 0.0:
rl_target = clampf(1.0 - (_upper_lock / _upper_total), 0.0, 1.0)
_pose_mod.reload_phase = rl_target
func _update_motion_orientation(delta: float) -> void:
if not is_instance_valid(_motion_root):
return
var wall_run_active := _pose_mod != null and _pose_mod.state == "wall_run"
if not wall_run_active:
# This root is only a presentation frame for lateral wall-run clips.
# Blending it back to zero makes the whole character visibly spin on the
# floor when a wall run ends into a slide/ground state.
if _motion_was_wall_run:
_motion_yaw = 0.0
_motion_root.rotation.y = 0.0
_motion_was_wall_run = false
return
_motion_was_wall_run = true
var wanted_yaw := 0.0
if _pose_mod and _pose_mod.state == "wall_run":
var local_velocity := global_transform.basis.inverse() \
* _wall_glide_velocity_world
local_velocity.y = 0.0
if local_velocity.length_squared() > 0.01:
var source_forward := Vector3.FORWARD \
if facing_flip else Vector3.BACK
wanted_yaw = source_forward.signed_angle_to(
local_velocity.normalized(), Vector3.UP)
var blend := 1.0 - exp(-8.5 * delta)
_motion_yaw = lerp_angle(_motion_yaw, wanted_yaw, blend)
_motion_root.rotation.y = _motion_yaw
func _animation_length_if_distinct(start: String, loop: String) -> float:
if not _resolved_clips.has(start) or not _resolved_clips.has(loop):
return 0.0
var start_name: String = _resolved_clips[start]
var loop_name: String = _resolved_clips[loop]
if start_name == loop_name:
return 0.0
var clip := animation_player.get_animation(start_name)
return clip.length if clip else 0.0
func _slide_playback_scale(speed: float) -> float:
# SlideStart is part of the same momentum gesture as Slide. It must advance
# with the entry velocity too, otherwise a fast slide spends a full authored
# second in a slow crouch pose before the loop catches up.
return clampf(speed / maxf(run_anim_reference_speed, 0.01), 0.75, 1.8)
func _stop_playback_scale(entry_speed: float) -> float:
# Finish the weight-catching step while the physical stopping glide still
# has momentum. Faster entries compress the one-shot without hard-cutting it.
return clampf(entry_speed / maxf(run_anim_reference_speed, 0.01), 1.0, 2.2)
func _speed_scaled_transition(speed: float) -> float:
# High-speed traversal needs a quicker silhouette handoff, but never a
# zero-frame snap. At ordinary run speed this is ~0.12 s; near the movement
# cap it approaches 0.07 s.
var speed_factor := clampf((speed - 3.0) / 15.0, 0.0, 1.0)
return lerpf(0.18, 0.07, speed_factor)
## Pick the cloth solver's detail level from how far the camera is.
##
## Re-checked a few times a second rather than every frame: the answer changes
## slowly, and the distance query is not free either.
func _update_cloth_lod(delta: float) -> void:
if _spring_mod == null:
return
_lod_timer -= delta
if _lod_timer > 0.0:
return
_lod_timer = 0.25
var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
if cam == null:
return
var d := cam.global_position.distance_to(global_position)
var want := CLOTH_LOD_RANGES.size()
for i in CLOTH_LOD_RANGES.size():
if d < CLOTH_LOD_RANGES[i]:
want = i
break
_spring_mod.lod = want
## Drive authored ground cycles directly from the current velocity.
##
## The old tier ladder could spend 0.34 s in Idle and another 0.34 s in Walk
## even though gameplay acceleration reached running speed in one physics frame.
## That is the visible "sliding, then slow walk, then run" failure. Here the
## BlendSpace position is physical metres/second on this call, so the feet react
## immediately but ease over a few rendered frames instead of teleporting from
## one gait/direction pose to another.
func _update_animation_smoothing(delta: float) -> void:
if not _anim_tree:
return
var current_speed := _loco_blend_visual.length()
var target_speed := _loco_blend_target.length()
var reversing := current_speed > 0.2 and target_speed > 0.2 \
and _loco_blend_visual.dot(_loco_blend_target) < 0.0
var response := 10.0 if target_speed > current_speed else 6.5
if reversing:
response = 5.5
var position_weight := 1.0 - exp(-response * delta)
_loco_blend_visual = _loco_blend_visual.lerp(
_loco_blend_target, position_weight)
_crouch_blend_visual = lerpf(_crouch_blend_visual,
_crouch_blend_target, 1.0 - exp(-8.0 * delta))
_loco_scale_visual = lerpf(_loco_scale_visual,
_loco_scale_target, 1.0 - exp(-7.0 * delta))
_anim_tree.set("parameters/loco_blend/blend_position", _loco_blend_visual)
_anim_tree.set("parameters/crouch_blend/blend_position", _crouch_blend_visual)
if _current_state_node in ["Locomotion", "CrouchLocomotion"]:
_anim_tree.set("parameters/loco_scale/scale", _loco_scale_visual)
func _play_ground_locomotion(speed: float, crouched: bool) -> void:
if not _anim_tree:
return
var state_node := "CrouchLocomotion" if crouched else "Locomotion"
if _current_state_node != state_node:
var exiting_wall_run := _current_state_node in [
"WallRunLeft", "WallRunRight"]
var exiting_stop: bool = \
_current_state_node == String(_resolved_clips.get("Stop", ""))
if exiting_wall_run:
_state_trans.xfade_time = _speed_scaled_transition(speed)
elif exiting_stop:
# The stop one-shot has already absorbed the residual momentum. Enter
# the Idle point directly under the crossfade; carrying Sprint's stale
# BlendSpace coordinate forward would create a final fast run cycle.
_loco_blend_target = Vector2.ZERO
_loco_blend_visual = Vector2.ZERO
_loco_scale_target = 1.0
_loco_scale_visual = 1.0
_anim_tree.set("parameters/loco_blend/blend_position", Vector2.ZERO)
_anim_tree.set("parameters/loco_scale/scale", 1.0)
_state_trans.xfade_time = 0.18
else:
_state_trans.xfade_time = GROUND_STATE_BLEND
_anim_tree.set("parameters/state/transition_request", state_node)
_current_state_node = state_node
if crouched:
var authored_speed := minf(speed, crouch_anim_reference_speed)
_crouch_blend_target = authored_speed
_loco_scale_target = maxf(1.0,
speed / maxf(crouch_anim_reference_speed, 0.01))
_current_clip = _resolved_clips.get(
"CrouchWalk" if speed > 0.1 else "Crouch", "")
return
var direction := Vector2(_target_strafe, _target_fwd)
if direction.length_squared() < 0.0001:
direction = Vector2(0.0, 1.0)
else:
direction = direction.normalized()
# Each axis is positioned at its measured source velocity. The edge of the
# BlendSpace is therefore a velocity envelope, not an arbitrary circle:
# solve where this heading intersects the line between its two neighboring
# authored clips. Above that speed, TimeScale advances the authored cycle
# proportionally instead of letting the mesh slide over planted feet.
var forward_limit := sprint_anim_reference_speed \
if direction.y > 0.0 else strafe_run_anim_reference_speed
var inverse_limit := (
absf(direction.x) / maxf(strafe_run_anim_reference_speed, 0.01)
+ absf(direction.y) / maxf(forward_limit, 0.01)
)
var authored_limit := 1.0 / maxf(inverse_limit, 0.01)
var authored_speed := minf(speed, authored_limit)
_loco_blend_target = direction * authored_speed
_loco_scale_target = maxf(1.0,
speed / maxf(authored_limit, 0.01))
var canonical := "Idle"
if speed > 0.1:
if absf(direction.x) > absf(direction.y):
canonical = "RunRight" if direction.x > 0.0 else "RunLeft"
elif direction.y < 0.0:
canonical = "RunBackward"
elif speed < (walk_anim_reference_speed + run_anim_reference_speed) * 0.5:
canonical = "Walk"
elif speed < (run_anim_reference_speed + sprint_anim_reference_speed) * 0.5:
canonical = "RunForward"
else:
canonical = "Sprint"
_current_clip = _resolved_clips.get(canonical, "")
func _play_clip(canonical: String, restart: bool = false,
blend_override: float = -1.0) -> void:
if not _anim_tree or not _resolved_clips.has(canonical):
return
var clip_name: String = _resolved_clips[canonical]
if not restart and _current_state_node == clip_name:
return
_state_trans.xfade_time = blend_override \
if blend_override >= 0.0 else BLEND_TIMES.get(canonical, BLEND_TIME)
_anim_tree.set("parameters/state/transition_request", clip_name)
_current_state_node = clip_name
_current_clip = clip_name
## Procedural locomotion lean was removed; kept for regression compatibility.
func get_lean_debug() -> float:
return 0.0
func get_brake_debug() -> float:
return 0.0
## Smoothed visual velocity currently driving the authored ground BlendSpace.
func locomotion_blend_debug() -> Vector2:
if not _anim_tree:
return Vector2.ZERO
var value = _anim_tree.get("parameters/loco_blend/blend_position")
return value if value is Vector2 else Vector2.ZERO
func locomotion_blend_target_debug() -> Vector2:
return _loco_blend_target
## Visual velocity represented by the authored stride after playback-rate calibration.
## The raw BlendSpace position stops at the source clip's measured velocity;
## TimeScale covers faster gameplay without changing the pose or foot phase.
func locomotion_effective_speed_debug() -> float:
if not _anim_tree:
return 0.0
var scale_value = _anim_tree.get("parameters/loco_scale/scale")
var scale := float(scale_value) if scale_value != null else 1.0
return locomotion_blend_debug().length() * scale
## Animated world-space anchors for the two shoulder and two foot jets.
## Offsets use the skeleton frame, not each limb's twisting frame, so the
## nozzles stay on the character's back/heels through every authored clip.
func _animated_jet_socket_world_positions() -> Array[Vector3]:
if not skeleton:
return []
var roles_and_fallbacks := [
["shoulder.L", ["shoulder.L", "upper_arm.L", "Left shoulder"]],
["shoulder.R", ["shoulder.R", "upper_arm.R", "Right shoulder"]],
["foot.L", ["foot.L", "Left ankle", "Left foot"]],
["foot.R", ["foot.R", "Right ankle", "Right foot"]],
]
var sockets: Array[Vector3] = []
for i in roles_and_fallbacks.size():
var spec: Array = roles_and_fallbacks[i]
var bone := _role_bone(spec[0], spec[1])
if bone < 0:
return []
var world_pose := skeleton.global_transform * skeleton.get_bone_global_pose(bone)
# The shoulder pair sits just outside the back silhouette. This extra
# clearance matters for long-haired skins: mounting at the literal joint
# leaves both flames completely buried in the hair mesh.
var side := 0.10 if i == 0 else -0.10
var offset := skeleton.global_transform.basis \
* (Vector3(side, -0.03, -0.30) if i < 2 \
else Vector3(0.0, 0.035, -0.045))
sockets.append(world_pose.origin + offset)
return sockets
func _update_mecha_nozzles() -> void:
if not is_instance_valid(_mecha_kit) or not skeleton:
return
var sockets := _animated_jet_socket_world_positions()
if sockets.size() == 4:
_mecha_kit.update_nozzles(sockets, skeleton.global_transform.basis)
func get_jet_socket_world_positions() -> Array[Vector3]:
var sockets := _animated_jet_socket_world_positions()
if is_instance_valid(_mecha_kit) and sockets.size() == 4:
_mecha_kit.update_nozzles(sockets, skeleton.global_transform.basis)
return _mecha_kit.nozzle_world_positions()
return sockets
func mecha_nozzle_count_debug() -> int:
return _mecha_kit.nozzle_count() if is_instance_valid(_mecha_kit) else 0
func mecha_detail_part_count_debug() -> int:
return _mecha_kit.detail_part_count_debug() \
if is_instance_valid(_mecha_kit) else 0
## Every canonical clip this character resolved to something real.
##
## Canonical rather than raw, because the raw names differ per character — one
## rig's "CrouchIdle" is another's "Crouch_Idle_Loop" — and the canonical name is
## what the game asks for. For debug/rig_lab.gd's clip scrubber.
func clip_names_debug() -> Array:
return _resolved_clips.keys()
## Play one clip outright, ignoring the locomotion state machine. Lab only.
func play_clip_debug(canonical: String) -> void:
_play_clip(canonical, true)
## Push a new hold tuning table in and re-seat the weapon with it.
##
## For debug/rig_lab.gd: the knobs that live on the pose layer take effect on
## the next frame, but weapon SIZE and the grip offset are baked into the
## attachment when the weapon is seated, so those need the weapon re-measured.
func set_hold_tuning(t: Dictionary) -> void:
hold_tune = t
if _pose_mod:
_pose_mod.tune = t
_reseat_weapon()
## Push a new anchor table in and re-seat the weapon on it.
##
## Separate from set_hold_tuning because the two are separate questions with
## separate scopes — a hold is per character AND weapon, an anchor is per
## character — and the lab edits them on different screens.
func set_anchors(a: Dictionary) -> void:
anchors = a
_reseat_weapon()
## Keep the weapon where the solver put it while the wrist turns under it.
##
## The two are welded by construction — the gun is a child of a BoneAttachment3D
## on the trigger hand — so a wrist rotation swings the barrel off the aim line
## and takes every control that could correct it along for the ride. The pose
## layer works out the exact counter-rotation in the hand's own local frame;
## this applies it.
##
## Deliberately in `_process` rather than inside the modifier pass. The gun's
## mount is not something the skeleton owns, and the value being compensated
## only changes when a slider moves or the ADS blend travels, so being one frame
## behind is a rotation of a fraction of a degree that nothing can see. Reaching
## into the modifier to touch a scene node would be worse.
func _hold_weapon_still() -> void:
if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0:
return
var w := _weapon_attachment.get_child(0) as Node3D
if w == null:
return
var comp: Quaternion = _pose_mod.wrist_comp_r
w.transform = Transform3D(Basis(comp), Vector3.ZERO) * _weapon_seat
## Re-apply the grip anchor and re-measure, after either table changed.
##
## `_measure_weapon` reads the weapon's transform to work out where its grip and
## muzzle are, so the anchor has to be back in place BEFORE it runs — measuring
## from identity and then offsetting would move the gun without moving the
## points the hands are being solved onto.
func _reseat_weapon() -> void:
if _weapon_attachment == null or _weapon_attachment.get_child_count() == 0:
return
var w := _weapon_attachment.get_child(0) as Node3D
if w:
w.transform = RigAnchors.grip_transform(anchors)
_measure_weapon(w)
## Compatibility probe: authored direction clips always play forward.
func stride_reversed_debug() -> bool:
return false
## Clip currently playing. For debug/transition_check.gd.
func current_clip_debug() -> String:
return _current_clip
## Vertical velocity of the body this model is attached to (0 if detached).
func _vertical_speed() -> float:
var p := get_parent()
if p is CharacterBody3D:
return p.velocity.y
return 0.0
# ── Third-person weapon ───────────────────────────────────────────────────────
## Attach a weapon (by weapon script path) to the right hand bone so other
## players see what this player is holding. Mirrors HumanoidModel.set_weapon().
func set_weapon(script_path: String) -> void:
if _weapon_attachment:
_weapon_attachment.queue_free()
_weapon_attachment = null
is_holding_weapon = script_path != ""
if script_path == "" or not skeleton:
return
# How this KIND of weapon is held, then the per-character tuning on top.
#
# The profile is a defaults layer: it answers "what sort of thing is this"
# for a weapon nobody has tuned, and every knob an artist saved in the rig lab
# still wins, because the JSON is merged over it with overwrite. Aria's
# hand-tuned AK-47 hold is byte-for-byte what it was.
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
var weapon_id := script_path.get_file().get_basename()
if hold_tune.is_empty():
hold_tune = WeaponHoldProfiles.knobs_for(weapon_id)
hold_tune.merge(WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(),
sid, weapon_id), true)
if _pose_mod:
# Structural, not tunable — see WeaponHoldProfiles. These decide where the
# off hand goes and how it is turned there, and whether the head comes
# down to the stock, which no slider on the rifle solve could express.
var style := WeaponHoldProfiles.style_for(weapon_id)
_pose_mod.support_mode = WeaponHoldProfiles.support_for(weapon_id)
_pose_mod.cheek = WeaponHoldProfiles.cheek_for(weapon_id)
_pose_mod.full_fist = style == WeaponHoldProfiles.BLADE
hold_style = WeaponHoldProfiles.style_for(weapon_id)
# Anchors are per character, not per weapon — where a grip sits in a palm is
# a fact about the hand — so unlike hold_tune they are not re-read per gun
# unless the lab has pushed a live set in.
if anchors.is_empty():
anchors = RigAnchors.resolve(RigAnchors.load_all(), sid)
var script = load(script_path)
if not script:
return
var w = script.new()
w.name = "ThirdPersonWeapon"
w.set_meta("is_third_person_weapon", true)
w.ready.connect(func():
w.set_process(false)
w.set_process_input(false)
# Owner's first-person view must not see their own held weapon (it
# sits right in front of the lens as a huge blob) — shadows only,
# same as the body. Skip when the owner is already in third person
# (weapon swap while toggled), else the new weapon comes up invisible.
if (shadows_only or first_person_mode) and not _owner_visible:
_set_shadows_recursive(w)
# Seat the weapon in the hand with NO hand-relative rotation.
#
# There used to be a fixed `rotation_degrees = (0, 90, -90)` here, which
# is the offset that happens to be right for the library's own DEF-hand.R
# and is wrong for every rig whose hand bone has a different roll. A bone
# attachment is expressed in the BONE's axes, and no two rigs agree on
# those, so a constant here mounts the gun differently on every character
# — which is what "the hand mount points are totally wrong on all models"
# was.
#
# It does not need to be right, because the pose layer aims the gun by
# rotating the WRIST until the weapon's forward axis lies on the aim line
# (see _apply_rifle_hold). Handing it the identity means "the gun's
# forward is the hand bone's -Z", which is true by construction on any
# rig, and the wrist then absorbs whatever that bone's roll happens to be.
# The grip is placed at the bone's origin below, so the gun sits IN the
# hand rather than at a fixed offset from a differently-oriented bone.
#
# ...with one adjustment on top: the character's own grip ANCHOR. A hand
# bone's origin is the wrist, not the palm, and how far down the palm a
# grip should sit is a fact about that character's hand — how big it is,
# how the fingers were modelled — which cannot be derived. It defaults to
# identity, so a character nobody has tuned behaves exactly as before.
w.transform = RigAnchors.grip_transform(anchors)
if _pose_mod:
_pose_mod.gun_fwd_hand = Vector3(0, 0, -1)
_pose_mod.gun_up_hand = Vector3(0, 1, 0)
_measure_weapon(w)
)
var hand_idx := _role_bone("hand.R", ["RightHand", "Hand_R", "hand.R"])
if hand_idx >= 0:
_weapon_attachment = BoneAttachment3D.new()
_weapon_attachment.name = "WeaponAttachment"
skeleton.add_child(_weapon_attachment)
_weapon_attachment.bone_idx = hand_idx
_weapon_attachment.add_child(w)
else:
# No hand bone — hold it at chest height like the procedural model did.
w.ready.connect(func():
w.position = Vector3(-0.15, 1.0, 0.4)
w.rotation_degrees = Vector3(0, 180, 0)
)
add_child(w)
## The muzzle of the gun actually in this character's hand.
##
## Anything the WORLD sees — tracers, muzzle flash, the shot's audio position —
## has to originate here whenever the character model is what the viewer is
## looking at. The first-person viewmodel is parented to the camera, so ITS
## muzzle sits inside the player's head; using it in third person put the flash
## next to the character's shoulder.
func get_muzzle_node() -> Node3D:
if not _weapon_attachment or _weapon_attachment.get_child_count() == 0:
return null
var w := _weapon_attachment.get_child(0)
if "muzzle_flash" in w and w.muzzle_flash:
return w.muzzle_flash
return w as Node3D
## Seat the weapon in the hand and tell the pose layer where the support hand
## and stock are, using the weapon's OWN authored markers.
##
## This used to derive everything from mesh AABBs, which silently produced
## nonsense: the FBX guns report bind-pose bounds tens of metres across (the M4
## measured 24 m long), so the grip offset threw the gun 7.5 m in front of the
## character. Nothing about a mesh's bounding box is trustworthy here.
##
## The reliable data is already authored: WeaponManager places the first-person
## viewmodel's hands at GRIP_LOCAL and SUPPORT_LOCAL in weapon space, and every
## weapon sets muzzle_flash.position at its barrel tip. Third person simply
## reaches for the same points the viewmodel does.
## How far a weapon may be scaled down before it reads as a toy.
const MIN_WEAPON_SCALE := 0.55
## Forward offset of the stock pocket from the shoulder joint — see POCKET_HIP.
const POCKET_FORWARD := 0.06
## Scale the weapon so this character can actually reach its handguard.
##
## Not against a fixed reference size: against the arm that has to hold it. The
## set is modelled at real-world scale — an M4 is 0.84 m butt to muzzle — and
## these characters are stylised, with arms around 0.47 m against an adult 0.52.
##
## The binding constraint is the SUPPORT arm. Its hand has to reach a point
## (stock + fore) in front of the shoulder pocket, from a shoulder half the
## shoulder-width off the weapon's axis. Solve that triangle for the largest gun
## whose handguard still lands inside the arm's reach, and the support hand stops
## being dragged back down the barrel.
##
## Measured on Taila before this: a support offset authored at 0.35 m collapsed
## to 0.083 m, which puts the support fist on top of the trigger fist — a
## two-handed pistol grip, not a rifle. That is what "the hands are not on the
## rails" was.
func _weapon_scale(span: float) -> float:
if not _pose_mod or not skeleton or span <= 0.01:
return 1.0
var reach: float = _pose_mod._arm_reach(skeleton, "DEF-upper_arm.L",
"DEF-forearm.L", "DEF-hand.L") * 0.94
if reach <= 0.01:
return 1.0
var ua_l: int = _pose_mod._idx.get("DEF-upper_arm.L", -1)
var ua_r: int = _pose_mod._idx.get("DEF-upper_arm.R", -1)
var lateral := 0.14
if ua_l >= 0 and ua_r >= 0:
# The weapon rides near the right shoulder, so the support arm crosses
# about half the shoulder width to get to it.
lateral = skeleton.get_bone_global_rest(ua_l).origin.distance_to(
skeleton.get_bone_global_rest(ua_r).origin) * 0.5
var forward := sqrt(maxf(reach * reach - lateral * lateral, 0.01))
return clampf((forward - POCKET_FORWARD) / span, MIN_WEAPON_SCALE, 1.0)
func _measure_weapon(w: Node3D) -> void:
var grip: Vector3 = WeaponGrips.GRIP
var support: Vector3 = WeaponGrips.SUPPORT
var fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis
# Barrel length, grip to muzzle. Every weapon marks its own barrel tip.
var muzzle_dist: float = WeaponGrips.DEFAULT_MUZZLE_DIST
if "muzzle_flash" in w and w.muzzle_flash:
muzzle_dist = maxf(absf((w.muzzle_flash.position - grip).dot(fwd)), 0.1)
# Support hand: where the viewmodel's off hand rides — but NEVER past the
# muzzle. That offset is one constant for all weapons, so on a short gun
# (the MP7's barrel is only 0.30 m) it used to hang the support hand out in
# front of the barrel with nothing to hold, which is why some weapons did
# not look supported by the arms.
var fore: float = absf((support - grip).dot(fwd))
var fore_u: float = clampf(minf(fore, muzzle_dist * 0.8), 0.12, 0.45)
var stock_u: float = clampf(muzzle_dist * 0.5, 0.10, 0.40)
# Size the gun to the arm that has to hold it, then seat the grip in the fist.
var k := _weapon_scale(stock_u + fore_u)
# A tuned size wins over the derived one. The derivation keeps the handguard
# inside the support arm's reach, which is a floor on believability, not a
# statement about how big the gun should LOOK.
if hold_tune.get("weapon_scale", 0.0) > 0.01:
k = float(hold_tune["weapon_scale"])
w.scale = Vector3.ONE * k
w.position -= w.transform.basis * grip
_pose_mod.gun_fore = fore_u * k
if hold_tune.get("gun_fore", 0.0) > 0.0001:
_pose_mod.gun_fore = float(hold_tune["gun_fore"])
# Stock: not authored anywhere, so derive it from the barrel. Half the
# grip-to-muzzle distance behind the grip lands the butt in the shoulder
# pocket for every gun in the set.
_pose_mod.gun_stock = stock_u * k
if hold_tune.get("gun_stock", 0.0) > 0.0001:
_pose_mod.gun_stock = float(hold_tune["gun_stock"])
_pose_mod.tune = hold_tune
# Where the weapon sits in the hand once everything derived and tuned has
# been applied. Kept because `_process` re-derives the mount every frame from
# it plus the wrist counter-rotation, and recomputing the seat instead would
# re-run this whole measurement sixty times a second.
_weapon_seat = w.transform
# ── Helpers ───────────────────────────────────────────────────────────────────
## A bone by its resolved ROLE, falling back to name matching.
##
## tools/rig_map.py resolves every rig to roles and writes them to the sidecar so
## that nothing downstream has to guess a bone name — but a hardcoded lookup here
## meant four characters could not hold a gun. Their hands resolve perfectly as
## "Right wrist" and "J_Bip_R_Hand"; none of them matches a spelling this file
## knew, so set_weapon fell through to parenting the weapon to the model root at
## a fixed chest offset, where it is not attached to the character at all.
##
## The fallback stays for a model with no sidecar. It must never be the first
## thing tried.
func _role_bone(role: String, fallbacks: Array) -> int:
var roles: Dictionary = _rig_info.get("roles", {})
var actual := String(roles.get(role, ""))
if actual != "" and skeleton:
var b := RigRoles.find_imported_bone(skeleton, actual)
if b >= 0:
return b
return _find_bone(fallbacks)
func _find_bone(name_parts: Array) -> int:
if not skeleton:
return -1
for i in range(skeleton.get_bone_count()):
var bone_name := skeleton.get_bone_name(i)
for part in name_parts:
if bone_name.findn(part) != -1:
return i
return -1
func _find_node_of_type(node: Node, type_name: String) -> Node:
if node.is_class(type_name):
return node
for child in node.get_children():
var found := _find_node_of_type(child, type_name)
if found:
return found
return null
func _set_shadows_recursive(node: Node) -> void:
if node is GeometryInstance3D:
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
for child in node.get_children():
_set_shadows_recursive(child)
# ── Mechanical shooter pose layer ─────────────────────────────────────────────
#
# Authored clips own body motion. This modifier handles only mechanics that must
# follow live gameplay data: aim pitch, weapon IK, cheek weld, finger grip, and
# recoil.
class ShooterPoseModifier extends SkeletonModifier3D:
# Inputs, written by the owning SkinnedPlayerModel each frame.
var ads: float = 0.0 # 0 hip .. 1 aiming
var aim_pitch: float = 0.0 # radians, up positive — upper body follows aim
var recoil: float = 0.0 # decaying shot kick
var state: String = "idle"
# Grapple target/velocity arrive in world space from the movement state.
# They are converted to skeleton space at solve time, after the authored
# animation has placed the shoulders.
var grapple_active: bool = false
var grapple_target_world: Vector3 = Vector3.ZERO
var grapple_velocity_world: Vector3 = Vector3.ZERO
var grapple_alignment: float = 0.0
var _grapple_body: Quaternion = Quaternion.IDENTITY
# Per-arm rifle-hold weights (0 = clip owns the arm, 1 = hold pose owns it).
var hold_r_target: float = 0.0
var hold_l_target: float = 0.0
var _hold_r: float = 0.0
var _hold_l: float = 0.0
# The attached gun's forward/up axes in hand-bone space (set on set_weapon).
var gun_fwd_hand: Vector3 = Vector3.ZERO
var gun_up_hand: Vector3 = Vector3.UP
# Measured gun geometry (metres from the grip): how far out the support
# hand rides, and how far back the stock butt reaches. See _measure_weapon.
var gun_fore: float = 0.26
var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back.
var reload_phase: float = 0.0
# What KIND of weapon is being held. See WeaponHoldProfiles: these three are
# the differences a slider cannot express, and they are what make a launcher
# read as a launcher rather than as a very large rifle.
var support_mode: String = WeaponHoldProfiles.SUPPORT_BARREL
## How far the head comes down and across to the stock, 0..1. Negative leans
## it away, which is what a tube over the shoulder needs.
var cheek: float = 0.0
## Whether the trigger finger closes with the rest. True for a blade, which
## has nothing to keep a finger straight along.
var full_fist: bool = false
# [child_bone, helper_bone] pairs; see SkinJointHelper.
var joint_helpers: Array = []
const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms
## The library skeleton's spine, hips first. Shared with the dance layer via
## RigRoles, which also owns the mapping onto a rig that kept its own names.
const SPINE := RigRoles.SPINE
var _idx: Dictionary = {}
var _resolved := false
## Role -> actual bone name for THIS rig, from <model>.rig.json. Empty when
## the model was rebound onto the library skeleton, where the names below
## already match.
var roles: Dictionary = {}
## "index.L" -> its bone names, knuckle to fingertip, from the same sidecar.
var fingers: Dictionary = {}
## Per-character hold overrides — see characters/weapon_hold_tuning.gd and
## debug/rig_lab.gd. A dictionary rather than a field per knob so a new
## knob needs no plumbing: add it here, read it with _t/_tv, and the lab
## picks it up from the same table.
var tune: Dictionary = {}
## Where the hold put the weapon this frame, in skeleton space. The lab draws
## these so the points being tuned are visible rather than inferred.
var dbg_grip: Vector3 = Vector3.ZERO
var dbg_fore: Vector3 = Vector3.ZERO
var dbg_stock: Vector3 = Vector3.ZERO
## The gun's frame this frame — across, up, along the barrel. The lab needs
## it to turn a mouse drag on an anchor marker back into the axes its knob is
## expressed in; without it, dragging left would mean something different at
## every pitch of the weapon.
var dbg_gun_basis: Basis = Basis.IDENTITY
## What the weapon mount must be rotated by, in the trigger hand's own local
## frame, to undo `wrist_r` — so the hand turns and the GUN does not.
##
## The weapon is parented to a BoneAttachment3D on that hand, so without this
## the two are welded: rotating the wrist swings the barrel off the aim line,
## and there is no second control that could bring it back, because every
## control that moves the gun is expressed relative to the same hand. The
## wrist knob was therefore unusable for the one thing it exists for.
##
## Identity when `wrist_r` is untuned, so a character nobody has touched
## mounts its weapon exactly as before.
var wrist_comp_r: Quaternion = Quaternion.IDENTITY
func _t(key: String, fallback: float) -> float:
return float(tune.get(key, fallback))
func _tv(key: String, fallback: Vector3) -> Vector3:
var v = tune.get(key)
# A zero-length vector means "not set" — see WeaponHoldTuning. It is how
# the elbow poles keep their hip/ADS blend unless overridden.
return v if (v is Vector3 and v.length() > 0.0001) else fallback
## A per-pose scalar knob, blended by `ads` the same way the hold itself is.
##
## Stored as `<stem>_hip` and `<stem>_ads` — the convention `pocket_hip` and
## `pocket_ads` already used, now that every knob which ought to differ
## between the two holds can.
func _tp(stem: String, d_hip: float, d_ads: float) -> float:
return lerpf(_t(stem + "_hip", d_hip), _t(stem + "_ads", d_ads), ads)
func _tvp(stem: String, d_hip: Vector3, d_ads: Vector3) -> Vector3:
return _tv(stem + "_hip", d_hip).lerp(_tv(stem + "_ads", d_ads), ads)
## The wrist offset for one hand, as a rotation in the GUN's frame.
##
## Pitch about the weapon's across-axis, yaw about its up, roll about the
## barrel — so the three sliders mean the same thing whether the muzzle is
## down at low ready or level down the sights. Identity when untuned, which
## is exactly what the hold did before there was anything but a roll.
func _wrist(stem: String, side: Vector3, up: Vector3, fwd: Vector3) -> Quaternion:
var w := _tvp(stem, Vector3.ZERO, Vector3.ZERO)
if w == Vector3.ZERO:
return Quaternion.IDENTITY
return Quaternion(side, w.x) * Quaternion(up, w.y) * Quaternion(fwd, w.z)
var _fing: Dictionary = {} # same, resolved to bone indices
var _curl: Dictionary = {} # "L"/"R" -> curl axis in the rest frame
## "L"/"R" -> Basis(along, palm, curl), the hand's anatomy in the rest pose.
var _hand_frame: Dictionary = {}
func _resolve() -> void:
var skel := get_skeleton()
# The canonical names are the LIBRARY skeleton's, and a model that kept
# its own rig names things differently — Taila's hips are DEF-spine, her
# head is DEF-spine.006, and she has no bone with "neck" in its name at
# all. RigRoles maps weapon IK and aim onto those authored names through
# the sidecar rather than imposing one skeleton naming convention.
_idx = RigRoles.resolve(skel, roles)
_resolve_hands(skel)
_resolved = true
## Finger bones, and the axis a finger closes about, per hand.
##
## The axis is derived from the hand's OWN ANATOMY in the rest pose, because
## no two rigs agree on finger bone orientation and a constant would close
## one character's hand and splay another's:
##
## along wrist -> middle knuckle, the length of the hand
## palm middle knuckle -> thumb tip, across it. The thumb opposes the
## fingers, so it is on the palm side by construction — which is
## true of a hand, not of a rig convention.
## curl along x palm, so turning about it swings the fingers into the
## palm rather than sideways or backwards.
func _resolve_hands(skel: Skeleton3D) -> void:
for key in fingers:
var list := PackedInt32Array()
for n in fingers[key]:
var b := skel.find_bone(String(n))
if b >= 0:
list.append(b)
if not list.is_empty():
_fing[key] = list
for side in ["L", "R"]:
var hand: int = _idx.get("DEF-hand." + side, -1)
var mid: PackedInt32Array = _fing.get("middle." + side, PackedInt32Array())
if hand < 0 or mid.is_empty():
continue
var wrist := skel.get_bone_global_rest(hand).origin
var knuckle := skel.get_bone_global_rest(mid[0]).origin
var along := knuckle - wrist
if along.length() < 0.0001:
continue
along = along.normalized()
var palm := Vector3.ZERO
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
if not thumb.is_empty():
palm = skel.get_bone_global_rest(thumb[thumb.size() - 1]).origin - knuckle
else:
# No thumb on this rig: fall back to the knuckle line, which
# gives a plane but not a side, so the sign may be wrong.
var ix: PackedInt32Array = _fing.get("index." + side, PackedInt32Array())
var pk: PackedInt32Array = _fing.get("pinky." + side, PackedInt32Array())
if ix.is_empty() or pk.is_empty():
continue
palm = (skel.get_bone_global_rest(pk[0]).origin
- skel.get_bone_global_rest(ix[0]).origin).cross(along)
palm -= along * palm.dot(along)
if palm.length() < 0.0001:
continue
palm = palm.normalized()
var curl := along.cross(palm).normalized()
_curl[side] = curl
# The whole hand as an ANATOMICAL FRAME, not just the curl axis.
#
# Orienting a hand onto something it is gripping is a frame-to-frame
# problem: the fingers have to wrap AROUND the object, so the curl
# axis must lie along the object's axis, and the palm has to face it.
# Both are answered at once by mapping this frame onto the target's.
# Building the rotation from a shortest arc plus a constant twist —
# which is what was here — leaves the roll about the barrel
# unspecified, so the support hand landed upside down.
_hand_frame[side] = Basis(along, palm, curl)
if OS.has_environment("HAND_DEBUG"):
print("HANDS fingers=%d resolved=%d curl=%s" % [
fingers.size(), _fing.size(), _curl])
func _process_modification() -> void:
var skel := get_skeleton()
if not skel:
return
if not _resolved:
_resolve()
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
else get_process_delta_time()
var t := 1.0 - exp(-HOLD_SMOOTH * delta)
_hold_r = lerpf(_hold_r, hold_r_target, t)
_hold_l = lerpf(_hold_l, hold_l_target, t)
# Authored clips own the full silhouette. Traversal adds only constraints
# an offline clip cannot know, such as the live grapple cable.
_apply_grapple_body(skel, delta)
if absf(aim_pitch) > 0.01 and not grapple_active:
_apply_aim_pitch(skel)
if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel)
if absf(cheek) > 0.01:
_apply_cheek(skel)
_close_hands(skel)
if recoil > 0.01:
_apply_recoil(skel)
recoil = lerpf(recoil, 0.0, 0.25)
if grapple_active:
_apply_grapple_arm(skel)
# Joints LAST, and inside the modification pass: each helper has to track
# whatever final rotation its child bone ended up with, or it deforms the
# limb instead of saving it.
if not joint_helpers.is_empty():
SkinJointHelper.update(skel, joint_helpers)
## Rotate the visual body as a pendulum without touching the gameplay
## CharacterBody/capsule. At low energy the pilot hangs upright under
## gravity. Speed and angular velocity progressively align the body's long
## axis with the rope, while tangential velocity controls facing around it.
func _apply_grapple_body(skel: Skeleton3D, delta: float) -> void:
var wanted := Quaternion.IDENTITY
grapple_alignment = 0.0
if grapple_active:
var ua: int = _idx.get("DEF-upper_arm.L", -1)
if ua >= 0:
var inv := skel.global_transform.affine_inverse()
var target := inv * grapple_target_world
var shoulder := skel.get_bone_global_pose(ua).origin
var rope := target - shoulder
var rope_length := rope.length()
if rope_length > 0.01:
var rope_dir := rope / rope_length
var velocity := skel.global_transform.basis.inverse() \
* grapple_velocity_world
var tangent := velocity - rope_dir * velocity.dot(rope_dir)
var angular_speed := tangent.length() / rope_length
# Whole-body cable alignment is a high-energy silhouette.
# Below 11 m/s the authored hanging pose remains dominant;
# angular motion can shape a fast swing but cannot bypass
# the speed gate by itself.
var speed_factor := smoothstep(
11.0, 28.0, velocity.length())
var angular_factor := smoothstep(
0.8, 2.8, angular_speed)
grapple_alignment = speed_factor * lerpf(
0.55, 1.0, angular_factor)
var up := Vector3.UP.slerp(
rope_dir, grapple_alignment).normalized()
var tilt := Quaternion(Vector3.UP, up)
var forward := tangent - up * tangent.dot(up)
if forward.length_squared() < 0.0001:
forward = tilt * Vector3(0.0, 0.0, 1.0)
else:
forward = forward.normalized()
var base_forward := tilt * Vector3(0.0, 0.0, 1.0)
base_forward = (
base_forward - up * base_forward.dot(up)
).normalized()
var twist := base_forward.signed_angle_to(forward, up)
wanted = Quaternion(up, twist) * tilt
var blend := 1.0 - exp(-7.0 * delta)
_grapple_body = _grapple_body.slerp(wanted.normalized(), blend)
if _grapple_body.angle_to(Quaternion.IDENTITY) > 0.001:
_add_space(
skel, _idx.get("DEF-hips", -1), _grapple_body)
## Exact live two-bone constraint for the grapple arm. The target hand point
## is the farthest reachable point on the cable ray, so shoulder, hand and
## hook are collinear even when the hook is many metres away.
func _apply_grapple_arm(skel: Skeleton3D) -> void:
var ua: int = _idx.get("DEF-upper_arm.L", -1)
var hand: int = _idx.get("DEF-hand.L", -1)
if ua < 0 or hand < 0:
return
var inv := skel.global_transform.affine_inverse()
var target := inv * grapple_target_world
var shoulder := skel.get_bone_global_pose(ua).origin
var line := target - shoulder
if line.length_squared() < 0.0001:
return
var along := line.normalized()
var reach := _arm_reach(
skel, "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L")
var hand_target := shoulder + along * reach * 0.985
var gravity_down := skel.global_transform.basis.inverse() * Vector3.DOWN
var pole := gravity_down + Vector3(0.35, 0.0, 0.0)
var g_fore := _ik_arm(
skel,
"DEF-upper_arm.L",
"DEF-forearm.L",
"DEF-hand.L",
hand_target,
pole.normalized(),
1.0,
)
# Continue the hand itself down the same ray. This puts the palm socket,
# not merely the wrist joint, on the cable line.
if g_fore != Quaternion.IDENTITY and _hand_frame.has("L"):
var palm := gravity_down - along * gravity_down.dot(along)
if palm.length_squared() < 0.0001:
palm = Vector3.RIGHT - along * Vector3.RIGHT.dot(along)
palm = palm.normalized()
var curl := along.cross(palm).normalized()
var want := Basis(along, palm, curl)
var rest: Basis = _hand_frame["L"]
var g_hand := (
want * rest.inverse()
).get_rotation_quaternion() \
* skel.get_bone_global_rest(hand).basis.get_rotation_quaternion()
_set_global_rot(skel, hand, g_fore, g_hand.normalized(), 1.0)
_close_hand(skel, "L", 1.0)
# Upper body follows the camera pitch: distributed over spine/neck/head
# so looking up/down reads on the whole silhouette, not just the head.
func _apply_aim_pitch(skel: Skeleton3D) -> void:
# Positive camera pitch (looking up) arches the torso back.
var per := Quaternion.IDENTITY.slerp(
Quaternion(Vector3(1, 0, 0), -aim_pitch * 0.55), 1.0 / SPINE.size())
for n in SPINE:
_add_space(skel, _idx.get(n, -1), per)
var head_q := Quaternion(Vector3(1, 0, 0), -aim_pitch * 0.45)
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(head_q, 0.5))
# Shot kick: the torso absorbs it. The MUZZLE rise is not applied here —
# it rides in the hold's aim direction (see `kick` in _apply_rifle_hold),
# so the IK carries BOTH hands up with the gun. Rotating the arms here
# instead would shove the support hand straight off the handguard.
func _apply_recoil(skel: Skeleton3D) -> void:
var k := recoil
var back := Quaternion(Vector3(1, 0, 0), -0.05 * k)
for n in ["DEF-spine.002", "DEF-spine.003"]:
_add_space(skel, _idx.get(n, -1), back)
# ── Two-hand rifle hold ──────────────────────────────────────────────────
# The gun is parented to the RIGHT HAND bone, so where the hands go decides
# where the gun goes. We therefore place the WEAPON first — stock in the
# shoulder pocket, barrel down the aim line — then solve both arms with
# two-bone IK to the resulting grip and foregrip points. That is what makes
# the stock actually meet the shoulder and the support hand actually touch
# the handguard, instead of both arms waving at art-directed angles near it.
# Skeleton space: character faces +Z, up +Y, character-right -X.
# Muzzle tilt at low-ready. Kept shallow: at the old 0.38 rad (22 degrees)
# the gun read as dangling from the hands rather than being carried.
const GUN_PITCH_HIP := 0.16
# Where the butt of the stock sits, relative to the right shoulder joint.
# The pocket is on the FRONT of the shoulder, slightly inboard of the joint.
# Low-ready used to drop the butt 0.20 m to the ribs, which pulled the whole
# weapon off the shoulder and out of the arms — it never looked held. It now
# stays in the pocket and only relaxes slightly out of ADS.
const POCKET_ADS := Vector3(0.05, 0.01, 0.07) # in the shoulder pocket
const POCKET_HIP := Vector3(0.03, -0.07, 0.06) # still shouldered, relaxed
# Elbow bend hints (skeleton space). At the hip the firing elbow rides
# down by the ribs; shouldered it flares OUT and level (the classic
# "chicken wing"), which is what keeps the tight fold from folding the
# arm up behind the head. The support elbow always tucks under the gun.
const POLE_R_HIP := Vector3(-0.55, -0.85, -0.20)
const POLE_R_ADS := Vector3(-1.0, -0.25, -0.10)
const POLE_L_HIP := Vector3(0.45, -0.90, -0.10)
const POLE_L_ADS := Vector3(0.30, -0.95, -0.05)
const R_HAND_TWIST := 0.0
func _apply_rifle_hold(skel: Skeleton3D) -> void:
var ua_r: int = _idx.get("DEF-upper_arm.R", -1)
var ua_l: int = _idx.get("DEF-upper_arm.L", -1)
if ua_r < 0:
return
# ~7 degrees of muzzle rise per shot, stacking a little on full auto.
var kick := recoil * 0.12
# 1. The gun's line: pitched down at low-ready, on the camera line at
# ADS, kicked up by recoil.
#
# `pitch` is a low-ready knob only, and deliberately: down the sights
# the muzzle follows the CAMERA, so there is nothing there to tune.
var gun_pitch := lerpf(
_t("pitch_hip", GUN_PITCH_HIP), -aim_pitch, ads) - kick
var aim_dir: Vector3 = (Quaternion(Vector3(1, 0, 0), gun_pitch) \
* Vector3(0, 0, 1)).normalized()
# Gun's "up" — perpendicular to the barrel in the vertical plane, so the
# magazine always hangs DOWN.
var side := aim_dir.cross(Vector3.UP)
if side.length_squared() < 0.0001:
side = Vector3(-1, 0, 0)
side = side.normalized()
var gun_up := side.cross(aim_dir).normalized()
# 2. Anchor the stock at the shoulder, then walk out along the barrel.
var shoulder := skel.get_bone_global_pose(ua_r).origin
var pocket: Vector3 = _tvp("pocket", POCKET_HIP, POCKET_ADS)
var stock_pos := shoulder + pocket
# The gun's own frame: across, up, along the barrel. The hand anchors are
# nudged in THIS rather than in skeleton space so a sideways offset stays
# sideways relative to the weapon as it pitches from low ready to ADS,
# instead of sliding around the gun as it tips.
var gun_basis := Basis(side, gun_up, aim_dir)
dbg_gun_basis = gun_basis
var grip_pos := stock_pos + aim_dir * gun_stock \
+ gun_basis * _tv("grip_shift", Vector3.ZERO)
# The support hand rides as far out the handguard as it can actually
# REACH. Without this a long rifle puts the foregrip past the left
# arm's limit and the IK yanks the whole arm out straight.
var fore_dist := gun_fore
if ua_l >= 0:
var l_sh := skel.get_bone_global_pose(ua_l).origin
var reach_l := _arm_reach(skel, "DEF-upper_arm.L", "DEF-forearm.L",
"DEF-hand.L") * 0.94
# Slide the support hand back down the handguard until the arm can
# reach it — but NEVER past this floor. Without one it collapsed to
# 0.083 m on Taila, which puts the support fist on top of the trigger
# fist: that reads as a two-handed pistol grip, not a rifle. A support
# arm that is nearly straight looks far better than no handguard hold.
var floor_fore: float = gun_fore * 0.55
for _i in 6:
if grip_pos.distance_to(l_sh) > reach_l:
break # even the grip is out of reach; nothing to slide to
if (grip_pos + aim_dir * fore_dist).distance_to(l_sh) <= reach_l:
break
fore_dist = maxf(fore_dist * 0.8, floor_fore)
if fore_dist <= floor_fore:
break
var fore_pos := grip_pos + aim_dir * fore_dist \
+ gun_basis * _tv("fore_shift", Vector3.ZERO)
dbg_grip = grip_pos
dbg_fore = fore_pos
dbg_stock = stock_pos
# 3. Where the SUPPORT hand goes, which is most of what tells a viewer
# what is being held. See WeaponHoldProfiles.
#
# All four modes reuse the geometry above — the weapon is still placed
# first and the arms still solved onto it — they differ in which point
# on it the off hand is sent to.
var l_target := fore_pos
match support_mode:
WeaponHoldProfiles.SUPPORT_CUPPED:
# Both hands together on the grip. Down and to the character's
# LEFT of the firing fist (`side` is character-right), so the two
# hands stack rather than collide.
l_target = grip_pos + gun_basis * Vector3(-0.048, -0.038, 0.012)
WeaponHoldProfiles.SUPPORT_TUBE:
# Hooked under the tube from below, forward of the shoulder.
l_target = fore_pos - gun_up * 0.075
if reload_phase > 0.001:
var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10
var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05
var p := reload_phase
if p < 0.30: # rip the mag straight down out of the well
l_target = mag_well.lerp(drop, p / 0.30)
elif p < 0.55: # reach down for a fresh one
l_target = drop
elif p < 0.80: # bring it back up and seat it
l_target = drop.lerp(mag_well, (p - 0.55) / 0.25)
else: # hand returns to the handguard
l_target = mag_well.lerp(fore_pos, (p - 0.80) / 0.20)
# 4. Solve both arms onto those points.
var pole_r: Vector3 = _tvp("pole_r", POLE_R_HIP, POLE_R_ADS).normalized()
var pole_l: Vector3 = _tvp("pole_l", POLE_L_HIP, POLE_L_ADS).normalized()
var g_fa_r := _ik_arm(skel, "DEF-upper_arm.R", "DEF-forearm.R",
"DEF-hand.R", grip_pos, pole_r, _hold_r)
var g_fa_l := _ik_arm(skel, "DEF-upper_arm.L", "DEF-forearm.L",
"DEF-hand.L", l_target, pole_l, _hold_l)
if OS.has_environment("GUN_POSE_DEBUG"):
var hr: int = _idx.get("DEF-hand.R", -1)
var hl: int = _idx.get("DEF-hand.L", -1)
print("HOLD st=%s tgtL=%.2f ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [
state, hold_l_target, ads, _hold_r, _hold_l, reload_phase,
gun_fore, gun_stock, fore_dist],
" sh=", shoulder, " grip=", grip_pos, " fore=", fore_pos,
" handR=", skel.get_bone_global_pose(hr).origin if hr >= 0 else "-",
" handL=", skel.get_bone_global_pose(hl).origin if hl >= 0 else "-",
" reachL=%.3f" % _arm_reach(skel, "DEF-upper_arm.L",
"DEF-forearm.L", "DEF-hand.L"))
# 5. Roll the gun hand so the BARREL lies on the aim line and the
# magazine points down — the gun's orientation comes entirely from
# this wrist, so it can never end up inverted.
if _hold_r > 0.001 and g_fa_r != Quaternion.IDENTITY \
and gun_fwd_hand.length_squared() > 0.5:
var hand: int = _idx.get("DEF-hand.R", -1)
if hand >= 0:
var arc := Quaternion(gun_fwd_hand.normalized(), aim_dir)
var up_now := arc * gun_up_hand.normalized()
var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
if up_flat.length_squared() > 0.0001:
var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
# The artist's wrist, on top of the solved one. Three axes in the
# gun's frame rather than the single twist this used to take —
# the barrel has to lie on the aim line, which fixes two of the
# hand's three freedoms, but nothing fixes how far the wrist is
# cocked or broken, and those were unreachable.
#
# The gun hangs off THIS bone, so rotating it carries the gun
# along and the barrel comes off the aim line — which made the
# knob useless for its actual purpose, since there was then no
# way to align the hand to a gun that had moved with it. Both
# outcomes are computed, the hand takes the rotated one, and the
# difference between them is published as the counter-rotation
# the weapon mount needs to stay exactly where the solver put it.
# See `wrist_comp_r`.
var cur := skel.get_bone_pose_rotation(hand)
var local_free := (g_fa_r.inverse() * arc).normalized()
var wrist_q := _wrist("wrist_r", side, gun_up, aim_dir)
var local_wrist := (g_fa_r.inverse() * (wrist_q * arc)).normalized()
var applied := cur.slerp(local_wrist, _hold_r)
wrist_comp_r = applied.inverse() * cur.slerp(local_free, _hold_r)
skel.set_bone_pose_rotation(hand, applied)
# 6. Support hand: WRAP the handguard.
#
# Built as a frame, not as an arc plus a twist. A hand gripping a
# cylinder has its fingers curling AROUND that cylinder, which fixes two
# things at once and leaves nothing free:
#
# curl axis must lie along the BARREL, or the fingers close across
# the handguard instead of around it
# palm must face the barrel — up, for a hand supporting from
# underneath
#
# The old version aligned the hand's forearm line to the barrel with a
# shortest arc and then added a constant 0.5 rad twist. A shortest arc
# says nothing about roll, so the roll came entirely from that constant,
# and a constant is only ever right for the one rig it was tuned on — the
# support hand came out upside down.
if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0 \
and _hand_frame.has("L"):
var hand_l: int = _idx.get("DEF-hand.L", -1)
if hand_l >= 0:
# The hand's target frame, as (along, palm, curl) — the same
# three axes `_hand_frame` measured off the rest pose.
#
# Which way the PALM faces and which axis the fingers CURL about
# is the whole difference between wrapping a handguard, cupping a
# fist and hooking under a tube. Sending the hand to a different
# POSITION without changing its orientation gives a hand that has
# been teleported to the new spot still shaped for the old one.
#
# -aim_dir throughout, so the hand comes at the weapon from the
# body side rather than reaching over it backwards.
var palm := gun_up
var curl := -aim_dir
match support_mode:
WeaponHoldProfiles.SUPPORT_CUPPED:
# Palm presses inward against the grip's exposed panel;
# fingers still close along the barrel, over the firing
# hand's.
palm = side
WeaponHoldProfiles.SUPPORT_TUBE:
# A vertical foregrip: the palm faces BACK toward the
# body and the fingers close about the handle's own
# up-axis, not about the tube.
palm = -aim_dir
curl = gun_up
var want := Basis(palm.cross(curl).normalized(), palm, curl)
# Then the artist's wrist. Rolling about the barrel is the one
# axis a hand wrapping a cylinder is genuinely free in, and it
# used to be the only one offered — which left no way to cock the
# wrist forward or break it inward, and those are most of what
# separates a convincing support hand from a mannequin's.
# Rotating the whole frame is equivalent to the old roll for the
# roll component, since -aim_dir is unchanged by a rotation
# about aim_dir.
want = Basis(_wrist("wrist_l", side, gun_up, aim_dir)) * want
var rest: Basis = _hand_frame["L"]
var g_hand := (want * rest.inverse()).get_rotation_quaternion() \
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
_set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l)
# ── The head against the stock ───────────────────────────────────────────
#
# A cheek weld is the single most recognisable thing about how a marksman
# holds a rifle, and its inverse — the head leaning AWAY — is what says a tube
# is resting on that shoulder. Both are silhouette at any distance, long after
# the weapon mesh itself has become a few pixels.
#
# Three axes, all toward the right shoulder, because that is where the weapon
# is: down onto the comb, rolled over it, and turned slightly along it.
## Full-weld amounts, in radians, at cheek = 1.
const CHEEK_PITCH := 0.20 # down onto the comb
const CHEEK_ROLL := 0.26 # over toward the shoulder
const CHEEK_YAW := 0.09 # turned along the stock
## How much of the weld is present at low ready. Not zero: the pose has to
## read before the character shoulders the weapon, and a marksman carrying a
## rifle already holds their head differently from someone carrying a knife.
const CHEEK_HIP := 0.35
func _apply_cheek(skel: Skeleton3D) -> void:
var k: float = cheek * lerpf(CHEEK_HIP, 1.0, ads) * _hold_r
if absf(k) < 0.005:
return
# Positive X pitches the head DOWN and positive Z rolls it toward the
# character's right — the same sign conventions as the aim pitch and the
# wall-run lean respectively.
var q := Quaternion(Vector3(1, 0, 0), CHEEK_PITCH * k) \
* Quaternion(Vector3(0, 0, 1), CHEEK_ROLL * k) \
* Quaternion(Vector3(0, 1, 0), CHEEK_YAW * k)
# Split across neck and head so the whole column leans rather than the
# skull hinging off a rigid neck. Weighted toward the head, which is what
# actually happens when someone lowers a cheek onto a stock.
_add_space(skel, _idx.get("DEF-neck", -1),
Quaternion.IDENTITY.slerp(q, 0.35))
_add_space(skel, _idx.get("DEF-head", -1),
Quaternion.IDENTITY.slerp(q, 0.65))
# How far each segment of a finger closes, knuckle -> tip, in radians.
#
# Two different grips. The SUPPORT hand wraps a handguard, so all four
# fingers close hard and evenly. The TRIGGER hand wraps a pistol grip with
# three fingers while the index lies along the trigger, nearly straight —
# curling it with the rest is the single thing that most makes a game
# character look like they are squeezing a bar of soap rather than holding a
# rifle.
const CURL_WRAP := [0.85, 1.15, 0.85]
const CURL_TRIGGER := [0.42, 0.55, 0.35]
# The thumb opposes rather than curls, so it closes less and it is the one
# digit whose middle joint barely bends.
const CURL_THUMB := [0.45, 0.30, 0.25]
const DIGITS := ["index", "middle", "ring", "pinky"]
## Close both hands around whatever the IK put them on.
##
## Applied AFTER the hold, so the fingers follow wherever the wrists ended up.
## Weighted by the per-arm hold blend, so a hand the clip has taken back opens
## again instead of staying clamped shut.
func _close_hands(skel: Skeleton3D) -> void:
_close_hand(skel, "R", _hold_r)
_close_hand(skel, "L", _hold_l)
func _close_hand(skel: Skeleton3D, side: String, w: float) -> void:
if w <= 0.01 or not _curl.has(side):
return
var axis: Vector3 = _curl[side]
for digit in DIGITS:
var bones: PackedInt32Array = _fing.get(digit + "." + side,
PackedInt32Array())
if bones.is_empty():
continue
# The right index rides the trigger; everything else wraps. A blade
# has no trigger, and an index left straight along a knife handle
# reads as a mistake rather than as discipline — so a full fist
# closes every finger the same.
var trigger: bool = side == "R" and digit == "index" and not full_fist
var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
for i in bones.size():
var a: float = amount[i] if i < amount.size() else amount[-1]
_add_space(skel, bones[i], Quaternion(axis, a * scale * w))
var thumb: PackedInt32Array = _fing.get("thumb." + side, PackedInt32Array())
for i in thumb.size():
var a: float = CURL_THUMB[i] if i < CURL_THUMB.size() else CURL_THUMB[-1]
_add_space(skel, thumb[i], Quaternion(axis, a * _t("curl_thumb", 1.0) * w))
## Straight-arm length of an arm chain, from the rest pose.
func _arm_reach(skel: Skeleton3D, ua_name: String, fa_name: String,
hand_name: String) -> float:
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
var hand: int = _idx.get(hand_name, -1)
if ua < 0 or fa < 0 or hand < 0:
return 0.5
var a := skel.get_bone_global_rest(ua).origin
var b := skel.get_bone_global_rest(fa).origin
var c := skel.get_bone_global_rest(hand).origin
return a.distance_to(b) + b.distance_to(c)
## Two-bone IK: rotate the upper arm + forearm so the HAND JOINT lands on
## `target` (skeleton space). `pole` biases which way the elbow breaks.
## Returns the forearm's achieved global rotation (IDENTITY when skipped).
func _ik_arm(skel: Skeleton3D, ua_name: String, fa_name: String,
hand_name: String, target: Vector3, pole: Vector3,
w: float) -> Quaternion:
if w <= 0.001:
return Quaternion.IDENTITY
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
var hand: int = _idx.get(hand_name, -1)
if ua < 0 or fa < 0 or hand < 0:
return Quaternion.IDENTITY
# Segment lengths come from the REST pose so they never drift.
var ua_rest := skel.get_bone_global_rest(ua).origin
var fa_rest := skel.get_bone_global_rest(fa).origin
var hand_rest := skel.get_bone_global_rest(hand).origin
var l1 := ua_rest.distance_to(fa_rest)
var l2 := fa_rest.distance_to(hand_rest)
if l1 < 0.0001 or l2 < 0.0001:
return Quaternion.IDENTITY
# The shoulder's CURRENT position (spine lean/aim pitch already moved it).
var root := skel.get_bone_global_pose(ua).origin
var to_target := target - root
var d := to_target.length()
if d < 0.0001:
return Quaternion.IDENTITY
var reach := to_target / d
d = clampf(d, absf(l1 - l2) + 0.002, l1 + l2 - 0.002)
# Law of cosines for the shoulder angle, then break the elbow toward
# the pole to pick one of the infinitely many solutions.
var cos_a := clampf((l1 * l1 + d * d - l2 * l2) / (2.0 * l1 * d), -1.0, 1.0)
var axis := reach.cross(pole)
if axis.length_squared() < 0.000001:
axis = reach.cross(Vector3.UP)
if axis.length_squared() < 0.000001:
axis = reach.cross(Vector3(1, 0, 0))
axis = axis.normalized()
var ua_dir := (Quaternion(axis, acos(cos_a)) * reach).normalized()
var elbow := root + ua_dir * l1
var fa_dir := target - elbow
fa_dir = fa_dir.normalized() if fa_dir.length_squared() > 0.00000001 else reach
var g_fa := _aim_chain(skel, ua_name, fa_name, ua_dir, fa_dir, 0.0, w)
if OS.has_environment("IK_DEBUG") and ua_name.ends_with(".R"):
var got_ua := skel.get_bone_global_pose(ua)
var got_fa := skel.get_bone_global_pose(fa)
var got_hand := skel.get_bone_global_pose(hand)
print("IK l1=%.3f l2=%.3f d=%.3f" % [l1, l2, d],
"\n root_want=", root, " ua_origin_got=", got_ua.origin,
"\n elbow_want=", elbow, " fa_origin_got=", got_fa.origin,
"\n hand_want=", target, " hand_got=", got_hand.origin,
"\n ua_dir=", ua_dir, " ua_dir_got=",
(got_fa.origin - got_ua.origin).normalized())
return g_fa
# Aim an upper-arm/forearm chain along the given directions with exact FK:
# desired global orientation = (shortest arc from the bone's rest line to
# the target dir, plus a twist about that line) ⊕ rest, each local pose
# derived against the parent's posed global so there is no drift.
# Returns the forearm's target global rotation (IDENTITY when skipped).
func _aim_chain(skel: Skeleton3D, ua_name: String, fa_name: String,
ua_dir: Vector3, fa_dir: Vector3, ua_twist: float,
w: float) -> Quaternion:
if w <= 0.001:
return Quaternion.IDENTITY
var ua: int = _idx.get(ua_name, -1)
var fa: int = _idx.get(fa_name, -1)
if ua < 0 or fa < 0:
return Quaternion.IDENTITY
# Bone lines at rest (upper arm -> forearm -> hand joint origins).
var ua_o := skel.get_bone_global_rest(ua).origin
var fa_o := skel.get_bone_global_rest(fa).origin
var fa_children := skel.get_bone_children(fa)
var fa_tip := skel.get_bone_global_rest(fa_children[0]).origin \
if fa_children.size() > 0 else fa_o + (fa_o - ua_o)
var ua_rest_dir := (fa_o - ua_o).normalized()
var fa_rest_dir := (fa_tip - fa_o).normalized()
var ua_rest_q := skel.get_bone_global_rest(ua).basis.get_rotation_quaternion()
var fa_rest_q := skel.get_bone_global_rest(fa).basis.get_rotation_quaternion()
# Desired global rotations.
var g_ua := Quaternion(ua_dir, ua_twist) * Quaternion(ua_rest_dir, ua_dir) * ua_rest_q
var g_fa := Quaternion(fa_rest_dir, fa_dir) * fa_rest_q
# Convert both to LOCAL pose rotations. Godot composes a bone as
# global = parent_global * rest_local * pose_local
# so pose_local = rest_local⁻¹ * parent_global⁻¹ * global_target.
# (Dropping the rest_local⁻¹ term silently biases every bone by its
# rest orientation — which is why hand-tuned angles used to be needed.)
var parent := skel.get_bone_parent(ua)
var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \
if parent >= 0 else Quaternion.IDENTITY
_set_global_rot(skel, ua, g_parent, g_ua, w)
# The forearm hangs off the upper arm we just aimed, so its parent's
# global IS g_ua (using the cached pose here would lag a frame).
var fa_parent := skel.get_bone_parent(fa)
var g_fa_parent := g_ua if fa_parent == ua \
else skel.get_bone_global_pose(fa_parent).basis.get_rotation_quaternion()
_set_global_rot(skel, fa, g_fa_parent, g_fa, w)
if OS.has_environment("GUN_POSE_DEBUG") and ua_name.ends_with(".R"):
var ua_actual := skel.get_bone_global_pose(ua).basis.get_rotation_quaternion()
var fa_actual := skel.get_bone_global_pose(fa).basis.get_rotation_quaternion()
print("CHAIN DEBUG w=", w,
" ua_t=", g_ua, " ua_a=", ua_actual,
" | fa_t=", g_fa, " fa_a=", fa_actual,
" | ua_dir_t=", ua_dir, " ua_dir_a=", ua_actual * (ua_rest_q.inverse() * ua_rest_dir))
return g_fa
func _blend_local(skel: Skeleton3D, idx: int, target: Quaternion, w: float) -> void:
skel.set_bone_pose_rotation(idx,
skel.get_bone_pose_rotation(idx).slerp(target.normalized(), w))
## Give a bone the requested GLOBAL rotation, given its parent's global
## rotation. In Godot 4 a bone's POSE *is* its local transform (rest is
## merely the default), so this is a plain parent-relative conversion.
func _set_global_rot(skel: Skeleton3D, idx: int, g_parent: Quaternion,
g_target: Quaternion, w: float) -> void:
_blend_local(skel, idx, g_parent.inverse() * g_target, w)
# Compose a skeleton-space rotation onto a bone's animated local pose.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0:
return
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
var local := b.inverse() * q_space * b
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)