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"], "Fall": ["Fall", "Jump", "Idle"], "Land": ["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", "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.24, "Hit": 0.14, "Land": 0.28, # SlideStart has a distinct authored crouch pose; blending into it from # airborne/standing can send the lower body through a ground-plane flip. "SlideStart": 0.0, "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, "Fall": 0.32, # The two authored wall performances start in distinct side poses. A full # body crossfade from Fall/ground interpolates the hips and thighs through # an upside-down midpoint, so hand off directly to the authored first pose. "WallRunLeft": 0.0, "WallRunRight": 0.0, "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 _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 .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 _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 == "air" \ 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 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" "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 != "": # A wall jump hands the full body from a side performance to Jump/Fall. # Interpolating those two poses can put the thighs through the horizontal # midpoint, which reads as a delayed leg flip on the jump frame. _play_clip(clip, false, 0.0 if exiting_wall_run else -1.0) 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 == "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) ## 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"] _state_trans.xfade_time = 0.0 if exiting_wall_run \ else 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 .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 `_hip` and `_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)