extends Node3D class_name SkinnedPlayerModel ## 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 first_person_mode: bool = false @export var shadows_only: bool = false @export var facing_flip: bool = true # glTF forward is +Z; players face -Z ## Horizontal speed (m/s) at which the Run clip plays at authored speed. @export var run_anim_reference_speed: float = 8.0 @export var walk_anim_reference_speed: float = 3.0 ## 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"], "Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"], "WallRun": ["WallRun", "Run", "Walk"], "WallCling": ["WallCling", "CrouchIdle", "Idle"], "Grapple": ["Grapple", "Fall", "Jump", "Idle"], "Dash": ["Dash", "Sprint", "Run", "Idle"], "Death": ["Death", "Fall"], "Hit": ["Hit", "Idle"], "Dance": ["Dance", "Idle"], "PistolIdle": ["PistolIdle", "Idle"], "PistolShoot": ["PistolShoot"], "PistolReload": ["PistolReload"], "Throw": ["Throw", "Hit"], } const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch", "CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple", "Dance", "PistolIdle"] const BLEND_TIME := 0.32 ## 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.14, "Jump": 0.16, "Hit": 0.10, "Land": 0.16, "Slide": 0.22, "Death": 0.20, "Throw": 0.14, "PistolReload": 0.24, "Idle": 0.42, "PistolIdle": 0.42, "Walk": 0.40, "Run": 0.40, "Sprint": 0.40, "CrouchIdle": 0.40, "CrouchWalk": 0.40, "Fall": 0.28, "WallRun": 0.30, "WallCling": 0.28, "Grapple": 0.28, } ## 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 var _resolved_clips: Dictionary = {} # canonical name -> actual clip name var _current_clip: String = "" var _weapon_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 = {} var _spring_mod: SpringBones var is_holding_weapon: bool = false # Animation blending: locomotion plays full-body through a Transition node; # 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 _loco_trans: AnimationNodeTransition var _upper_anim: AnimationNodeAnimation 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"] # Grapple: world-space anchor the hook is attached to (drives the procedural # zip pose — body aligned to the line, free arm reaching for the point). var _grapple_point_world: Vector3 = Vector3.ZERO var _cur_grapple: float = 0.0 # Procedural shooter pose layer (lean / slide / weapon hold), applied on top of # the base clip by a SkeletonModifier3D so it composes with the animation. 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 _cur_strafe: float = 0.0 var _cur_fwd: float = 0.0 var _cur_ads: float = 0.0 var _cur_slide: float = 0.0 var _cur_wall: float = 0.0 ## Which way the legs are actually travelling, in radians about the character's ## own up axis, and whether the locomotion cycle is running backwards to achieve ## it. See _update_travel. var _cur_travel: float = 0.0 var _travel_reverse: bool = false var _owner_visible: bool = false ## Horizontal speed from the last update_state, so the lean can scale with how ## fast the character is really moving. var _speed: float = 0.0 var _loco_tier: int = 0 var _tier_age: float = 0.0 const POSE_SMOOTH := 10.0 ## Separate, slower rate for the whole-body lean. Roughly a 0.36 s time constant, ## so the posture arrives with the clip crossfade instead of a tenth of a second ## ahead of it. const LEAN_SMOOTH := 4.5 ## Ground locomotion tiers, slowest first, and how far below the promoting ## threshold the speed must fall before dropping back a tier. const LOCO_TIERS := ["Idle", "Walk", "Run", "Sprint"] const LOCO_HYSTERESIS := 0.78 ## A locomotion tier is held at least this long before another change is ## allowed. Hard acceleration genuinely passes through walking pace in about a ## fifth of a second, so without this Walk got 0.19 s — less than half of its own ## 0.40 s crossfade — and was cut off mid-blend by Run. Multi-tier jumps still ## happen in one step, so this delays nothing that was not already a blur. const MIN_TIER_DWELL := 0.22 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 loaded = false _resolved_clips.clear() _current_clip = "" _weapon_attachment = null var scene := GLBLoader.load(path) if not scene: push_warning("SkinnedPlayerModel: failed to load '%s'" % path) return 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) # `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.name = "ShooterPose" skeleton.add_child(_pose_mod) # Cloth and hair last, so the springs react to the FINAL body pose — # animation plus the shooter lean/slide 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 + ink outline. LevelMaterials.apply_toon_recursive(scene) # ...then the character-only pass: flat line-work + softer banding (see the # function — toon-lighting the model's own outline shell is what put a white # rim on every hair strand, and re-banding already-shaded textures read as # gloss). LevelMaterials.apply_character_look(scene) 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_clip("Idle") ## 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 ## 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 ## 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() 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 ## Runtime blend tree: ## clips -> loco Transition -> TimeScale -> OneShot(upper filter) -> output ## The OneShot's filter holds every upper-body track, so reload/throw/hit ## replace arms+chest only while the locomotion clip keeps owning the legs. func _setup_anim_tree(scene: Node) -> void: var bt := AnimationNodeBlendTree.new() _loco_trans = AnimationNodeTransition.new() _loco_trans.xfade_time = BLEND_TIME _loco_trans.allow_transition_to_self = true # lets Land restart itself bt.add_node("loco", _loco_trans, Vector2(-200, 0)) # One input per unique clip, named by the clip so transition_request works. var seen := {} var idx := 0 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)) _loco_trans.add_input(clip_name) bt.connect_node("loco", idx, node_id) idx += 1 var ts := AnimationNodeTimeScale.new() bt.add_node("loco_scale", ts, Vector2(0, 0)) bt.connect_node("loco_scale", 0, "loco") 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.14 upper.fadeout_time = 0.22 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 _dancing: bool = false ## 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). func set_dancing(on: bool) -> void: _dancing = 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: _speed = speed if not loaded or not animation_player: return # 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 var clip := "Idle" match state: "ground", "idle": if _dancing and speed < 0.5 and not is_crouching: clip = "Dance" elif is_crouching: clip = "CrouchWalk" if speed > 0.5 else "Crouch" else: clip = _loco_clip(speed) # Armed idle uses the plain Idle clip — the rifle-hold pose layer # owns the arms, so the odd arms-crossed PistolIdle base reads worse. "air": # Rising = jump, falling = the fall loop. clip = "Jump" if _vertical_speed() > 0.5 else "Fall" "slide": clip = "Slide" "wall_run": clip = "WallRun" "wall_cling", "wall_climb": clip = "WallCling" "grapple": # No canned clip — the zip pose is procedural (body aligned to the # line, free arm reaching). Fall gives the legs a natural airborne # base instead of the library's horizontal swim. clip = "Fall" "dash": clip = "Dash" "death": clip = "Death" _play_clip(clip) if _pose_mod: _pose_mod.state = state _pose_mod.weapon_held = is_holding_weapon # Scale locomotion playback so feet keep up with actual movement speed. if _anim_tree: var s := 1.0 var is_loco := false match clip: "Walk", "CrouchWalk": s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6) is_loco = true "Run", "Sprint", "WallRun": s = clampf(speed / run_anim_reference_speed, 0.7, 1.8) is_loco = true # Backpedalling: run the cycle BACKWARDS rather than moon-walking with # the forward clip. The shared library has no authored reverse run, and # a reversed stride reads correctly for a backpedal. The hip yaw in # _update_travel picks the regime and points the legs to match, so the # two must agree — reading a different threshold here used to leave a # band where the feet ran one way and pointed the other. if is_loco and _travel_reverse: s = -s _anim_tree.set("parameters/loco_scale/scale", s) ## Drives the procedural pose layer. Called by the controller each frame. ## 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 run: -1 wall on left, +1 wall on right, 0 none. ## Drives a whole-body lean into the wall. func set_wall_side(side: float) -> void: _target_wall = clampf(side, -1.0, 1.0) ## World-space point the grapple hook is anchored to. While the movement state ## is "grapple" the pose layer aligns the body along the line to this point ## and reaches the free hand toward it. func set_grapple_target(point_world: Vector3) -> void: _grapple_point_world = point_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) if not _pose_mod: return var t := 1.0 - exp(-POSE_SMOOTH * delta) # The body lean gets its own, much slower rate, and is scaled by how fast the # character is ACTUALLY moving rather than by which key is held. # # The controller passes a normalised input direction, so `fwd` jumps 0 -> 1 # the instant W is pressed. At the shared rate that planted the full forward # lean in about a tenth of a second while the Idle->Run crossfade was still # 0.4 s from finishing — the body snapped into a run posture ahead of the run # cycle. Tying it to speed means the lean now grows as the character # accelerates, and lands with the clip. var lean_t := 1.0 - exp(-LEAN_SMOOTH * delta) var drive: float = clampf(_speed / maxf(run_anim_reference_speed, 0.01), 0.0, 1.0) _cur_strafe = lerpf(_cur_strafe, _target_strafe * drive, lean_t) _cur_fwd = lerpf(_cur_fwd, _target_fwd * drive, lean_t) _cur_ads = lerpf(_cur_ads, _target_ads, t) var slide_target := 1.0 if _pose_mod.state == "slide" else 0.0 _cur_slide = lerpf(_cur_slide, slide_target, t) var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0 _cur_wall = lerpf(_cur_wall, wall_target, lean_t) _update_travel(delta, drive) _pose_mod.strafe = _cur_strafe _pose_mod.fwd = _cur_fwd _pose_mod.travel_yaw = _cur_travel _pose_mod.ads = _cur_ads _pose_mod.slide = _cur_slide _pose_mod.wall = _cur_wall # Grapple zip pose: blend in while grappling; feed the modifier the # direction to the anchor in skeleton space. 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) var grapple_target := 1.0 if _pose_mod.state == "grapple" else 0.0 _cur_grapple = lerpf(_cur_grapple, grapple_target, t) _pose_mod.grapple = _cur_grapple if _cur_grapple > 0.01 and skeleton and _grapple_point_world != Vector3.ZERO: var chest := skeleton.global_transform.origin + Vector3.UP * 1.2 var dir_world := _grapple_point_world - chest if dir_world.length_squared() > 0.01: _pose_mod.grapple_dir = \ (skeleton.global_transform.basis.inverse() * dir_world).normalized() # 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 hold_l = 1.0 match st: "slide": hold_l = 0.0 # trailing arm braces the ground "wall_run": # The wall-side arm reaches for the wall. if _cur_wall > 0.05: hold_r = 0.0 elif _cur_wall < -0.05: hold_l = 0.0 "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 ## Which way the LEGS should point, and whether the stride runs backwards. ## ## The shared clip library has one forward locomotion cycle and no strafe or ## backpedal clips, so a character sidestepping used to run forwards on the spot ## while sliding sideways — nothing in the animation said which way they were ## going, and a body lean was carrying the whole burden of telling the player. ## ## Turning the HIPS toward the travel direction is what actually says it, and it ## costs no new animation: the legs are children of the hips, so the whole stride ## turns with them, while the spine counter-rotates so the chest and the gun stay ## on the aim. It is the same split every third-person shooter uses, and the same ## one Hoyoverse's locked-on locomotion uses. ## ## Beyond about a right angle the hips cannot follow, so the cycle plays in ## REVERSE and the legs point the other way instead — a real backpedal rather ## than a moonwalk. Which regime is in force is hysteretic, and the yaw is eased ## rather than snapped, so switching between them reads as a pivot on the spot, ## which is what a person actually does there. ## The most the hips may turn away from where the character is facing. ## ## A right angle would point the legs exactly along a sidestep, but a person ## sidestepping does not stand with their hips square to their path — they open ## maybe half that and let the feet cross. Past this the silhouette stops reading ## as a shooter holding an aim and starts reading as someone who has turned round. const MAX_TRAVEL_YAW := 0.95 const TRAVEL_SMOOTH := 9.0 const TRAVEL_REVERSE_IN := -0.35 const TRAVEL_REVERSE_OUT := -0.12 func _update_travel(delta: float, drive: float) -> void: var st: float = _target_strafe var fw: float = _target_fwd if _travel_reverse: if fw > TRAVEL_REVERSE_OUT: _travel_reverse = false elif fw < TRAVEL_REVERSE_IN: _travel_reverse = true var want := 0.0 if absf(st) > 0.01 or absf(fw) > 0.01: # Skeleton space: the character faces +Z and character-right is -X, so a # travel direction of (strafe right, forward) is (-strafe, 0, fwd). A yaw # of `want` about +Y points the legs along it — or along the opposite of # it when the stride is running backwards. want = atan2(st, -fw) if _travel_reverse else atan2(-st, fw) want = clampf(want, -MAX_TRAVEL_YAW, MAX_TRAVEL_YAW) * drive # Shortest way round, so a pivot never takes the long route. var d := wrapf(want - _cur_travel, -PI, PI) _cur_travel += d * (1.0 - exp(-TRAVEL_SMOOTH * delta)) ## 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 ## Locomotion clip for a ground speed, with HYSTERESIS. ## ## The thresholds used to be a bare elif chain, so a character accelerating from ## a standstill crossed all three in under a second and each crossfade cut off ## the one before it — and any speed hovering on a boundary flickered between ## two clips forever. Dropping back down needs the speed to fall well under the ## threshold that promoted it, so a tier, once entered, is committed to. func _loco_clip(speed: float) -> String: _tier_age += get_process_delta_time() var up := [0.5, walk_anim_reference_speed * 1.2, run_anim_reference_speed * 1.35] var want := _loco_tier while want < LOCO_TIERS.size() - 1 and speed > up[want]: want += 1 while want > 0 and speed < up[want - 1] * LOCO_HYSTERESIS: want -= 1 if want != _loco_tier and _tier_age >= MIN_TIER_DWELL: _loco_tier = want _tier_age = 0.0 return LOCO_TIERS[_loco_tier] func _play_clip(canonical: String, restart: bool = false) -> void: if not _anim_tree or not _resolved_clips.has(canonical): return var clip_name: String = _resolved_clips[canonical] if not restart and _current_clip == clip_name: return _loco_trans.xfade_time = BLEND_TIMES.get(canonical, BLEND_TIME) _anim_tree.set("parameters/loco/transition_request", clip_name) _current_clip = clip_name ## Current smoothed forward lean, 0..1. For debug/transition_check.gd. func get_lean_debug() -> float: return _cur_fwd ## Whether the locomotion cycle is running backwards, which points the stride ## the opposite way to the hips. For debug/travel_dir_check.gd. func stride_reversed_debug() -> bool: return _travel_reverse ## 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 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. w.transform = Transform3D.IDENTITY 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. 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 # Put the GRIP — not the model origin — in the fist. w.position -= w.transform.basis * grip # 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)) _pose_mod.gun_fore = clampf(minf(fore, muzzle_dist * 0.8), 0.12, 0.45) # 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 = clampf(muzzle_dist * 0.5, 0.10, 0.40) # ── 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 := skeleton.find_bone(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) # ── Procedural shooter pose layer ───────────────────────────────────────────── # # Runs after the AnimationPlayer each frame and layers shooter-feel poses on top # of the base clip: lean into the movement direction, a slide that leans back and # looks forward, and an always-held weapon that raises to ADS. All rotations are # authored in the skeleton's own space (forward = +Z, up = +Y, character-right = # -X) and converted into each bone's local pose, so they read intuitively. class ShooterPoseModifier extends SkeletonModifier3D: # Inputs, written by the owning SkinnedPlayerModel each frame. var strafe: float = 0.0 # -1 left .. +1 right var fwd: float = 0.0 # -1 back .. +1 forward var ads: float = 0.0 # 0 hip .. 1 aiming var slide: float = 0.0 # 0 .. 1 slide blend var wall: float = 0.0 # -1 wall left .. +1 wall right (wall-run lean) # Yaw of the LOWER body about the character's up axis, in radians — which way # the legs are actually travelling. See SkinnedPlayerModel._update_travel. var travel_yaw: float = 0.0 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" var weapon_held: bool = false # Grapple zip: 0..1 blend + skeleton-space direction to the anchor point. var grapple: float = 0.0 var grapple_dir: Vector3 = Vector3.ZERO # 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 var _time: 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 # [child_bone, helper_bone] pairs; see SkinJointHelper. var joint_helpers: Array = [] # Tuning (radians). Positive pitch leans forward; positive roll leans right. # The lean is the ONLY thing that tells a viewer which way this character is # travelling — the library has one forward locomotion cycle and no strafe # clips — so it has to be legible, not subtle. ## How much of the hip yaw the spine takes back, so the chest, the head and ## the gun stay pointed where the player is aiming. Not all of it: a real ## torso does follow the hips a little, and countering the whole thing makes ## the waist look broken. const TRAVEL_COUNTER := 0.82 const LEAN_ROLL := 0.42 const LEAN_PITCH := 0.30 const SLIDE_BACK := 0.75 # torso lean-back during slide const SLIDE_HEAD_UP := 0.7 # head pitch to keep looking forward const SLIDE_LEG_FWD := 0.95 # thighs swing forward so feet lead the slide const SLIDE_KNEE := 0.55 # shins straighten against the crouch clip's bend const WALL_PITCH := 0.2 # forward drive lean during a wall run const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"] 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 = {} func _resolve() -> void: var skel := get_skeleton() var names := SPINE + ["DEF-neck", "DEF-head", "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L", "DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"] # The names above are the LIBRARY skeleton's. A model that kept its own # rig names things differently and three of them simply do not exist on # it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has # no bone with "neck" in its name at all. Unresolved, every lean, aim # pitch and slide head-lift below silently did nothing. var alias := {} if not roles.is_empty(): var neck: String = roles.get("neck", "") var head: String = roles.get("head", "") var torso: Array = [] for n in roles.get("spine", []): if n != neck and n != head: torso.append(n) for i in mini(torso.size(), SPINE.size() - 1): alias[SPINE[i + 1]] = torso[i] for n in names: # Canonical names are the role keys with the DEF- prefix, so the # limbs, hips, neck and head all map straight through. var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n)) var b := skel.find_bone(actual) if b < 0: b = skel.find_bone(n) _idx[n] = b _resolved = true 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() _time += delta 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) _apply_lean(skel) if absf(aim_pitch) > 0.01: _apply_aim_pitch(skel) if slide > 0.01: _apply_slide(skel) if absf(wall) > 0.01: _apply_wall_lean(skel) if grapple > 0.01 and grapple_dir != Vector3.ZERO: _apply_grapple(skel) if _hold_r > 0.01 or _hold_l > 0.01: _apply_rifle_hold(skel) if recoil > 0.01: _apply_recoil(skel) recoil = lerpf(recoil, 0.0, 0.25) # 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) # 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) # Turn the lower body to face the way the character is travelling, and lean # along that direction rather than along the facing. # # The legs hang off the hips, so yawing the hips turns the whole stride — the # one thing that makes a sidestep look like a sidestep when the clip library # has only a forward run. The spine takes most of it back so the chest and the # gun stay on the aim. # # The LEAN has to move into the travel frame with it. Leaning "forward" along # the character's facing while the legs run off to one side leans them # sideways relative to their own stride, which is exactly the sensation of a # character being dragged rather than running. func _apply_lean(skel: Skeleton3D) -> void: var yaw := travel_yaw * (1.0 - slide) # How hard the character is driving, regardless of which way. Signed # `fwd` is wrong now that the legs turn: a sidestep has fwd near zero and # should still lean into its own stride. var effort := clampf(Vector2(strafe, fwd).length(), 0.0, 1.0) var pitch := effort * LEAN_PITCH * (1.0 - slide) if absf(pitch) < 0.001 and absf(yaw) < 0.001: return var turn := Quaternion(Vector3(0, 1, 0), yaw) # Pitch about the axis ACROSS the direction of travel, not across the # facing — see the comment above the function. var lean := Quaternion((turn * Vector3(1, 0, 0)).normalized(), pitch) # The hips carry the yaw, and a little of the lean. _add_space(skel, _idx.get("DEF-hips", -1), turn * Quaternion.IDENTITY.slerp(lean, 0.25)) # The torso unwinds the yaw and takes the rest of the lean. var torso: Array = SPINE.slice(1) var n := maxf(torso.size(), 1) var back := Quaternion(Vector3(0, 1, 0), -yaw * TRAVEL_COUNTER) var per_yaw := Quaternion.IDENTITY.slerp(back, 1.0 / n) var per_lean := Quaternion.IDENTITY.slerp(lean, 0.75 / n) for b in torso: _add_space(skel, _idx.get(b, -1), per_yaw * per_lean) # Grapple zip: the whole body pivots to fly along the line to the anchor, # legs trail behind, and the FREE (left) hand reaches up the rope — the # right hand keeps the rifle (the hold layer runs after this and owns it). func _apply_grapple(skel: Skeleton3D) -> void: var d := grapple_dir.normalized() var fwd := Vector3(0, 0, 1) if d.dot(fwd) > 0.999: d = (d + Vector3(0.001, 0.001, 0)).normalized() var arc := Quaternion(fwd, d) # Align a good chunk of the body with the line, capped so a grapple # point straight overhead doesn't fold the character in half. var frac := 0.65 var ang := arc.get_angle() if ang * frac > 1.15: frac = 1.15 / ang var per := Quaternion.IDENTITY.slerp(arc, grapple * frac / SPINE.size()) for n in SPINE: _add_space(skel, _idx.get(n, -1), per) # Head keeps sighting the anchor point. var head_q := Quaternion.IDENTITY.slerp(arc, grapple * 0.18) _add_space(skel, _idx.get("DEF-neck", -1), head_q) _add_space(skel, _idx.get("DEF-head", -1), head_q) # Legs trail behind the flight line (Superman zip, not a swim). var trail := Quaternion(Vector3(1, 0, 0), 0.45 * grapple) _add_space(skel, _idx.get("DEF-thigh.R", -1), trail) _add_space(skel, _idx.get("DEF-thigh.L", -1), Quaternion(Vector3(1, 0, 0), 0.32 * grapple)) var straighten := Quaternion(Vector3(1, 0, 0), -0.25 * grapple) _add_space(skel, _idx.get("DEF-shin.R", -1), straighten) _add_space(skel, _idx.get("DEF-shin.L", -1), straighten) # Free arm reaches along the rope toward the anchor. var ua_dir := (d + Vector3(0.28, 0.1, 0.0)).normalized() var g_fa := _aim_chain(skel, "DEF-upper_arm.L", "DEF-forearm.L", ua_dir, d, -0.2, grapple) var hand_l: int = _idx.get("DEF-hand.L", -1) var fa_l: int = _idx.get("DEF-forearm.L", -1) if hand_l >= 0 and fa_l >= 0 and g_fa != Quaternion.IDENTITY: var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() var fa_o := skel.get_bone_global_rest(fa_l).origin var hand_o := skel.get_bone_global_rest(hand_l).origin var fa_rest_dir := (hand_o - fa_o).normalized() var g_hand := Quaternion(d, 0.4) * Quaternion(fa_rest_dir, d) * hand_rest_q _set_global_rot(skel, hand_l, g_fa, g_hand, grapple) # Wall run: roll into the wall, drive forward, inner arm reaches the wall. func _apply_wall_lean(skel: Skeleton3D) -> void: var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \ * Quaternion(Vector3(1, 0, 0), WALL_PITCH * absf(wall)) var per := Quaternion.IDENTITY.slerp(q, 1.0 / SPINE.size()) for n in SPINE: _add_space(skel, _idx.get(n, -1), per) # Reach the wall-side arm out sideways (character-right = -X, so a # negative Z rotation swings the down arm toward the right side). var reach := Quaternion(Vector3(0, 0, 1), -WALL_ARM_OUT * wall) if wall > 0.0: _add_space(skel, _idx.get("DEF-upper_arm.R", -1), reach) else: _add_space(skel, _idx.get("DEF-upper_arm.L", -1), reach) # Slide: torso leans back, head looks forward, legs kick out in front so # it reads feet-first instead of "sitting in a crouch". func _apply_slide(skel: Skeleton3D) -> void: var back := Quaternion(Vector3(1, 0, 0), -SLIDE_BACK * slide) var per := Quaternion.IDENTITY.slerp(back, 1.0 / SPINE.size()) for n in SPINE: _add_space(skel, _idx.get(n, -1), per) var up := Quaternion(Vector3(1, 0, 0), SLIDE_HEAD_UP * slide) _add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(up, 0.5)) _add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(up, 0.5)) # Legs: thighs swing forward (lead leg further), knees straighten. var lead := Quaternion(Vector3(1, 0, 0), -SLIDE_LEG_FWD * slide) var trail := Quaternion(Vector3(1, 0, 0), -SLIDE_LEG_FWD * 0.7 * slide) _add_space(skel, _idx.get("DEF-thigh.R", -1), lead) _add_space(skel, _idx.get("DEF-thigh.L", -1), trail) var straighten := Quaternion(Vector3(1, 0, 0), SLIDE_KNEE * slide) _add_space(skel, _idx.get("DEF-shin.R", -1), straighten) _add_space(skel, _idx.get("DEF-shin.L", -1), straighten) # Trailing arm braces back-and-down against the ground for balance. var brace := Quaternion(Vector3(1, 0, 0), 0.9 * slide) \ * Quaternion(Vector3(0, 0, 1), -0.5 * slide) _add_space(skel, _idx.get("DEF-upper_arm.L", -1), brace) _add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide)) # ── 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 const L_HAND_TWIST := 0.5 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 var breathe := sin(_time * 2.2) * 0.012 + fwd * 0.02 # ~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. var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe 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 = POCKET_HIP.lerp(POCKET_ADS, ads) var stock_pos := shoulder + pocket var grip_pos := stock_pos + aim_dir * gun_stock # 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 for _i in 5: 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 *= 0.75 var fore_pos := grip_pos + aim_dir * fore_dist # 3. Support hand goes to the mag well during a reload (under the # receiver — the correct side), otherwise to the handguard. var l_target := fore_pos 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 = POLE_R_HIP.lerp(POLE_R_ADS, ads).normalized() var pole_l: Vector3 = POLE_L_HIP.lerp(POLE_L_ADS, 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 ads=%.2f holdR=%.2f holdL=%.2f rl=%.2f fore=%.3f stock=%.3f foredist=%.3f" % [ 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 _set_global_rot(skel, hand, g_fa_r, arc, _hold_r) # 6. Support hand: palm wraps the handguard, following its forearm. if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY and ua_l >= 0: var hand_l: int = _idx.get("DEF-hand.L", -1) var fa_l_idx: int = _idx.get("DEF-forearm.L", -1) if hand_l >= 0 and fa_l_idx >= 0: var fa_o := skel.get_bone_global_rest(fa_l_idx).origin var hand_o := skel.get_bone_global_rest(hand_l).origin var fa_rest_dir := (hand_o - fa_o).normalized() var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() # Point the palm along the barrel so the fingers close over it. var g_hand := Quaternion(aim_dir, L_HAND_TWIST) \ * Quaternion(fa_rest_dir, aim_dir) * hand_rest_q _set_global_rot(skel, hand_l, g_fa_l, g_hand, _hold_l) ## 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)