Compare commits
6
Commits
d0c746d084
...
0d125dc03f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d125dc03f | ||
|
|
df0bf18e0f | ||
|
|
d46530bd8c | ||
|
|
1ad3563e5a | ||
|
|
87b8ae70df | ||
|
|
b0f26e6dde |
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dlifg4duwpj7v
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dxueyw1h4l7ao
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -45,25 +50,24 @@ func _build_model() -> void:
|
|||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if fire_cooldown > 0.0:
|
if fire_cooldown > 0.0:
|
||||||
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,16 +89,25 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,25 +49,22 @@ func _build_model() -> void:
|
|||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if fire_cooldown > 0.0:
|
if fire_cooldown > 0.0:
|
||||||
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,16 +86,25 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+32
-20
@@ -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.
|
||||||
# Windup: Move to the right, point the blade left and tilt it
|
tween.tween_property(self, "position",
|
||||||
tween.tween_property(self, "position", Vector3(0.6, -0.2, -0.630), 0.1)
|
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(10), deg_to_rad(80), deg_to_rad(-40)), 0.1)
|
tween.parallel().tween_property(self, "rotation", Vector3(
|
||||||
|
deg_to_rad(35), deg_to_rad(70 * s), deg_to_rad(-70 * s)), 0.08)
|
||||||
# Swipe: Move across the screen to the left quickly
|
# Slash: whip across to the other side, low — a full diagonal cut.
|
||||||
tween.tween_property(self, "position", Vector3(-0.6, -0.3, -0.788), 0.1)
|
tween.tween_property(self, "position",
|
||||||
tween.parallel().tween_property(self, "rotation", Vector3(deg_to_rad(10), deg_to_rad(110), deg_to_rad(-60)), 0.1)
|
Vector3(-0.55 * s, -0.42, -0.80), 0.09).set_ease(Tween.EASE_IN)
|
||||||
|
tween.parallel().tween_property(self, "rotation", Vector3(
|
||||||
# Return to idle
|
deg_to_rad(-25), deg_to_rad(120 * s), deg_to_rad(-100 * s)), 0.09)
|
||||||
tween.tween_property(self, "position", default_pos, 0.2)
|
# Follow-through drift before recovering to guard.
|
||||||
tween.parallel().tween_property(self, "rotation", Vector3.ZERO, 0.2)
|
tween.tween_property(self, "position",
|
||||||
|
Vector3(-0.62 * s, -0.5, -0.72), 0.06)
|
||||||
# Damage occurs partway through the swing
|
tween.tween_property(self, "position", default_pos, 0.18).set_ease(Tween.EASE_OUT)
|
||||||
get_tree().create_timer(0.15).timeout.connect(_do_damage)
|
tween.parallel().tween_property(self, "rotation", Vector3.ZERO, 0.18)
|
||||||
|
|
||||||
|
# Third-person: the model swings an arm (synced to other players too).
|
||||||
|
if player and player.has_method("_trigger_action"):
|
||||||
|
player._trigger_action("melee")
|
||||||
|
|
||||||
|
# Damage lands mid-slash.
|
||||||
|
get_tree().create_timer(0.12).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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b3ojytkqb8i88
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user