Fixes the two root causes behind four of the seven reported breakages, and rejects the source that cannot be fixed. STAND UP FIRST. flatten_and_scale() now derives the up axis from the skeleton and rotates the model upright before measuring anything. Aria and Momo are correct: 1.75 m tall, 1.43 x 0.41 and 1.52 x 0.39 across, verified by render. The up vector is measured from the FEET to the HIPS, not from the hips to the head. The head is not a reliable landmark — the spine walk ends on the last non-cosmetic bone in the chain, which on a rig with a facial skeleton can sit BELOW the hips. Momo's did, so the first cut of this fix stood her neatly on her head: right size, right proportions, upside down. Feet cannot be mistaken. REACH BONES BY ROLE. SkinnedPlayerModel gained _role_bone(), and set_weapon uses it. Four characters could not hold a gun because one hardcoded lookup knew three spellings and their hands are called "Right wrist" and "J_Bip_R_Hand" — both resolved perfectly in the sidecar the whole time. HIKARI IS REJECTED. She now fails the gate: her feet and spine disagree about which way is up, so the stand-up correction cannot resolve her either, on top of zero-length cosmetic bones and a second armature that was smuggling its own clips into the export. That is not a tuning problem, it is a file that has been through two toolchains. De-registered and removed rather than shipped broken — which is what the gate is for. Six GLB skins remain, all passing. Smoke 0 failures, 11/11 movement tests. Still open, recorded in the skill: kiyoko faces backwards, miku's grown hair stretches under animation, the mannequin's rifle hold does not convince, and taila's front skirt clipping. Co-Authored-By: Claude Opus 5 <[email protected]>
1607 lines
69 KiB
GDScript
1607 lines
69 KiB
GDScript
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 <model>.rig.json — resolved bone roles, cloth chains, twist pairs
|
|
## and leg colliders, written by tools/retarget.py. Empty for a model that was
|
|
## rebound onto the library skeleton instead of keeping its own rig.
|
|
var _rig_info: Dictionary = {}
|
|
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)
|
|
# Undo the first-person viewmodel placement from the weapon's _ready:
|
|
# lie along the hand's grip, scaled down to character proportions.
|
|
w.position = Vector3(-0.02, 0.07, 0.0)
|
|
w.rotation_degrees = Vector3(0, 90, -90)
|
|
w.scale = Vector3(1.0, 1.0, 1.0)
|
|
# Tell the pose layer the gun's axes in hand-bone space so it can
|
|
# aim the wrist to point the muzzle exactly where the player looks.
|
|
if _pose_mod:
|
|
var b: Basis = w.transform.basis.orthonormalized()
|
|
_pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1)
|
|
_pose_mod.gun_up_hand = b * 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 <model>.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)
|