Compare commits

..
6 Commits
Author SHA1 Message Date
Nicholas ButzkeandClaude Opus 4.8 0d125dc03f fix: third-person gun actually held — IK to the weapon, real recoil, no flip
Four separate third-person defects, one shared root: the arms were posed at
art-directed ANGLES that merely approximated the gun, and a stuck one-shot
flag kept handing them back to the animation clip.

- The rifle hold was silently DEAD after the first reload or throw. The gate
  trusted AnimationNodeOneShot's `active` parameter, which never clears, so
  clip_owns_arms stayed true forever and the clip drove the arms while the
  gun hung off the hand. `_upper_lock` (a timer we own) is now the
  authority, and the one-shot is explicitly faded out when it expires.
  This alone is why the gun was never held correctly.
- Arms are now solved with real two-bone IK (`_ik_arm`) onto points derived
  from the WEAPON: the stock is anchored in the shoulder pocket, the grip
  and foregrip fall out along the barrel, and the support hand is placed on
  the actual handguard (sliding inboard if the model is too long to reach).
  Verified numerically: the hand lands within 2mm of its target.
- `_measure_weapon` measures the model's AABB along its barrel and re-seats
  it so the hand sits at a realistic pistol-grip point. Weapon models put
  their origin anywhere — the M4's was 10cm from the muzzle end, so parking
  its stock in the shoulder drove the hand INTO the shoulder and folded the
  arm up behind the head.
- Recoil now shows for the OWNER in third person: server_play_fire_effects
  only ever fired for remote shooters, so the local player saw nothing. The
  ammo counter dropping now kicks the model. The kick rides in the gun's aim
  direction so the IK carries BOTH hands up with it, instead of rotating the
  arms and shoving the support hand off the gun.
- Reload no longer flips the gun. The library's pistol-reload rotates the
  wrist the gun is parented to, which turned the rifle upside-down (mag to
  the sky) while the hand reached down for it. The hold now keeps the right
  arm through a reload and the support hand does the magazine work at the
  real mag well, under the receiver.

anim_capture gains recoil shots and lets ADS settle before framing;
debug/dump_bones.gd prints a skin's bone hierarchy.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 10:15:54 -04:00
Nicholas ButzkeandClaude Fable 5 df0bf18e0f feat: real per-weapon reload/recoil choreography + knife slash, fully matte hair
Weapons no longer reload by spinning the gun 360 (that was the upside-down
flip; nothing detached). New ViewmodelAnim module drives per-style manual-of-
arms sequences on the existing model_root + named arm pivots, with a temporary
magazine/shell/rocket/cell prop that visibly leaves and returns to the gun:

  - mag     (M4/AK/MP7/DMR): roll gun to present the well, hand rips the mag
            down and away, fresh mag up, seat, charge. Gun stays UPRIGHT.
  - boltmag (AWP): mag swap + a distinct bolt-cycle rock.
  - break   (double barrel): hinge open, flick both shells out, drop fresh
            shells in, snap shut.
  - tube    (rocket launcher): tip the tube in, shove a rocket home, shoulder.
  - cell    (plasma/nail gun): glowing energy cell swapped on the side.

Per-shot viewmodel recoil kick (ViewmodelAnim.apply_kick) scaled by each
weapon's recoil_amplitude, so an AWP slams and an MP7 chatters — replaces the
old flat model behaviour. Weapons expose reload_style; base classes call
play_reload / stop (on unequip) / apply_kick.

Knife: the old barely-visible flick is now a real diagonal slash that
alternates backhand/forehand, whips the blade through screen centre, and
fires the third-person melee arm-swing action (synced to other players).

Hair/materials: specular fully to 0 (even a 2% stepped glint swept as shine
across Taila's hair when the camera moved); rim trimmed to a whisper. Fully
matte cloth/hair now.

Verify: debug/fp_weapon_capture.gd screenshots each weapon's reload phases,
the knife slash, and the fire kick.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 00:26:29 -04:00
Nicholas ButzkeandClaude Fable 5 d46530bd8c fix: color-accurate grade — ginger hair stays ginger, characters stay matte
The saturation grade (1.3) was shifting authored hues — Taila's auburn
ginger hair rendered fire-truck red and the whole game read filtered.
Saturation 1.3 -> 1.08, contrast 1.05 -> 1.03: textures now carry their
own vibrancy. Verified by sampling the rendered hair against the source
texture: lit hue 18-20 deg vs authored ~20 deg.

Character rim/specular cut again (rim 0.15/0.4 -> 0.08/0.32, spec
0.06 -> 0.025 @ 64): hair and cloth read fully matte, no wet sheen.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-20 22:55:58 -04:00
Nicholas ButzkeandClaude Fable 5 1ad3563e5a feat: animations follow the mechanics — upper/lower split + real grapple pose
Two fidelity gaps closed:

- Reload/throw/shoot/hit used to REPLACE the whole-body clip, so reloading
  mid-slide snapped the character upright on standing legs. The model now
  runs a runtime AnimationTree (clips -> Transition -> TimeScale -> OneShot)
  whose one-shot layer is FILTERED to upper-body bones: the arms play the
  action while the legs keep sliding/running/falling. Land stays full-body.
  The rifle hold releases exactly while the one-shot node reports active.
- Grapple no longer plays the library's horizontal-swim clip. The zip pose
  is procedural: Fall as the airborne base, the spine pivots to fly along
  the line to the actual anchor point (capped so overhead shots don't fold
  the body), the head sights the anchor, legs trail behind, the FREE left
  hand reaches up the rope — and the right hand keeps holding the rifle.
  The anchor point feeds in from the controller for local and remote
  players (synced_grapple_point).
- anim_capture: deterministic open-ground teleport + two new regression
  shots (reload-while-sliding, grapple zip toward a real anchor).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-20 22:51:04 -04:00
Nicholas ButzkeandClaude Fable 5 87b8ae70df feat: Fortnite-style OTS camera + Alt free-look orbit in third person
- Third-person camera chain is now HeadPivot > OrbitPivot > ShoulderOffset
  (0.55m right, 0.12m up) > SpringArm(2.6m, 14 deg) — the character sits
  slightly left of screen centre over-the-shoulder instead of dead centre
  behind. Aim is unchanged (still the first-person camera).
- Hold Alt in third person to orbit the camera freely around the character
  with the mouse (yaw wraps, pitch clamped -69..+29 deg); the character
  keeps facing and aiming where it was. Release Alt and the camera springs
  back behind the shoulder.
- debug/orbit_capture.gd: screenshots the OTS framing, a driven orbit, and
  the spring-back for visual regression.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 23:59:44 -04:00
Nicholas ButzkeandClaude Fable 5 b0f26e6dde feat: matte anime materials — kill the glass/mirror look, keep the vibrancy
Everything read as wet plastic or glowing porcelain for two stacking
reasons, both fixed centrally:

- toonify() shipped every character/prop/weapon with a hot rim (0.45 over
  65% of the silhouette) and broad stepped specular (0.25 @ shininess 24) —
  patent-leather highlights on any dark surface. Now a thin rim (0.15/0.4)
  and a small tight glint (0.06 @ 56); shader defaults match.
- The LINEAR tonemapper clipped lit white surfaces (sun + ambient push
  facades past 1.0) and the glow pass bloomed the clip, so buildings glowed
  like porcelain. Switched to FILMIC with white=2.4: near-linear through
  the cel bands (they stay crisp), soft shoulder above 1.0. Glow threshold
  1.1 -> 1.45 so only genuine emissives (neon, tracers, plasma) bloom.
- Saturation 1.22 -> 1.3 compensates filmic mids — vibrancy unchanged.
- Neon Alley: ambient 1.7 -> 1.35 / sun 2.2 -> 2.5 — the shoulder no longer
  hides the ambient lavender cast, so let the sun shape the facades.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-19 23:53:05 -04:00
24 changed files with 1315 additions and 187 deletions
+6 -4
View File
@@ -16,10 +16,12 @@ uniform float band_edge : hint_range(-1.0, 1.0) = 0.05; // NdotL where light b
uniform float band_softness : hint_range(0.001, 0.5) = 0.04; uniform float band_softness : hint_range(0.001, 0.5) = 0.04;
uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band uniform float mid_band_edge : hint_range(-1.0, 1.0) = 0.55; // second, brighter band
uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint uniform vec4 shadow_color : source_color = vec4(0.62, 0.65, 0.78, 1.0); // cool shadow tint
uniform float rim_strength : hint_range(0.0, 2.0) = 0.35; // Matte-anime defaults: zero specular (any stepped glint reads as shine
uniform float rim_width : hint_range(0.0, 1.0) = 0.65; // sweeping across hair/cloth when the camera moves), whisper of rim.
uniform float specular_strength : hint_range(0.0, 1.0) = 0.25; uniform float rim_strength : hint_range(0.0, 2.0) = 0.05;
uniform float specular_shininess : hint_range(1.0, 128.0) = 24.0; uniform float rim_width : hint_range(0.0, 1.0) = 0.28;
uniform float specular_strength : hint_range(0.0, 1.0) = 0.0;
uniform float specular_shininess : hint_range(1.0, 128.0) = 64.0;
varying vec3 world_pos; varying vec3 world_pos;
varying vec3 world_normal; varying vec3 world_normal;
+37 -4
View File
@@ -28,6 +28,14 @@ var _pitch_impulse: float = 0.0 # extra camera pitch in radians, decays
var _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays var _dash_fov_kick: float = 0.0 # extra FOV from dashing, decays
var _machine: MovementStateMachine = null var _machine: MovementStateMachine = null
# Third-person free-look: hold Alt to orbit the camera around the character
# without turning the character or aim; release and it springs back behind.
var _orbit_yaw: float = 0.0
var _orbit_pitch: float = 0.0
const ORBIT_PITCH_MIN := -1.2 # radians (looking down from above)
const ORBIT_PITCH_MAX := 0.5 # radians (looking up from below)
const ORBIT_RETURN_SPEED := 10.0
func _ready() -> void: func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
@@ -71,10 +79,17 @@ func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED: if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
var is_ads = camera and camera.fov < SettingsManager.world_fov - 5.0 var is_ads = camera and camera.fov < SettingsManager.world_fov - 5.0
var sens = SettingsManager.ads_sensitivity if is_ads else SettingsManager.mouse_sensitivity var sens = SettingsManager.ads_sensitivity if is_ads else SettingsManager.mouse_sensitivity
# Yaw: rotate the player (parent) if _free_looking():
get_parent().rotate_y(-event.relative.x * sens) # Alt held in third person: orbit the camera around the character;
# Pitch: rotate this pivot # the character keeps facing (and aiming) where it was.
rotation.x = clampf(rotation.x - event.relative.y * sens, deg_to_rad(-pitch_limit), deg_to_rad(pitch_limit)) _orbit_yaw = wrapf(_orbit_yaw - event.relative.x * sens, -PI, PI)
_orbit_pitch = clampf(_orbit_pitch - event.relative.y * sens,
ORBIT_PITCH_MIN, ORBIT_PITCH_MAX)
else:
# Yaw: rotate the player (parent)
get_parent().rotate_y(-event.relative.x * sens)
# Pitch: rotate this pivot
rotation.x = clampf(rotation.x - event.relative.y * sens, deg_to_rad(-pitch_limit), deg_to_rad(pitch_limit))
# Toggle mouse capture with Escape # Toggle mouse capture with Escape
if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE: if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
@@ -84,6 +99,14 @@ func _input(event: InputEvent) -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
## True while the player is holding Alt to look around in third person.
func _free_looking() -> bool:
var p := get_parent()
return p != null and "third_person" in p and p.third_person \
and Input.is_key_pressed(KEY_ALT) \
and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not camera or not params: if not camera or not params:
return return
@@ -92,6 +115,16 @@ func _process(delta: float) -> void:
if not player: if not player:
return return
# ── Third-person free-look orbit ──────────────────────────────────────
var orbit := get_node_or_null("OrbitPivot")
if orbit:
if not _free_looking():
# Spring back behind the character once Alt is released.
var t := 1.0 - exp(-ORBIT_RETURN_SPEED * delta)
_orbit_yaw = lerpf(_orbit_yaw, 0.0, t)
_orbit_pitch = lerpf(_orbit_pitch, 0.0, t)
orbit.rotation = Vector3(_orbit_pitch, _orbit_yaw, 0.0)
if not _machine: if not _machine:
_connect_machine() _connect_machine()
+467 -94
View File
@@ -70,6 +70,7 @@ const ACTIONS := {
"reload": ["PistolReload", 1.15], "reload": ["PistolReload", 1.15],
"throw": ["Throw", 0.55], "throw": ["Throw", 0.55],
"shoot": ["PistolShoot", 0.2], "shoot": ["PistolShoot", 0.2],
"melee": ["Throw", 0.45], # overhand arm swing doubles as the knife slash
} }
var skeleton: Skeleton3D var skeleton: Skeleton3D
@@ -81,6 +82,26 @@ var _current_clip: String = ""
var _weapon_attachment: BoneAttachment3D var _weapon_attachment: BoneAttachment3D
var is_holding_weapon: bool = false 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.
const UPPER_BONE_HINTS := ["shoulder", "upper_arm", "forearm", "hand", "thumb",
"f_index", "f_middle", "f_ring", "f_pinky", "spine.002", "spine.003",
"neck", "head"]
# 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 # Procedural shooter pose layer (lean / slide / weapon hold), applied on top of
# the base clip by a SkeletonModifier3D so it composes with the animation. # the base clip by a SkeletonModifier3D so it composes with the animation.
var _pose_mod: ShooterPoseModifier var _pose_mod: ShooterPoseModifier
@@ -135,6 +156,7 @@ func load_model(path: String) -> void:
LevelMaterials.apply_toon_recursive(scene) LevelMaterials.apply_toon_recursive(scene)
if animation_player: if animation_player:
_index_animations() _index_animations()
_setup_anim_tree(scene)
else: else:
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path) push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
@@ -178,6 +200,67 @@ func _index_animations() -> void:
anim.loop_mode = Animation.LOOP_LINEAR 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()
upper.fadein_time = 0.08
upper.fadeout_time = 0.15
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: func _find_clip(available: PackedStringArray, wanted: String) -> String:
for name in available: for name in available:
if name == wanted: if name == wanted:
@@ -218,13 +301,24 @@ var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _dancing: bool = false var _dancing: bool = false
## Play a one-shot clip (Hit reaction, Land, ...) over locomotion for ## Play a one-shot clip over locomotion for `lock_time` seconds.
## `lock_time` seconds; locomotion resumes afterwards. ## 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: func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
if not loaded or not _resolved_clips.has(canonical): if not loaded or not _resolved_clips.has(canonical):
return return
_oneshot_lock = lock_time if canonical in FULL_BODY_ONESHOTS or not _anim_tree:
_play_clip(canonical, true) _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 ## Emote toggle (Dance). Shown while grounded and near-idle; any real
@@ -236,6 +330,7 @@ func set_dancing(on: bool) -> void:
## Play a named gameplay action (reload / throw / shoot) as a one-shot. ## Play a named gameplay action (reload / throw / shoot) as a one-shot.
func play_action(action: String) -> void: func play_action(action: String) -> void:
if ACTIONS.has(action): if ACTIONS.has(action):
_upper_action = action
play_oneshot(ACTIONS[action][0], ACTIONS[action][1]) play_oneshot(ACTIONS[action][0], ACTIONS[action][1])
@@ -297,7 +392,10 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
"wall_cling", "wall_climb": "wall_cling", "wall_climb":
clip = "WallCling" clip = "WallCling"
"grapple": "grapple":
clip = "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": "dash":
clip = "Dash" clip = "Dash"
"death": "death":
@@ -310,13 +408,14 @@ func update_state(state: String, speed: float, is_crouching: bool = false) -> vo
_pose_mod.weapon_held = is_holding_weapon _pose_mod.weapon_held = is_holding_weapon
# Scale locomotion playback so feet keep up with actual movement speed. # Scale locomotion playback so feet keep up with actual movement speed.
match clip: if _anim_tree:
"Walk", "CrouchWalk": var s := 1.0
animation_player.speed_scale = clampf(speed / walk_anim_reference_speed, 0.7, 1.6) match clip:
"Run", "Sprint", "WallRun": "Walk", "CrouchWalk":
animation_player.speed_scale = clampf(speed / run_anim_reference_speed, 0.7, 1.8) s = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
_: "Run", "Sprint", "WallRun":
animation_player.speed_scale = 1.0 s = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
_anim_tree.set("parameters/loco_scale/scale", s)
## Drives the procedural pose layer. Called by the controller each frame. ## Drives the procedural pose layer. Called by the controller each frame.
@@ -335,6 +434,13 @@ func set_wall_side(side: float) -> void:
_target_wall = clampf(side, -1.0, 1.0) _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
func _process(delta: float) -> void: func _process(delta: float) -> void:
if not _pose_mod: if not _pose_mod:
return return
@@ -352,11 +458,45 @@ func _process(delta: float) -> void:
_pose_mod.slide = _cur_slide _pose_mod.slide = _cur_slide
_pose_mod.wall = _cur_wall _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 # Two-hand rifle hold: owns the arms whenever a weapon is held, EXCEPT when
# a one-shot clip (reload/throw/hit), an emote, or a state whose arms matter # a one-shot clip (reload/throw/hit) or a full-body moment (Land lock,
# (death, dance) needs the authored animation to read through. # 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 var st: String = _pose_mod.state
var clip_owns_arms := _oneshot_lock > 0.0 or _dancing or st == "death" # `_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_r := 0.0
var hold_l := 0.0 var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms: if is_holding_weapon and not clip_owns_arms:
@@ -376,16 +516,21 @@ func _process(delta: float) -> void:
_pose_mod.hold_r_target = hold_r _pose_mod.hold_r_target = hold_r
_pose_mod.hold_l_target = hold_l _pose_mod.hold_l_target = hold_l
# Reload progress drives the support hand's trip to the mag well.
var rl_target := 0.0
if reloading and _upper_total > 0.0:
rl_target = clampf(1.0 - (_upper_lock / _upper_total), 0.0, 1.0)
_pose_mod.reload_phase = rl_target
func _play_clip(canonical: String, restart: bool = false) -> void: func _play_clip(canonical: String, restart: bool = false) -> void:
if not animation_player or not _resolved_clips.has(canonical): if not _anim_tree or not _resolved_clips.has(canonical):
return return
var clip_name: String = _resolved_clips[canonical] var clip_name: String = _resolved_clips[canonical]
if not restart and _current_clip == clip_name and animation_player.is_playing(): if not restart and _current_clip == clip_name:
return return
if restart: _loco_trans.xfade_time = BLEND_TIMES.get(canonical, BLEND_TIME)
animation_player.stop() _anim_tree.set("parameters/loco/transition_request", clip_name)
animation_player.play(clip_name, BLEND_TIMES.get(canonical, BLEND_TIME))
_current_clip = clip_name _current_clip = clip_name
@@ -428,13 +573,14 @@ func set_weapon(script_path: String) -> void:
# lie along the hand's grip, scaled down to character proportions. # lie along the hand's grip, scaled down to character proportions.
w.position = Vector3(-0.02, 0.07, 0.0) w.position = Vector3(-0.02, 0.07, 0.0)
w.rotation_degrees = Vector3(0, 90, -90) w.rotation_degrees = Vector3(0, 90, -90)
w.scale = Vector3(0.75, 0.75, 0.75) w.scale = Vector3(1.0, 1.0, 1.0)
# Tell the pose layer the gun's axes in hand-bone space so it can # 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. # aim the wrist to point the muzzle exactly where the player looks.
if _pose_mod: if _pose_mod:
var b: Basis = w.transform.basis.orthonormalized() var b: Basis = w.transform.basis.orthonormalized()
_pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1) _pose_mod.gun_fwd_hand = b * Vector3(0, 0, -1)
_pose_mod.gun_up_hand = b * Vector3(0, 1, 0) _pose_mod.gun_up_hand = b * Vector3(0, 1, 0)
_measure_weapon(w)
) )
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"]) var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
@@ -453,6 +599,45 @@ func set_weapon(script_path: String) -> void:
add_child(w) add_child(w)
## Measure the held weapon along its own barrel axis so the pose layer knows
## where the real foregrip and stock butt are, instead of guessing. Distances
## are from the GRIP (the weapon node's origin, which sits in the hand), in
## metres of character space.
func _measure_weapon(w: Node3D) -> void:
var local_fwd := Vector3(0, 0, -1) # the weapon's own muzzle axis
var min_t := INF # most negative = stock end
var max_t := -INF # most positive = muzzle end
for mi in w.find_children("*", "MeshInstance3D", true, false):
if not mi.mesh:
continue
var xf: Transform3D = w.global_transform.affine_inverse() * mi.global_transform
var aabb: AABB = mi.mesh.get_aabb()
for i in 8:
var t: float = (xf * aabb.get_endpoint(i)).dot(local_fwd)
min_t = minf(min_t, t)
max_t = maxf(max_t, t)
if min_t > max_t:
return
var s: float = absf(w.scale.z)
var total := max_t - min_t
if total < 0.0001:
return
# Weapon models put their origin wherever the artist left it — for the M4
# that is barely 10 cm behind the muzzle end, so hanging the hand there
# and then parking the stock in the shoulder shoved the hand INTO the
# shoulder and the arm folded up behind the head. Re-seat the weapon so
# the hand sits at a realistic pistol-grip point (~a third back from the
# muzzle), which puts real length of gun behind the hand to reach the
# shoulder with.
var grip_at := min_t + total * 0.32
w.position -= _pose_mod.gun_fwd_hand * (grip_at * s)
var back := (grip_at - min_t) * s # butt of the stock, behind the grip
var front := (max_t - grip_at) * s # muzzle, ahead of the grip
_pose_mod.gun_stock = clampf(back, 0.10, 0.40)
# Support hand rides partway out the handguard, never past the muzzle.
_pose_mod.gun_fore = clampf(front * 0.55, 0.14, 0.45)
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
func _find_bone(name_parts: Array) -> int: func _find_bone(name_parts: Array) -> int:
@@ -501,6 +686,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var recoil: float = 0.0 # decaying shot kick var recoil: float = 0.0 # decaying shot kick
var state: String = "idle" var state: String = "idle"
var weapon_held: bool = false 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). # Per-arm rifle-hold weights (0 = clip owns the arm, 1 = hold pose owns it).
var hold_r_target: float = 0.0 var hold_r_target: float = 0.0
var hold_l_target: float = 0.0 var hold_l_target: float = 0.0
@@ -510,6 +698,12 @@ class ShooterPoseModifier extends SkeletonModifier3D:
# The attached gun's forward/up axes in hand-bone space (set on set_weapon). # The attached gun's forward/up axes in hand-bone space (set on set_weapon).
var gun_fwd_hand: Vector3 = Vector3.ZERO var gun_fwd_hand: Vector3 = Vector3.ZERO
var gun_up_hand: Vector3 = Vector3.UP 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
# Tuning (radians). Positive pitch leans forward; positive roll leans right. # Tuning (radians). Positive pitch leans forward; positive roll leans right.
const LEAN_ROLL := 0.30 const LEAN_ROLL := 0.30
@@ -557,6 +751,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_slide(skel) _apply_slide(skel)
if absf(wall) > 0.01: if absf(wall) > 0.01:
_apply_wall_lean(skel) _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: if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel) _apply_rifle_hold(skel)
if recoil > 0.01: if recoil > 0.01:
@@ -577,16 +773,15 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_add_space(skel, _idx.get("DEF-head", -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: shoulders snap back and up, forearms rise; decays fast. # 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: func _apply_recoil(skel: Skeleton3D) -> void:
var k := recoil var k := recoil
var back := Quaternion(Vector3(1, 0, 0), -0.12 * k) var back := Quaternion(Vector3(1, 0, 0), -0.05 * k)
for n in ["DEF-spine.002", "DEF-spine.003"]: for n in ["DEF-spine.002", "DEF-spine.003"]:
_add_space(skel, _idx.get(n, -1), back) _add_space(skel, _idx.get(n, -1), back)
var arm_up := Quaternion(Vector3(1, 0, 0), -0.3 * k)
_add_space(skel, _idx.get("DEF-upper_arm.R", -1), arm_up)
_add_space(skel, _idx.get("DEF-upper_arm.L", -1), arm_up)
_add_space(skel, _idx.get("DEF-forearm.R", -1), Quaternion(Vector3(1, 0, 0), -0.22 * k))
# Distribute a skeleton-space lean across the spine bones. # Distribute a skeleton-space lean across the spine bones.
func _apply_lean(skel: Skeleton3D) -> void: func _apply_lean(skel: Skeleton3D) -> void:
@@ -599,6 +794,50 @@ class ShooterPoseModifier extends SkeletonModifier3D:
for n in SPINE: for n in SPINE:
_add_space(skel, _idx.get(n, -1), per) _add_space(skel, _idx.get(n, -1), per)
# 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. # Wall run: roll into the wall, drive forward, inner arm reaches the wall.
func _apply_wall_lean(skel: Skeleton3D) -> void: func _apply_wall_lean(skel: Skeleton3D) -> void:
var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \ var q := Quaternion(Vector3(0, 0, 1), wall * 0.35) \
@@ -639,88 +878,207 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide)) _add_space(skel, _idx.get("DEF-forearm.L", -1), Quaternion(Vector3(1, 0, 0), 0.35 * slide))
# ── Two-hand rifle hold ────────────────────────────────────────────────── # ── Two-hand rifle hold ──────────────────────────────────────────────────
# REPLACES the arm-chain rotations from the base clip with a deterministic # The gun is parented to the RIGHT HAND bone, so where the hands go decides
# FK pose: each arm bone is aimed along an art-directed skeleton-space # where the gun goes. We therefore place the WEAPON first — stock in the
# DIRECTION (low-ready at the hip, shouldered on ADS), so the hold looks # shoulder pocket, barrel down the aim line — then solve both arms with
# identical in every locomotion state — no unarmed jog arms flailing # two-bone IK to the resulting grip and foregrip points. That is what makes
# around a floating gun. Directions are in skeleton space: character faces # the stock actually meet the shoulder and the support hand actually touch
# +Z, up +Y, character-right -X. # the handguard, instead of both arms waving at art-directed angles near it.
# Skeleton space: character faces +Z, up +Y, character-right -X.
# Right arm: gun hand — elbow at the ribs, hand ahead of the right hip,
# rifle line pointing forward-down.
const R_UA_HIP := Vector3(-0.30, -0.90, 0.28)
const R_FA_HIP := Vector3(0.25, 0.15, 0.95)
# Left arm: support hand crosses to the foregrip ahead of the belly.
const L_UA_HIP := Vector3(0.32, -0.80, 0.42)
const L_FA_HIP := Vector3(-0.25, 0.10, 0.96)
# ADS: both arms rise with bent elbows, hands stack along the eye line.
const R_UA_ADS := Vector3(-0.30, -0.60, 0.70)
const R_FA_ADS := Vector3(0.35, 0.45, 0.85)
const L_UA_ADS := Vector3(0.28, -0.50, 0.80)
const L_FA_ADS := Vector3(-0.25, 0.45, 0.88)
# Twist about the bone line (radians) to keep elbows/palms natural.
const R_UA_TWIST := 0.3; const L_UA_TWIST := -0.3
const L_HAND_TWIST := 0.5
const GUN_PITCH_HIP := 0.38 # muzzle tilts down this much at low-ready const GUN_PITCH_HIP := 0.38 # muzzle tilts down this much at low-ready
# 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.
const POCKET_ADS := Vector3(0.05, 0.01, 0.07) # in the shoulder pocket
const POCKET_HIP := Vector3(0.02, -0.20, 0.05) # tucked down at the ribs
# 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: func _apply_rifle_hold(skel: Skeleton3D) -> void:
var breathe := sin(_time * 2.2) * 0.015 + fwd * 0.03 var ua_r: int = _idx.get("DEF-upper_arm.R", -1)
var kick := recoil * 0.2 var ua_l: int = _idx.get("DEF-upper_arm.L", -1)
# Aim pitch tilts the whole hold on ADS; recoil kicks the muzzle up. if ua_r < 0:
var pitch := (-aim_pitch * ads) - kick - breathe return
var q_pitch := Quaternion(Vector3(1, 0, 0), pitch) 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
var ua_r: Vector3 = q_pitch * R_UA_HIP.lerp(R_UA_ADS, ads).normalized() # 1. The gun's line: pitched down at low-ready, on the camera line at
var fa_r: Vector3 = q_pitch * R_FA_HIP.lerp(R_FA_ADS, ads).normalized() # ADS, kicked up by recoil.
var ua_l: Vector3 = q_pitch * L_UA_HIP.lerp(L_UA_ADS, ads).normalized() var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick + breathe
var fa_l: Vector3 = q_pitch * L_FA_HIP.lerp(L_FA_ADS, ads).normalized() 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()
var g_fa_r := _aim_chain(skel, "DEF-upper_arm.R", "DEF-forearm.R", # 2. Anchor the stock at the shoulder, then walk out along the barrel.
ua_r, fa_r, R_UA_TWIST, _hold_r) var shoulder := skel.get_bone_global_pose(ua_r).origin
var g_fa_l := _aim_chain(skel, "DEF-upper_arm.L", "DEF-forearm.L", var pocket: Vector3 = POCKET_HIP.lerp(POCKET_ADS, ads)
ua_l, fa_l, L_UA_TWIST, _hold_l) 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
# Gun hand: orient the wrist so the MUZZLE points exactly along the aim # 3. Support hand goes to the mag well during a reload (under the
# line (forward-down at low-ready, camera pitch on ADS), gun kept # receiver — the correct side), otherwise to the handguard.
# upright. This is what makes the weapon read "aimed" instead of var l_target := fore_pos
# dangling at whatever angle the wrist twist happens to produce. 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 \ if _hold_r > 0.001 and g_fa_r != Quaternion.IDENTITY \
and gun_fwd_hand.length_squared() > 0.5: and gun_fwd_hand.length_squared() > 0.5:
var gun_pitch := lerpf(GUN_PITCH_HIP, -aim_pitch, ads) - kick * 2.0
var d := Quaternion(Vector3(1, 0, 0), gun_pitch) * Vector3(0, 0, 1)
var hand: int = _idx.get("DEF-hand.R", -1) var hand: int = _idx.get("DEF-hand.R", -1)
if hand >= 0: if hand >= 0:
var arc := Quaternion(gun_fwd_hand.normalized(), d) var arc := Quaternion(gun_fwd_hand.normalized(), aim_dir)
# Kill the roll: rotate about the aim line so the gun's up
# vector lands in the vertical plane of the aim direction.
var up_now := arc * gun_up_hand.normalized() var up_now := arc * gun_up_hand.normalized()
var side := d.cross(Vector3.UP) var up_flat := (up_now - aim_dir * up_now.dot(aim_dir))
if side.length_squared() > 0.001: if up_flat.length_squared() > 0.0001:
var up_ideal := side.normalized().cross(d).normalized() var roll := up_flat.normalized().signed_angle_to(gun_up, aim_dir)
var up_flat := (up_now - d * up_now.dot(d)).normalized() arc = Quaternion(aim_dir, roll + R_HAND_TWIST) * arc
var roll := up_flat.signed_angle_to(up_ideal, d) _set_global_rot(skel, hand, g_fa_r, arc, _hold_r)
arc = Quaternion(d, roll) * arc
_blend_local(skel, hand, g_fa_r.inverse() * arc, _hold_r)
if OS.has_environment("GUN_POSE_DEBUG"):
var fa_actual := skel.get_bone_global_pose(
_idx.get("DEF-forearm.R", -1)).basis.get_rotation_quaternion()
var hand_actual := skel.get_bone_global_pose(hand).basis.get_rotation_quaternion()
print("MOD DEBUG d=", d,
" fa_target=", g_fa_r, " fa_actual=", fa_actual,
" hand_global*v=", hand_actual * gun_fwd_hand)
# Support hand: follow the forearm line with a fixed palm twist. # 6. Support hand: palm wraps the handguard, following its forearm.
if _hold_l > 0.001 and g_fa_l != Quaternion.IDENTITY: 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 hand_l: int = _idx.get("DEF-hand.L", -1)
var fa_l_idx: int = _idx.get("DEF-forearm.L", -1) var fa_l_idx: int = _idx.get("DEF-forearm.L", -1)
if hand_l >= 0 and fa_l_idx >= 0: if hand_l >= 0 and fa_l_idx >= 0:
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_idx).origin var fa_o := skel.get_bone_global_rest(fa_l_idx).origin
var hand_o := skel.get_bone_global_rest(hand_l).origin var hand_o := skel.get_bone_global_rest(hand_l).origin
var fa_rest_dir := (hand_o - fa_o).normalized() var fa_rest_dir := (hand_o - fa_o).normalized()
var g_hand := Quaternion(fa_l, L_HAND_TWIST) \ var hand_rest_q := skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
* Quaternion(fa_rest_dir, fa_l) * hand_rest_q # Point the palm along the barrel so the fingers close over it.
_blend_local(skel, hand_l, g_fa_l.inverse() * g_hand, _hold_l) 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: # 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 # desired global orientation = (shortest arc from the bone's rest line to
@@ -753,13 +1111,21 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var g_ua := Quaternion(ua_dir, ua_twist) * Quaternion(ua_rest_dir, ua_dir) * ua_rest_q 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 var g_fa := Quaternion(fa_rest_dir, fa_dir) * fa_rest_q
# Local poses against the actual (clip-posed) parent for the shoulder # Convert both to LOCAL pose rotations. Godot composes a bone as
# link, then against our own target down the chain. # 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 parent := skel.get_bone_parent(ua)
var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \ var g_parent := skel.get_bone_global_pose(parent).basis.get_rotation_quaternion() \
if parent >= 0 else Quaternion.IDENTITY if parent >= 0 else Quaternion.IDENTITY
_blend_local(skel, ua, g_parent.inverse() * g_ua, w) _set_global_rot(skel, ua, g_parent, g_ua, w)
_blend_local(skel, fa, g_ua.inverse() * g_fa, 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"): 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 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() var fa_actual := skel.get_bone_global_pose(fa).basis.get_rotation_quaternion()
@@ -773,6 +1139,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
skel.set_bone_pose_rotation(idx, skel.set_bone_pose_rotation(idx,
skel.get_bone_pose_rotation(idx).slerp(target.normalized(), w)) 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. # Compose a skeleton-space rotation onto a bone's animated local pose.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void: func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0: if idx < 0:
+61 -3
View File
@@ -68,9 +68,12 @@ func _process(_delta: float) -> bool:
if not _model: if not _model:
printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin) printerr("anim_capture: no SkinnedModel (skin '%s' active?)" % _skin)
return true return true
# Freeze gameplay driving so we control the animation state directly. # Freeze gameplay driving so we control the animation state directly,
# and teleport to open ground so walls never block the shot.
_player.set_physics_process(false) _player.set_physics_process(false)
_player.set_process(false) _player.set_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_player.rotation = Vector3.ZERO
_model.set_owner_visible(true) _model.set_owner_visible(true)
# Give the model a weapon so armed poses read. # Give the model a weapon so armed poses read.
if _model.has_method("set_weapon"): if _model.has_method("set_weapon"):
@@ -131,12 +134,67 @@ func _process(_delta: float) -> bool:
if _model.has_method("set_locomotion"): if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 1.0) _model.set_locomotion(0.0, 0.0, 1.0)
_phase_frame += 1 _phase_frame += 1
if _phase_frame == 48: if _phase_frame == 108:
_snap("anim_ads_f") _snap("anim_ads_f")
_cam_to(Vector3(2.4, 0.9, -0.4)) _cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 50: elif _phase_frame >= 110:
_snap("anim_ads_s") _snap("anim_ads_s")
_debug_gun() _debug_gun()
_phase_frame = 0
_mode = "recoil"
return false
# Sustained fire: the shot kick must visibly move the third-person model.
if _mode == "recoil":
_model.update_state("idle", 0.0, false)
if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 0.0)
_phase_frame += 1
if _phase_frame == 40:
_snap("anim_recoil_before_f")
elif _phase_frame == 41:
_model.add_gun_recoil() # a single shot
elif _phase_frame == 42:
_snap("anim_recoil_peak_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame == 44:
_model.add_gun_recoil()
_snap("anim_recoil_peak_s")
_cam_to(Vector3(0.4, 0.9, -2.4))
_phase_frame = 0
_mode = "combo"
return false
# Mechanic-fidelity combos: reload WHILE sliding (legs must keep sliding),
# then the grapple zip pose toward an actual anchor point.
if _mode == "combo":
if _model.has_method("set_locomotion"):
_model.set_locomotion(0.0, 0.0, 0.0)
_model.update_state("slide", 10.0, true)
if _phase_frame == 10:
_model.play_action("reload")
_phase_frame += 1
if _phase_frame == 40:
_snap("anim_slide_reload_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 42:
_snap("anim_slide_reload_s")
_phase_frame = 0
_mode = "grapple_zip"
return false
if _mode == "grapple_zip":
if _phase_frame == 0 and _model._anim_tree:
# End the reload one-shot so the zip pose is unpolluted.
_model._anim_tree.set("parameters/upper/request",
AnimationNodeOneShot.ONE_SHOT_REQUEST_ABORT)
_model.update_state("grapple", 12.0, false)
if _model.has_method("set_grapple_target"):
# Anchor up and ahead of the player (player faces -Z world).
_model.set_grapple_target(_player.global_position + Vector3(1.5, 7.0, -7.0))
_phase_frame += 1
if _phase_frame == 48:
_snap("anim_grapple_zip_f")
_cam_to(Vector3(2.4, 0.9, -0.4))
elif _phase_frame >= 50:
_snap("anim_grapple_zip_s")
return true return true
return false return false
return true return true
+39
View File
@@ -0,0 +1,39 @@
extends SceneTree
## Dev tool: print the skeleton's bone hierarchy for a skin's GLB.
## godot --headless --path . -s res://debug/dump_bones.gd -- <res-path-to-glb>
func _init() -> void:
call_deferred("_run")
func _run() -> void:
var args := OS.get_cmdline_user_args()
var path: String = args[0] if args.size() > 0 \
else "res://assets/characters/skins/taila.glb"
var scene := GLBLoader.load(path)
if not scene:
printerr("could not load ", path)
quit(1)
return
var skel: Skeleton3D = _find(scene) as Skeleton3D
if not skel:
printerr("no skeleton")
quit(1)
return
print("bones: ", skel.get_bone_count())
for i in skel.get_bone_count():
var p := skel.get_bone_parent(i)
var pname := skel.get_bone_name(p) if p >= 0 else "-"
var n := skel.get_bone_name(i)
if n.findn("arm") != -1 or n.findn("hand") != -1 or n.findn("shoulder") != -1:
print(" %d %s parent=%s" % [i, n, pname])
quit(0)
func _find(node: Node) -> Node:
if node is Skeleton3D:
return node
for c in node.get_children():
var f := _find(c)
if f:
return f
return null
+156
View File
@@ -0,0 +1,156 @@
extends SceneTree
## Dev tool: screenshot first-person weapon choreography — reload phases for
## representative weapons (mag / boltmag / tube / break), the knife slash,
## and the fire kick. Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/fp_weapon_capture.gd -- <out_dir>
var _frames := 0
var _out_dir := "."
var _player: Node = null
var _wman: Node = null
var _mode := "boot"
var _phase_frame := 0
var _step := 0
# [tag, slot, weapon_id, screenshot fractions of reload_time]
# Slots 11+ so the loadout's own weapons get properly hidden by _equip_slot
# (re-spawning INTO an occupied slot leaves the old node visible).
var _weapons := [
["m4", 11, "m4", [0.12, 0.25, 0.38, 0.55, 0.72, 0.9]],
["awp", 12, "awp", [0.3, 0.6, 0.85]],
["rocket", 13, "rocket_launcher", [0.25, 0.55, 0.8]],
["shotgun", 14, "double_barrel_shotgun", [0.25, 0.6, 0.9]],
]
var _wi := 0
var _shots: Array = []
var _reload_T := 0.0
var _reload_start_ms := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _mode == "boot":
if _frames == 40:
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames >= 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
if p.has_method("get_visual_model") and p.is_multiplayer_authority():
_player = p
break
if not _player:
printerr("fp_capture: no player")
return true
_wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if not _wman:
for c in _player.find_children("*", "Node", true, false):
if c.name == "WeaponManager":
_wman = c
break
if not _wman:
printerr("fp_capture: no WeaponManager")
return true
_player.set_physics_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_mode = "spawn"
return false
if _mode == "spawn":
# Force-spawn the test weapons into known slots.
for w in _weapons:
_wman._spawn_weapon(w[1], w[2])
_mode = "next_weapon"
return false
if _mode == "next_weapon":
if _wi >= _weapons.size():
_mode = "knife_equip"
_phase_frame = 0
return false
var entry: Array = _weapons[_wi]
_wman._equip_slot(entry[1])
_phase_frame = 0
_mode = "reload_start"
return false
if _mode == "reload_start":
_phase_frame += 1
if _phase_frame < 15:
return false
var entry: Array = _weapons[_wi]
var w = _wman.weapons.get(entry[1])
if w == null:
_wi += 1
_mode = "next_weapon"
return false
# Drain ammo state and kick off the reload.
w._start_reload()
_reload_T = w.reload_time
_shots = entry[3].duplicate()
_reload_start_ms = Time.get_ticks_msec()
_mode = "reload_watch"
return false
if _mode == "reload_watch":
var entry: Array = _weapons[_wi]
var elapsed := (Time.get_ticks_msec() - _reload_start_ms) / 1000.0
if _shots.size() > 0:
if elapsed >= float(_shots[0]) * _reload_T:
_snap("fp_%s_reload_%d" % [entry[0], entry[3].size() - _shots.size() + 1])
_shots.pop_front()
elif elapsed >= _reload_T + 0.3:
_wi += 1
_mode = "next_weapon"
return false
if _mode == "knife_equip":
_wman._equip_slot(4)
_phase_frame = 0
_mode = "knife_swing"
return false
if _mode == "knife_swing":
_phase_frame += 1
if _phase_frame == 20:
var knife = _wman.weapons.get(4)
if knife and knife.has_method("_swing"):
knife._swing()
elif _phase_frame == 26:
_snap("fp_knife_windup")
elif _phase_frame == 32:
_snap("fp_knife_slash")
elif _phase_frame >= 60:
_mode = "kick"
_phase_frame = 0
return false
if _mode == "kick":
_phase_frame += 1
if _phase_frame == 5:
_wman._equip_slot(11)
elif _phase_frame == 30:
var w = _wman.weapons.get(11)
if w:
w._fire()
elif _phase_frame == 33:
_snap("fp_m4_kick")
return true
return false
return false
func _snap(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("fp_capture: saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://dlifg4duwpj7v
+59
View File
@@ -0,0 +1,59 @@
extends SceneTree
## Dev tool: screenshot the third-person over-the-shoulder framing, the Alt
## free-look orbit, and the spring-back. Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/orbit_capture.gd -- <out_dir>
var _frames := 0
var _out_dir := "."
var _player: Node = null
var _rig: Node = null
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 40:
var sm = root.get_node_or_null("SkinManager")
if sm:
sm.set_active_skin("taila")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
if p.has_method("set_third_person") and p.is_multiplayer_authority():
_player = p
break
if not _player:
printerr("orbit_capture: no player")
return true
_rig = _player.get_node_or_null("HeadPivot")
_player.set_third_person(true)
elif _frames == 220:
_shot("ots_default") # over-the-shoulder framing, no orbit
elif _frames > 220 and _frames <= 280:
# Simulate holding Alt: force the orbit angles every frame (the
# spring-back fights us since Alt isn't really down, so keep writing).
_rig._orbit_yaw = 1.9
_rig._orbit_pitch = -0.25
elif _frames == 281:
_shot("orbit_side") # camera swung around toward the front
elif _frames == 320:
_shot("sprung_back") # ~0.65s after release: should be behind again
return true
return false
func _shot(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("orbit_capture: saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://dxueyw1h4l7ao
+5 -2
View File
@@ -88,7 +88,9 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
bound = true bound = true
_check(bound, "skinned mesh is bound to the skeleton (won't T-pose)") _check(bound, "skinned mesh is bound to the skeleton (won't T-pose)")
if skinned.animation_player: if skinned.animation_player:
_check(skinned.animation_player.is_playing(), "animation playing") # The AnimationTree drives playback now; the raw player is idle.
_check(skinned._anim_tree != null and skinned._anim_tree.active,
"animation tree active")
print(" clips resolved: ", skinned._resolved_clips) print(" clips resolved: ", skinned._resolved_clips)
# Drive some movement states through the same API the game uses. # Drive some movement states through the same API the game uses.
for state in ["ground", "air", "slide", "wall_run", "dash"]: for state in ["ground", "air", "slide", "wall_run", "dash"]:
@@ -98,7 +100,8 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
# Third-person toggle: camera swap + owner model visibility. # Third-person toggle: camera swap + owner model visibility.
if player.has_method("set_third_person"): if player.has_method("set_third_person"):
var tp_cam = player.get_node_or_null("HeadPivot/ThirdPersonBoom/ThirdPersonCamera") var tp_cam = player.get_node_or_null(
"HeadPivot/OrbitPivot/ShoulderOffset/ThirdPersonBoom/ThirdPersonCamera")
_check(tp_cam != null, "third-person camera boom created") _check(tp_cam != null, "third-person camera boom created")
player.set_third_person(true) player.set_third_person(true)
await process_frame await process_frame
+36 -4
View File
@@ -46,6 +46,9 @@ var _remote_footstep_timer: float = 0.0
@export var synced_action_seq: int = 0 @export var synced_action_seq: int = 0
var _last_action_seq: int = 0 var _last_action_seq: int = 0
var _was_reloading: bool = false var _was_reloading: bool = false
## Ammo count last frame — a drop means we fired, which kicks the owner's
## own third-person model (remote shooters go through server_play_fire_effects).
var _last_local_ammo: int = -1
var dash_player: AudioStreamPlayer var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer var hit_player: AudioStreamPlayer
@@ -168,15 +171,27 @@ func _ready() -> void:
func _setup_third_person_camera() -> void: func _setup_third_person_camera() -> void:
if not head_pivot or not is_instance_valid(camera): if not head_pivot or not is_instance_valid(camera):
return return
# Chain: head_pivot > OrbitPivot (Alt free-look, springs back to zero)
# > ShoulderOffset (camera right of the spine — the character sits
# slightly LEFT of screen centre, Fortnite-style) > SpringArm.
var orbit := Node3D.new()
orbit.name = "OrbitPivot"
head_pivot.add_child(orbit)
var shoulder := Node3D.new()
shoulder.name = "ShoulderOffset"
shoulder.position = Vector3(0.55, 0.12, 0.0)
orbit.add_child(shoulder)
var boom := SpringArm3D.new() var boom := SpringArm3D.new()
boom.name = "ThirdPersonBoom" boom.name = "ThirdPersonBoom"
boom.spring_length = 3.2 boom.spring_length = 2.6
boom.margin = 0.3 boom.margin = 0.3
boom.collision_mask = 1 # environment only boom.collision_mask = 1 # environment only
boom.add_excluded_object(get_rid()) boom.add_excluded_object(get_rid())
# Aim the arm up-and-back from the head so the camera sits behind/above. # Aim the arm up-and-back from the shoulder so the camera sits behind/above.
boom.rotation_degrees = Vector3(20, 0, 0) boom.rotation_degrees = Vector3(14, 0, 0)
head_pivot.add_child(boom) shoulder.add_child(boom)
_tp_camera = Camera3D.new() _tp_camera = Camera3D.new()
_tp_camera.name = "ThirdPersonCamera" _tp_camera.name = "ThirdPersonCamera"
@@ -912,6 +927,8 @@ func _physics_process(_delta: float) -> void:
visual.set_wall_side(sm.wall_side) visual.set_wall_side(sm.wall_side)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing) visual.set_dancing(synced_is_dancing)
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Publish state for remote peers # Publish state for remote peers
synced_movement_state = sm.current_state synced_movement_state = sm.current_state
@@ -995,6 +1012,8 @@ func _process(delta: float) -> void:
visual.set_wall_side(synced_wall_side) visual.set_wall_side(synced_wall_side)
if visual.has_method("set_dancing"): if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing) visual.set_dancing(synced_is_dancing)
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Upper body follows the owner's synced camera pitch # Upper body follows the owner's synced camera pitch
if visual.has_method("set_aim_pitch") and head_pivot: if visual.has_method("set_aim_pitch") and head_pivot:
visual.set_aim_pitch(head_pivot.rotation.x) visual.set_aim_pitch(head_pivot.rotation.x)
@@ -1032,6 +1051,19 @@ func _process(delta: float) -> void:
if now_reloading and not _was_reloading: if now_reloading and not _was_reloading:
_trigger_action("reload") _trigger_action("reload")
_was_reloading = now_reloading _was_reloading = now_reloading
# Shot kick on our OWN third-person model. server_play_fire_effects
# only fires for REMOTE shooters, so without this the owner sees no
# recoil at all in third person. Detect it from the ammo counter.
var ammo_now: int = -1
if lw:
if "current_ammo" in lw:
ammo_now = lw.current_ammo
elif "shells" in lw:
ammo_now = lw.shells
if ammo_now >= 0 and _last_local_ammo >= 0 and ammo_now < _last_local_ammo \
and lvisual.has_method("add_gun_recoil"):
lvisual.add_gun_recoil()
_last_local_ammo = ammo_now
# Update UI # Update UI
if is_instance_valid(health_bar): if is_instance_valid(health_bar):
+15 -8
View File
@@ -91,19 +91,26 @@ static func make_environment(sky_variant: String = "day") -> Environment:
environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY environment.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
environment.ambient_light_energy = 0.55 environment.ambient_light_energy = 0.55
# Filmic crushes the cel bands; linear-ish keeps them crisp. # Filmic with a high white point: near-linear through the cel bands (they
environment.tonemap_mode = Environment.TONE_MAPPER_LINEAR # stay crisp) but a soft shoulder above 1.0. LINEAR clipped lit white
# surfaces and then bloomed the clip — every facade glowed like porcelain.
environment.tonemap_mode = Environment.TONE_MAPPER_FILMIC
environment.tonemap_white = 2.4
# Bloom sells emissives (tracers, plasma, rim highlights). # Bloom for genuine emissives only (tracers, plasma, neon) — lit geometry
# must never cross this threshold or it reads as glowing glass.
environment.glow_enabled = true environment.glow_enabled = true
environment.glow_intensity = 0.5 environment.glow_intensity = 0.4
environment.glow_bloom = 0.05 environment.glow_bloom = 0.05
environment.glow_hdr_threshold = 1.1 environment.glow_hdr_threshold = 1.45
# Cel color grade: punchy saturation, hint of contrast. # Cel color grade: a LIGHT saturation lift only. Anything stronger shifts
# hues — authored ginger hair grades into fire-truck red, pastels go neon —
# and the whole game reads "filtered". Let the textures' own colors carry
# the vibrancy.
environment.adjustment_enabled = true environment.adjustment_enabled = true
environment.adjustment_saturation = 1.22 environment.adjustment_saturation = 1.08
environment.adjustment_contrast = 1.05 environment.adjustment_contrast = 1.03
# A touch of depth haze for scale; far enough to not gray the arena. # A touch of depth haze for scale; far enough to not gray the arena.
environment.fog_enabled = true environment.fog_enabled = true
+7 -2
View File
@@ -85,8 +85,13 @@ static func toonify(src: Material) -> Material:
mat.set_shader_parameter("has_texture", tex != null) mat.set_shader_parameter("has_texture", tex != null)
mat.set_shader_parameter("use_triplanar", false) mat.set_shader_parameter("use_triplanar", false)
mat.set_shader_parameter("albedo_color", col) mat.set_shader_parameter("albedo_color", col)
# Characters read best with a slightly wider lit band and stronger rim. # Fully matte characters: NO specular (even a 2% stepped glint reads as
mat.set_shader_parameter("rim_strength", 0.45) # shine sweeping across hair when the camera moves) and only a whisper
# of rim for silhouette separation. Cel banding carries all the shape.
mat.set_shader_parameter("rim_strength", 0.05)
mat.set_shader_parameter("rim_width", 0.28)
mat.set_shader_parameter("specular_strength", 0.0)
mat.set_shader_parameter("specular_shininess", 64.0)
return mat return mat
+5 -2
View File
@@ -321,14 +321,17 @@ func _build_environment() -> void:
# the energies the old dark flat palette needed). # the energies the old dark flat palette needed).
env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR env.environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.environment.ambient_light_color = Color(0.58, 0.54, 0.66) env.environment.ambient_light_color = Color(0.58, 0.54, 0.66)
env.environment.ambient_light_energy = 1.7 # Under the filmic shoulder the ambient's lavender tint actually shows
# (LINEAR used to clip it to white) — keep it lower so the sun shapes
# surfaces and lit faces stay bright.
env.environment.ambient_light_energy = 1.35
env.environment.fog_density = 0.0007 # light city haze for depth over 600m env.environment.fog_density = 0.0007 # light city haze for depth over 600m
# Ambient occlusion grounds the modeled facade detail (ZZZ-style depth) # Ambient occlusion grounds the modeled facade detail (ZZZ-style depth)
env.environment.ssao_enabled = true env.environment.ssao_enabled = true
env.environment.ssao_intensity = 2.0 env.environment.ssao_intensity = 2.0
var sun := get_node_or_null("Sun") var sun := get_node_or_null("Sun")
if sun: if sun:
sun.light_energy = 2.2 sun.light_energy = 2.5
sun.rotation_degrees = Vector3(-38, 55, 0) sun.rotation_degrees = Vector3(-38, 55, 0)
sun.directional_shadow_max_distance = 220.0 sun.directional_shadow_max_distance = 220.0
+1
View File
@@ -17,6 +17,7 @@ func _init() -> void:
max_range = 300.0 max_range = 300.0
automatic = false automatic = false
spread_angle = 0.0 spread_angle = 0.0
reload_style = "boltmag"
penetration_count = 1 penetration_count = 1
penetration_damage_penalty = 0.5 penetration_damage_penalty = 0.5
+27 -14
View File
@@ -16,11 +16,16 @@ class_name BaseHitscanWeapon
@export var penetration_count: int = 0 @export var penetration_count: int = 0
@export var penetration_damage_penalty: float = 0.5 @export var penetration_damage_penalty: float = 0.5
## Reload choreography style (see ViewmodelAnim): mag, boltmag, break,
## tube, cell, none.
@export var reload_style: String = "mag"
var current_ammo: int = 30 var current_ammo: int = 30
var reloading: bool = false var reloading: bool = false
var reload_timer: float = 0.0 var reload_timer: float = 0.0
var fire_cooldown: float = 0.0 var fire_cooldown: float = 0.0
var is_firing: bool = false var is_firing: bool = false
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
var player: CharacterBody3D var player: CharacterBody3D
var camera: Camera3D var camera: Camera3D
@@ -47,23 +52,22 @@ func _process(delta: float) -> void:
fire_cooldown -= delta fire_cooldown -= delta
if reloading: if reloading:
var spin_speed = PI * 8.0 # The manual-of-arms animation runs via ViewmodelAnim; here we just
model_root.rotation.x += spin_speed * delta # track the timer and hand the ammo over at the end.
reload_timer -= delta
if reload_timer > 0.0: if reload_timer <= 0.0:
reload_timer -= delta current_ammo = max_ammo
else: reloading = false
# Continue the loop until facing normal direction (multiples of 2 PI)
var target_rot = ceil(model_root.rotation.x / (PI * 2.0)) * (PI * 2.0)
if target_rot - model_root.rotation.x < spin_speed * delta * 1.5:
current_ammo = max_ammo
reloading = false
model_root.rotation.x = 0.0
print(weapon_name, " Reloaded!")
elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0: elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0:
_fire() _fire()
# Per-shot recoil: the gun slides back into the grip and the muzzle kicks
# up, then springs back. Scaled by this weapon's recoil so an AWP slams
# while an MP7 chatters.
if not reloading and _vm_kick > 0.001:
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-14.0 * delta))
ViewmodelAnim.apply_kick(self, _vm_kick, recoil_amplitude)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
return return
@@ -85,15 +89,24 @@ func _start_reload() -> void:
reloading = true reloading = true
reload_timer = reload_time reload_timer = reload_time
is_firing = false is_firing = false
_vm_kick = 0.0
ViewmodelAnim.play_reload(self, reload_style, reload_time)
if reload_sound: if reload_sound:
reload_sound.play() reload_sound.play()
## Called by WeaponManager when this weapon is holstered mid-anything.
func unequip() -> void:
ViewmodelAnim.stop(self)
_vm_kick = 0.0
func _fire() -> void: func _fire() -> void:
if current_ammo <= 0 or reloading: if current_ammo <= 0 or reloading:
return return
current_ammo -= 1 current_ammo -= 1
fire_cooldown = fire_rate fire_cooldown = fire_rate
_vm_kick = minf(_vm_kick + 1.0, 1.4)
_shoot_hitscan() _shoot_hitscan()
_play_muzzle_flash() _play_muzzle_flash()
+25 -14
View File
@@ -16,11 +16,16 @@ class_name BaseProjectileWeapon
@export var projectile_speed: float = 50.0 @export var projectile_speed: float = 50.0
@export var projectile_gravity: float = 0.0 @export var projectile_gravity: float = 0.0
## Reload choreography style (see ViewmodelAnim): mag, boltmag, break,
## tube, cell, none.
@export var reload_style: String = "tube"
var current_ammo: int = 30 var current_ammo: int = 30
var reloading: bool = false var reloading: bool = false
var reload_timer: float = 0.0 var reload_timer: float = 0.0
var fire_cooldown: float = 0.0 var fire_cooldown: float = 0.0
var is_firing: bool = false var is_firing: bool = false
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
var player: CharacterBody3D var player: CharacterBody3D
var camera: Camera3D var camera: Camera3D
@@ -46,23 +51,20 @@ func _process(delta: float) -> void:
fire_cooldown -= delta fire_cooldown -= delta
if reloading: if reloading:
var spin_speed = PI * 8.0 # The manual-of-arms animation runs via ViewmodelAnim; here we just
model_root.rotation.x += spin_speed * delta # track the timer and hand the ammo over at the end.
reload_timer -= delta
if reload_timer > 0.0: if reload_timer <= 0.0:
reload_timer -= delta current_ammo = max_ammo
else: reloading = false
# Continue the loop until facing normal direction (multiples of 2 PI)
var target_rot = ceil(model_root.rotation.x / (PI * 2.0)) * (PI * 2.0)
if target_rot - model_root.rotation.x < spin_speed * delta * 1.5:
current_ammo = max_ammo
reloading = false
model_root.rotation.x = 0.0
print(weapon_name, " Reloaded!")
elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0: elif automatic and is_firing and fire_cooldown <= 0.0 and current_ammo > 0:
_fire() _fire()
# Per-shot recoil kick (launchers shove hard; see recoil_amplitude).
if not reloading and _vm_kick > 0.001:
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-14.0 * delta))
ViewmodelAnim.apply_kick(self, _vm_kick, recoil_amplitude)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
return return
@@ -84,15 +86,24 @@ func _start_reload() -> void:
reloading = true reloading = true
reload_timer = reload_time reload_timer = reload_time
is_firing = false is_firing = false
_vm_kick = 0.0
ViewmodelAnim.play_reload(self, reload_style, reload_time)
if reload_sound: if reload_sound:
reload_sound.play() reload_sound.play()
## Called by WeaponManager when this weapon is holstered mid-anything.
func unequip() -> void:
ViewmodelAnim.stop(self)
_vm_kick = 0.0
func _fire() -> void: func _fire() -> void:
if current_ammo <= 0 or reloading: if current_ammo <= 0 or reloading:
return return
current_ammo -= 1 current_ammo -= 1
fire_cooldown = fire_rate fire_cooldown = fire_rate
_vm_kick = minf(_vm_kick + 1.0, 1.4)
_shoot_projectile() _shoot_projectile()
_play_muzzle_flash() _play_muzzle_flash()
+15 -7
View File
@@ -6,6 +6,7 @@ class_name DoubleBarrelShotgun
var shells: int = 2 var shells: int = 2
var reloading: bool = false var reloading: bool = false
var reload_timer: float = 0.0 var reload_timer: float = 0.0
var _vm_kick: float = 0.0
var player: CharacterBody3D var player: CharacterBody3D
var camera: Camera3D var camera: Camera3D
@@ -57,16 +58,14 @@ func _build_model() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if reloading: if reloading:
reload_timer -= delta reload_timer -= delta
# Spin vertically (flip) over the duration of the reload
var spin_speed = (PI * 2.0) / reload_time
model_root.rotation.x += spin_speed * delta
if reload_timer <= 0.0: if reload_timer <= 0.0:
shells = 2 shells = 2
reloading = false reloading = false
model_root.rotation.x = 0.0 # Snap back to perfectly level
print("Shotgun Reloaded!") # Big single-shot shove that springs back.
if not reloading and _vm_kick > 0.001:
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-12.0 * delta))
ViewmodelAnim.apply_kick(self, _vm_kick, 0.10)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
@@ -84,13 +83,22 @@ func _input(event: InputEvent) -> void:
func _start_reload() -> void: func _start_reload() -> void:
reloading = true reloading = true
reload_timer = reload_time reload_timer = reload_time
_vm_kick = 0.0
ViewmodelAnim.play_reload(self, "break", reload_time)
if reload_sound: if reload_sound:
reload_sound.play() reload_sound.play()
## Called by WeaponManager when this weapon is holstered mid-anything.
func unequip() -> void:
ViewmodelAnim.stop(self)
_vm_kick = 0.0
func _try_fire() -> void: func _try_fire() -> void:
if shells > 0 and not reloading: if shells > 0 and not reloading:
shells -= 1 shells -= 1
print("BANG! Shells left: ", shells) print("BANG! Shells left: ", shells)
_vm_kick = minf(_vm_kick + 1.0, 1.4)
_apply_impulse() _apply_impulse()
_shoot_hitscan() _shoot_hitscan()
_play_muzzle_flash() _play_muzzle_flash()
+27 -15
View File
@@ -72,33 +72,45 @@ func do_quick_melee(prev_slot: int) -> void:
get_parent()._equip_slot(auto_switch_slot) get_parent()._equip_slot(auto_switch_slot)
auto_switch_slot = -1 auto_switch_slot = -1
var _swing_alt: bool = false # alternate slash direction each attack
func _swing() -> void: func _swing() -> void:
is_swinging = true is_swinging = true
fire_cooldown = fire_rate fire_cooldown = fire_rate
_swing_alt = not _swing_alt
if fire_sound: if fire_sound:
fire_sound.play() fire_sound.play()
# Tween for swipe animation # A real slash: windup opposite, then a fast diagonal arc THROUGH screen
# centre with the blade leading, alternating backhand/forehand.
var s := 1.0 if _swing_alt else -1.0
var tween = create_tween() var tween = create_tween()
# Windup: cock back and up on the swing-origin side, blade rolled outward.
tween.tween_property(self, "position",
Vector3(0.55 * s, 0.05, -0.55), 0.08).set_ease(Tween.EASE_OUT)
tween.parallel().tween_property(self, "rotation", Vector3(
deg_to_rad(35), deg_to_rad(70 * s), deg_to_rad(-70 * s)), 0.08)
# Slash: whip across to the other side, low — a full diagonal cut.
tween.tween_property(self, "position",
Vector3(-0.55 * s, -0.42, -0.80), 0.09).set_ease(Tween.EASE_IN)
tween.parallel().tween_property(self, "rotation", Vector3(
deg_to_rad(-25), deg_to_rad(120 * s), deg_to_rad(-100 * s)), 0.09)
# Follow-through drift before recovering to guard.
tween.tween_property(self, "position",
Vector3(-0.62 * s, -0.5, -0.72), 0.06)
tween.tween_property(self, "position", default_pos, 0.18).set_ease(Tween.EASE_OUT)
tween.parallel().tween_property(self, "rotation", Vector3.ZERO, 0.18)
# Windup: Move to the right, point the blade left and tilt it # Third-person: the model swings an arm (synced to other players too).
tween.tween_property(self, "position", Vector3(0.6, -0.2, -0.630), 0.1) if player and player.has_method("_trigger_action"):
tween.parallel().tween_property(self, "rotation", Vector3(deg_to_rad(10), deg_to_rad(80), deg_to_rad(-40)), 0.1) player._trigger_action("melee")
# Swipe: Move across the screen to the left quickly # Damage lands mid-slash.
tween.tween_property(self, "position", Vector3(-0.6, -0.3, -0.788), 0.1) get_tree().create_timer(0.12).timeout.connect(_do_damage)
tween.parallel().tween_property(self, "rotation", Vector3(deg_to_rad(10), deg_to_rad(110), deg_to_rad(-60)), 0.1)
# Return to idle
tween.tween_property(self, "position", default_pos, 0.2)
tween.parallel().tween_property(self, "rotation", Vector3.ZERO, 0.2)
# Damage occurs partway through the swing
get_tree().create_timer(0.15).timeout.connect(_do_damage)
# Finish swing # Finish swing
get_tree().create_timer(0.4).timeout.connect(_finish_swing) get_tree().create_timer(0.41).timeout.connect(_finish_swing)
func _do_damage() -> void: func _do_damage() -> void:
if not camera: return if not camera: return
+1
View File
@@ -3,6 +3,7 @@ class_name NailGun
func _init() -> void: func _init() -> void:
weapon_name = "Nail Gun" weapon_name = "Nail Gun"
reload_style = "cell"
fire_rate = 0.06 # Very fast (1000 RPM) fire_rate = 0.06 # Very fast (1000 RPM)
max_ammo = 60 max_ammo = 60
reload_time = 2.0 reload_time = 2.0
+1
View File
@@ -3,6 +3,7 @@ class_name PlasmaGun
func _init() -> void: func _init() -> void:
weapon_name = "Plasma Gun" weapon_name = "Plasma Gun"
reload_style = "cell"
fire_rate = 0.15 # Moderate fire_rate = 0.15 # Moderate
max_ammo = 25 max_ammo = 25
reload_time = 1.8 reload_time = 1.8
+305
View File
@@ -0,0 +1,305 @@
extends Object
class_name ViewmodelAnim
## Procedural first-person weapon choreography.
##
## Replaces the old "spin the gun 360° to reload" placeholder with per-style
## reload sequences that read like the real manual of arms, plus a per-shot
## viewmodel recoil kick. Everything is tween-driven on nodes the weapon
## already has: `model_root` (the gun) and the named arm pivots ("ArmL")
## built by WeaponManager, plus temporary prop meshes (magazine, shells,
## rocket) spawned for the duration.
##
## Styles:
## "mag" box-mag rifles/SMGs: tilt gun, left hand pulls the mag,
## discards it, brings a fresh one, seats it, charges the bolt.
## "boltmag" same, then a distinct bolt-cycle rock (AWP/DMR).
## "break" break-action: gun hinges open, shells eject, reload, snap shut.
## "tube" launchers: gun lowers, a rocket/shell is shoved into the tube.
## "cell" energy weapons: glowing cell swapped on the side.
## "none" no animation (ammo just refills).
##
## All state (original transforms, spawned props, the tween) is stored on the
## weapon via metadata and torn down by `stop()` — called on finish, unequip,
## or a new reload starting.
const META_TWEEN := "vm_reload_tween"
const META_PROPS := "vm_reload_props"
const META_ARM_L_XFORM := "vm_arm_l_xform"
static func play_reload(w: Node3D, style: String, T: float) -> void:
if style == "none" or not is_instance_valid(w) or not ("model_root" in w):
return
stop(w) # clean slate; also restores transforms from any prior run
var root: Node3D = w.model_root
var arm_l: Node3D = w.get_node_or_null("ArmL")
if arm_l and not w.has_meta(META_ARM_L_XFORM):
w.set_meta(META_ARM_L_XFORM, arm_l.transform)
var tw := w.create_tween()
w.set_meta(META_TWEEN, tw)
match style:
"boltmag":
_mag_sequence(w, root, arm_l, tw, T, true)
"break":
_break_sequence(w, root, arm_l, tw, T)
"tube":
_tube_sequence(w, root, arm_l, tw, T)
"cell":
_cell_sequence(w, root, arm_l, tw, T)
_:
_mag_sequence(w, root, arm_l, tw, T, false)
tw.tween_callback(func(): stop(w))
## Kill the running choreography and restore the resting pose.
static func stop(w: Node3D) -> void:
if not is_instance_valid(w):
return
if w.has_meta(META_TWEEN):
var tw = w.get_meta(META_TWEEN)
if tw is Tween and tw.is_valid():
tw.kill()
w.remove_meta(META_TWEEN)
if w.has_meta(META_PROPS):
for p in w.get_meta(META_PROPS):
if is_instance_valid(p):
p.queue_free()
w.remove_meta(META_PROPS)
if "model_root" in w and is_instance_valid(w.model_root):
w.model_root.transform = Transform3D.IDENTITY
var arm_l: Node3D = w.get_node_or_null("ArmL")
if arm_l and w.has_meta(META_ARM_L_XFORM):
arm_l.transform = w.get_meta(META_ARM_L_XFORM)
## Per-shot viewmodel kick: the gun slides back into the grip and the muzzle
## flips up, scaled by the weapon's recoil so every gun has its own punch.
## Weapons call this from _process with their decaying kick value 0..1.
static func apply_kick(w: Node3D, kick: float, amplitude: float) -> void:
if not ("model_root" in w) or not is_instance_valid(w.model_root):
return
var root: Node3D = w.model_root
root.position.z = kick * amplitude * 2.2
root.rotation.x = kick * amplitude * 5.0
# ── Sequences ────────────────────────────────────────────────────────────────
# Times are fractions of the weapon's reload_time so a 1.6 s M4 mag swap and
# a 3 s AWP reload both fill their whole window.
static func _mag_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float, bolt_cycle: bool) -> void:
# Mag prop rides the left hand. Sits at the gun's mag well when stowed.
var mag := _make_prop(w, arm_l, Vector3(0.07, 0.22, 0.11),
Color(0.42, 0.44, 0.50), Vector3(0.02, -0.30, -0.30))
var mag_t := 0.55 if bolt_cycle else 0.78 # leave room for the bolt rock
# 1. Bring the gun UP into view and roll it so the mag well faces the
# player — real reloads happen at chest height, gun clearly on screen.
tw.tween_property(root, "rotation", Vector3(0.05, 0.15, 0.45), 0.12 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.16, 0.18), 0.12 * T)
# Left hand leaves the foregrip for the mag well.
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(0.02, -0.10, -0.34), 0.12 * T)
# 2. Mag out: hand rips it down and back (mag visibly leaves the gun).
tw.tween_callback(func(): mag.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.02, -0.10, -0.34), Vector3(-0.12, -0.40, -0.14), 0.18 * mag_t * T) \
.set_ease(Tween.EASE_IN_OUT)
# 3. Discard, grab fresh mag low off-screen.
tw.tween_callback(func(): mag.visible = false)
tw.tween_interval(0.14 * mag_t * T)
tw.tween_callback(func(): mag.visible = true)
# 4. Fresh mag back up to the well.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.12, -0.40, -0.14), Vector3(0.02, -0.10, -0.34), 0.22 * mag_t * T) \
.set_ease(Tween.EASE_IN_OUT)
# 5. Seat it (small upward jolt on the gun), hide the prop.
tw.tween_property(root, "position", Vector3(-0.10, 0.19, 0.18), 0.05 * T) \
.set_ease(Tween.EASE_OUT)
tw.tween_callback(func(): mag.visible = false)
tw.tween_property(root, "position", Vector3(-0.10, 0.16, 0.18), 0.06 * T)
# 6. Hand back to the foregrip while the gun levels out.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.06, -0.34), Vector3(-0.02, -0.02, -0.3), 0.12 * T)
tw.parallel().tween_property(root, "rotation", Vector3.ZERO, 0.12 * T)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.12 * T)
# 7. Charge: sharp pull back, snap forward. Bolt guns rock the whole gun.
if bolt_cycle:
tw.tween_property(root, "rotation", Vector3(-0.14, 0.0, -0.08), 0.10 * T)
tw.parallel().tween_property(root, "position", Vector3(0, 0.01, 0.10), 0.10 * T)
tw.tween_interval(0.06 * T)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.08 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.08 * T)
else:
tw.tween_property(root, "position", Vector3(0, 0, 0.06), 0.06 * T)
tw.tween_property(root, "position", Vector3.ZERO, 0.05 * T) \
.set_ease(Tween.EASE_OUT)
static func _break_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var shell_a := _make_prop(w, root, Vector3(0.028, 0.028, 0.07),
Color(0.75, 0.25, 0.15), Vector3(-0.01, 0.03, -0.25))
var shell_b := _make_prop(w, root, Vector3(0.028, 0.028, 0.07),
Color(0.75, 0.25, 0.15), Vector3(0.02, 0.03, -0.25))
# Hinge open — barrels drop forward-down, breech rises into view — and
# flick the spent shells up and back.
tw.tween_property(root, "rotation", Vector3(-0.42, 0.15, 0.06), 0.18 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.10, 0.16), 0.18 * T)
tw.tween_callback(func():
shell_a.visible = true
shell_b.visible = true)
tw.tween_property(shell_a, "position", shell_a.position + Vector3(-0.06, 0.28, 0.25), 0.2 * T)
tw.parallel().tween_property(shell_b, "position", shell_b.position + Vector3(0.08, 0.24, 0.28), 0.2 * T)
tw.parallel().tween_property(shell_a, "rotation", Vector3(2.5, 0, 1.0), 0.2 * T)
tw.parallel().tween_property(shell_b, "rotation", Vector3(2.1, 0, -1.2), 0.2 * T)
tw.tween_callback(func():
shell_a.visible = false
shell_b.visible = false
shell_a.position = Vector3(-0.01, 0.10, -0.25)
shell_b.position = Vector3(0.02, 0.10, -0.25)
shell_a.rotation = Vector3.ZERO
shell_b.rotation = Vector3.ZERO)
# Left hand brings two fresh shells and drops them in.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(-0.04, -0.5, -0.1), 0.14 * T)
tw.tween_callback(func():
shell_a.visible = true
shell_b.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.04, -0.5, -0.1), Vector3(0.0, -0.06, -0.3), 0.16 * T)
tw.tween_property(shell_a, "position", Vector3(-0.01, 0.03, -0.25), 0.12 * T)
tw.parallel().tween_property(shell_b, "position", Vector3(0.02, 0.03, -0.25), 0.12 * T)
tw.tween_callback(func():
shell_a.visible = false
shell_b.visible = false)
# Snap shut with a little overshoot.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.06, -0.3), Vector3(-0.02, -0.02, -0.3), 0.1 * T)
tw.parallel().tween_property(root, "rotation", Vector3(0.06, 0.0, 0.0), 0.10 * T) \
.set_ease(Tween.EASE_IN)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.10 * T)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.06 * T) \
.set_ease(Tween.EASE_OUT)
static func _tube_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var rocket := _make_prop(w, arm_l if arm_l else root,
Vector3(0.045, 0.045, 0.30), Color(0.35, 0.55, 0.35),
Vector3(0, -0.1, -0.58))
# Bring the tube up across the chest and tip it toward the player.
tw.tween_property(root, "rotation", Vector3(0.28, 0.35, 0.0), 0.18 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.06, 0.16), 0.18 * T)
# Left hand fetches a fresh rocket from below…
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(-0.08, -0.55, -0.05), 0.16 * T)
tw.tween_callback(func(): rocket.visible = true)
# …lines it up with the muzzle and shoves it home.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.08, -0.55, -0.05), Vector3(0.0, -0.12, -0.55), 0.24 * T) \
.set_ease(Tween.EASE_IN_OUT)
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.12, -0.55), Vector3(0.0, -0.05, -0.30), 0.16 * T) \
.set_ease(Tween.EASE_IN)
tw.tween_callback(func(): rocket.visible = false)
# Shoulder it again.
tw.tween_property(root, "rotation", Vector3.ZERO, 0.16 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.16 * T)
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.05, -0.30), Vector3(-0.02, -0.02, -0.3), 0.14 * T)
static func _cell_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var cell := _make_prop(w, arm_l if arm_l else root,
Vector3(0.05, 0.09, 0.05), Color(0.2, 0.95, 0.9),
Vector3(0, -0.1, -0.58), true)
# Raise across the chest and quarter-roll so the cell port faces up.
tw.tween_property(root, "rotation", Vector3(0.08, 0.2, -0.35), 0.14 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.08, 0.14), 0.14 * T)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(0.03, -0.10, -0.30), 0.12 * T)
tw.tween_callback(func(): cell.visible = true)
# Pull the spent cell, flick it away, slot a fresh one.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.03, -0.10, -0.30), Vector3(-0.10, -0.45, -0.12), 0.2 * T)
tw.tween_callback(func(): cell.visible = false)
tw.tween_interval(0.12 * T)
tw.tween_callback(func(): cell.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.10, -0.45, -0.12), Vector3(0.03, -0.10, -0.30), 0.2 * T)
tw.tween_callback(func(): cell.visible = false)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.14 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.14 * T)
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(0.03, -0.10, -0.30), Vector3(-0.02, -0.02, -0.3), 0.14 * T)
# ── Helpers ──────────────────────────────────────────────────────────────────
## Re-aims an arm pivot (shoulder stays put, hand tracks `target` in weapon
## space) — same math WeaponManager used to place the arm initially.
static func _aim_arm(target: Vector3, arm: Node3D) -> void:
if not is_instance_valid(arm):
return
var shoulder := arm.position
var dir := target - shoulder
if dir.length_squared() < 0.0001:
return
arm.transform = Transform3D(Basis.looking_at(dir.normalized(), Vector3.UP), shoulder)
## An invisible-until-needed prop (mag/shell/rocket/cell) parented so it moves
## with the left hand or the gun. Registered for cleanup on stop().
static func _make_prop(w: Node3D, parent: Node3D, size: Vector3, color: Color,
pos: Vector3, emissive: bool = false) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.9
if emissive:
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = 1.6
mesh.material = mat
mi.mesh = mesh
mi.position = pos
mi.visible = false
mi.layers = 1 << 19 # viewmodel layer, same as the rest of the gun
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
(parent if parent else w).add_child(mi)
var props: Array = w.get_meta(META_PROPS) if w.has_meta(META_PROPS) else []
props.append(mi)
w.set_meta(META_PROPS, props)
return mi
+1
View File
@@ -0,0 +1 @@
uid://b3ojytkqb8i88
+7 -4
View File
@@ -272,15 +272,16 @@ func _set_layer_recursive(node: Node, layer_mask: int) -> void:
_set_layer_recursive(child, layer_mask) _set_layer_recursive(child, layer_mask)
func _add_procedural_arms(weapon: Node3D) -> void: func _add_procedural_arms(weapon: Node3D) -> void:
# Attach to weapon instead of model_root to avoid arms spinning on reload # Attach to weapon instead of model_root so reload choreography can move
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05)) # the gun (model_root) and each hand (named pivots) independently.
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05), "ArmR")
if "weapon_name" in weapon and weapon.weapon_name != "Knife": if "weapon_name" in weapon and weapon.weapon_name != "Knife":
_build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3)) _build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3), "ArmL")
## A first-person arm styled after the character skin: dark detached sleeve, ## A first-person arm styled after the character skin: dark detached sleeve,
## glowing cuff, bare hand — instead of the old featureless blue slab. ## glowing cuff, bare hand — instead of the old featureless blue slab.
func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3) -> void: func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3, arm_name: String = "") -> void:
var sleeve_mat = StandardMaterial3D.new() var sleeve_mat = StandardMaterial3D.new()
sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve
sleeve_mat.roughness = 0.8 sleeve_mat.roughness = 0.8
@@ -293,6 +294,8 @@ func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3) -> void:
skin_mat.roughness = 0.9 skin_mat.roughness = 0.9
var pivot = Node3D.new() var pivot = Node3D.new()
if arm_name != "":
pivot.name = arm_name
pivot.position = shoulder pivot.position = shoulder
pivot.look_at_from_position(shoulder, hand, Vector3.UP) pivot.look_at_from_position(shoulder, hand, Vector3.UP)