2632 lines
113 KiB
GDScript
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)
|