Feat/visual audio overhaul #21

Merged
Dotts merged 31 commits from feat/visual-audio-overhaul into main 2026-07-21 14:09:18 -07:00
16 changed files with 590 additions and 76 deletions
Showing only changes of commit df0bf18e0f - Show all commits
+5 -6
View File
@@ -16,12 +16,11 @@ 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
// Matte-anime defaults: a whisper of rim and near-zero specular. Anything // Matte-anime defaults: zero specular (any stepped glint reads as shine
// hotter reads as wet plastic / patent leather on dark surfaces and makes // sweeping across hair/cloth when the camera moves), whisper of rim.
// light hair look shiny. uniform float rim_strength : hint_range(0.0, 2.0) = 0.05;
uniform float rim_strength : hint_range(0.0, 2.0) = 0.08; uniform float rim_width : hint_range(0.0, 1.0) = 0.28;
uniform float rim_width : hint_range(0.0, 1.0) = 0.32; uniform float specular_strength : hint_range(0.0, 1.0) = 0.0;
uniform float specular_strength : hint_range(0.0, 1.0) = 0.025;
uniform float specular_shininess : hint_range(1.0, 128.0) = 64.0; uniform float specular_shininess : hint_range(1.0, 128.0) = 64.0;
varying vec3 world_pos; varying vec3 world_pos;
+1
View File
@@ -70,6 +70,7 @@ const ACTIONS := {
"reload": ["PistolReload", 1.15], "reload": ["PistolReload", 1.15],
"throw": ["Throw", 0.55], "throw": ["Throw", 0.55],
"shoot": ["PistolShoot", 0.2], "shoot": ["PistolShoot", 0.2],
"melee": ["Throw", 0.45], # overhand arm swing doubles as the knife slash
} }
var skeleton: Skeleton3D var skeleton: Skeleton3D
+156
View File
@@ -0,0 +1,156 @@
extends SceneTree
## Dev tool: screenshot first-person weapon choreography — reload phases for
## representative weapons (mag / boltmag / tube / break), the knife slash,
## and the fire kick. Run:
## godot --path . --windowed --resolution 1280x720 -s res://debug/fp_weapon_capture.gd -- <out_dir>
var _frames := 0
var _out_dir := "."
var _player: Node = null
var _wman: Node = null
var _mode := "boot"
var _phase_frame := 0
var _step := 0
# [tag, slot, weapon_id, screenshot fractions of reload_time]
# Slots 11+ so the loadout's own weapons get properly hidden by _equip_slot
# (re-spawning INTO an occupied slot leaves the old node visible).
var _weapons := [
["m4", 11, "m4", [0.12, 0.25, 0.38, 0.55, 0.72, 0.9]],
["awp", 12, "awp", [0.3, 0.6, 0.85]],
["rocket", 13, "rocket_launcher", [0.25, 0.55, 0.8]],
["shotgun", 14, "double_barrel_shotgun", [0.25, 0.6, 0.9]],
]
var _wi := 0
var _shots: Array = []
var _reload_T := 0.0
var _reload_start_ms := 0
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _mode == "boot":
if _frames == 40:
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames >= 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
if p.has_method("get_visual_model") and p.is_multiplayer_authority():
_player = p
break
if not _player:
printerr("fp_capture: no player")
return true
_wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if not _wman:
for c in _player.find_children("*", "Node", true, false):
if c.name == "WeaponManager":
_wman = c
break
if not _wman:
printerr("fp_capture: no WeaponManager")
return true
_player.set_physics_process(false)
_player.global_position = Vector3(0, 1.2, 14)
_mode = "spawn"
return false
if _mode == "spawn":
# Force-spawn the test weapons into known slots.
for w in _weapons:
_wman._spawn_weapon(w[1], w[2])
_mode = "next_weapon"
return false
if _mode == "next_weapon":
if _wi >= _weapons.size():
_mode = "knife_equip"
_phase_frame = 0
return false
var entry: Array = _weapons[_wi]
_wman._equip_slot(entry[1])
_phase_frame = 0
_mode = "reload_start"
return false
if _mode == "reload_start":
_phase_frame += 1
if _phase_frame < 15:
return false
var entry: Array = _weapons[_wi]
var w = _wman.weapons.get(entry[1])
if w == null:
_wi += 1
_mode = "next_weapon"
return false
# Drain ammo state and kick off the reload.
w._start_reload()
_reload_T = w.reload_time
_shots = entry[3].duplicate()
_reload_start_ms = Time.get_ticks_msec()
_mode = "reload_watch"
return false
if _mode == "reload_watch":
var entry: Array = _weapons[_wi]
var elapsed := (Time.get_ticks_msec() - _reload_start_ms) / 1000.0
if _shots.size() > 0:
if elapsed >= float(_shots[0]) * _reload_T:
_snap("fp_%s_reload_%d" % [entry[0], entry[3].size() - _shots.size() + 1])
_shots.pop_front()
elif elapsed >= _reload_T + 0.3:
_wi += 1
_mode = "next_weapon"
return false
if _mode == "knife_equip":
_wman._equip_slot(4)
_phase_frame = 0
_mode = "knife_swing"
return false
if _mode == "knife_swing":
_phase_frame += 1
if _phase_frame == 20:
var knife = _wman.weapons.get(4)
if knife and knife.has_method("_swing"):
knife._swing()
elif _phase_frame == 26:
_snap("fp_knife_windup")
elif _phase_frame == 32:
_snap("fp_knife_slash")
elif _phase_frame >= 60:
_mode = "kick"
_phase_frame = 0
return false
if _mode == "kick":
_phase_frame += 1
if _phase_frame == 5:
_wman._equip_slot(11)
elif _phase_frame == 30:
var w = _wman.weapons.get(11)
if w:
w._fire()
elif _phase_frame == 33:
_snap("fp_m4_kick")
return true
return false
return false
func _snap(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/" + tag + ".png"
img.save_png(path)
print("fp_capture: saved ", path)
+1
View File
@@ -0,0 +1 @@
uid://dlifg4duwpj7v
+1
View File
@@ -0,0 +1 @@
uid://dxueyw1h4l7ao
+6 -6
View File
@@ -85,12 +85,12 @@ 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)
# Matte anime surfaces: a whisper of rim for silhouette pop, near-zero # Fully matte characters: NO specular (even a 2% stepped glint reads as
# specular. Characters should read as cloth/hair, not vinyl — any hotter # shine sweeping across hair when the camera moves) and only a whisper
# and dark surfaces gloss up and light hair looks wet. # of rim for silhouette separation. Cel banding carries all the shape.
mat.set_shader_parameter("rim_strength", 0.08) mat.set_shader_parameter("rim_strength", 0.05)
mat.set_shader_parameter("rim_width", 0.32) mat.set_shader_parameter("rim_width", 0.28)
mat.set_shader_parameter("specular_strength", 0.025) mat.set_shader_parameter("specular_strength", 0.0)
mat.set_shader_parameter("specular_shininess", 64.0) mat.set_shader_parameter("specular_shininess", 64.0)
return mat return mat
+1
View File
@@ -17,6 +17,7 @@ func _init() -> void:
max_range = 300.0 max_range = 300.0
automatic = false automatic = false
spread_angle = 0.0 spread_angle = 0.0
reload_style = "boltmag"
penetration_count = 1 penetration_count = 1
penetration_damage_penalty = 0.5 penetration_damage_penalty = 0.5
+29 -16
View File
@@ -16,11 +16,16 @@ class_name BaseHitscanWeapon
@export var penetration_count: int = 0 @export var penetration_count: int = 0
@export var penetration_damage_penalty: float = 0.5 @export var penetration_damage_penalty: float = 0.5
## Reload choreography style (see ViewmodelAnim): mag, boltmag, break,
## tube, cell, none.
@export var reload_style: String = "mag"
var current_ammo: int = 30 var current_ammo: int = 30
var reloading: bool = false var reloading: bool = false
var reload_timer: float = 0.0 var reload_timer: float = 0.0
var fire_cooldown: float = 0.0 var fire_cooldown: float = 0.0
var is_firing: bool = false var is_firing: bool = false
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
var player: CharacterBody3D var player: CharacterBody3D
var camera: Camera3D var camera: Camera3D
@@ -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()
+28 -17
View File
@@ -16,11 +16,16 @@ class_name BaseProjectileWeapon
@export var projectile_speed: float = 50.0 @export var projectile_speed: float = 50.0
@export var projectile_gravity: float = 0.0 @export var projectile_gravity: float = 0.0
## Reload choreography style (see ViewmodelAnim): mag, boltmag, break,
## tube, cell, none.
@export var reload_style: String = "tube"
var current_ammo: int = 30 var current_ammo: int = 30
var reloading: bool = false var reloading: bool = false
var reload_timer: float = 0.0 var reload_timer: float = 0.0
var fire_cooldown: float = 0.0 var fire_cooldown: float = 0.0
var is_firing: bool = false var is_firing: bool = false
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
var player: CharacterBody3D var player: CharacterBody3D
var camera: Camera3D var camera: Camera3D
@@ -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()
+15 -7
View File
@@ -6,6 +6,7 @@ class_name DoubleBarrelShotgun
var shells: int = 2 var shells: int = 2
var reloading: bool = false var reloading: bool = false
var reload_timer: float = 0.0 var reload_timer: float = 0.0
var _vm_kick: float = 0.0
var player: CharacterBody3D var player: CharacterBody3D
var camera: Camera3D var camera: Camera3D
@@ -57,16 +58,14 @@ func _build_model() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
if reloading: if reloading:
reload_timer -= delta reload_timer -= delta
# Spin vertically (flip) over the duration of the reload
var spin_speed = (PI * 2.0) / reload_time
model_root.rotation.x += spin_speed * delta
if reload_timer <= 0.0: if reload_timer <= 0.0:
shells = 2 shells = 2
reloading = false reloading = false
model_root.rotation.x = 0.0 # Snap back to perfectly level
print("Shotgun Reloaded!") # Big single-shot shove that springs back.
if not reloading and _vm_kick > 0.001:
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-12.0 * delta))
ViewmodelAnim.apply_kick(self, _vm_kick, 0.10)
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
@@ -84,13 +83,22 @@ func _input(event: InputEvent) -> void:
func _start_reload() -> void: func _start_reload() -> void:
reloading = true reloading = true
reload_timer = reload_time reload_timer = reload_time
_vm_kick = 0.0
ViewmodelAnim.play_reload(self, "break", reload_time)
if reload_sound: if reload_sound:
reload_sound.play() reload_sound.play()
## Called by WeaponManager when this weapon is holstered mid-anything.
func unequip() -> void:
ViewmodelAnim.stop(self)
_vm_kick = 0.0
func _try_fire() -> void: func _try_fire() -> void:
if shells > 0 and not reloading: if shells > 0 and not reloading:
shells -= 1 shells -= 1
print("BANG! Shells left: ", shells) print("BANG! Shells left: ", shells)
_vm_kick = minf(_vm_kick + 1.0, 1.4)
_apply_impulse() _apply_impulse()
_shoot_hitscan() _shoot_hitscan()
_play_muzzle_flash() _play_muzzle_flash()
+32 -20
View File
@@ -72,33 +72,45 @@ func do_quick_melee(prev_slot: int) -> void:
get_parent()._equip_slot(auto_switch_slot) get_parent()._equip_slot(auto_switch_slot)
auto_switch_slot = -1 auto_switch_slot = -1
var _swing_alt: bool = false # alternate slash direction each attack
func _swing() -> void: func _swing() -> void:
is_swinging = true is_swinging = true
fire_cooldown = fire_rate fire_cooldown = fire_rate
_swing_alt = not _swing_alt
if fire_sound: if fire_sound:
fire_sound.play() fire_sound.play()
# Tween for swipe animation # A real slash: windup opposite, then a fast diagonal arc THROUGH screen
# centre with the blade leading, alternating backhand/forehand.
var s := 1.0 if _swing_alt else -1.0
var tween = create_tween() var tween = create_tween()
# Windup: cock back and up on the swing-origin side, blade rolled outward.
# 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
+1
View File
@@ -3,6 +3,7 @@ class_name NailGun
func _init() -> void: func _init() -> void:
weapon_name = "Nail Gun" weapon_name = "Nail Gun"
reload_style = "cell"
fire_rate = 0.06 # Very fast (1000 RPM) fire_rate = 0.06 # Very fast (1000 RPM)
max_ammo = 60 max_ammo = 60
reload_time = 2.0 reload_time = 2.0
+1
View File
@@ -3,6 +3,7 @@ class_name PlasmaGun
func _init() -> void: func _init() -> void:
weapon_name = "Plasma Gun" weapon_name = "Plasma Gun"
reload_style = "cell"
fire_rate = 0.15 # Moderate fire_rate = 0.15 # Moderate
max_ammo = 25 max_ammo = 25
reload_time = 1.8 reload_time = 1.8
+305
View File
@@ -0,0 +1,305 @@
extends Object
class_name ViewmodelAnim
## Procedural first-person weapon choreography.
##
## Replaces the old "spin the gun 360° to reload" placeholder with per-style
## reload sequences that read like the real manual of arms, plus a per-shot
## viewmodel recoil kick. Everything is tween-driven on nodes the weapon
## already has: `model_root` (the gun) and the named arm pivots ("ArmL")
## built by WeaponManager, plus temporary prop meshes (magazine, shells,
## rocket) spawned for the duration.
##
## Styles:
## "mag" box-mag rifles/SMGs: tilt gun, left hand pulls the mag,
## discards it, brings a fresh one, seats it, charges the bolt.
## "boltmag" same, then a distinct bolt-cycle rock (AWP/DMR).
## "break" break-action: gun hinges open, shells eject, reload, snap shut.
## "tube" launchers: gun lowers, a rocket/shell is shoved into the tube.
## "cell" energy weapons: glowing cell swapped on the side.
## "none" no animation (ammo just refills).
##
## All state (original transforms, spawned props, the tween) is stored on the
## weapon via metadata and torn down by `stop()` — called on finish, unequip,
## or a new reload starting.
const META_TWEEN := "vm_reload_tween"
const META_PROPS := "vm_reload_props"
const META_ARM_L_XFORM := "vm_arm_l_xform"
static func play_reload(w: Node3D, style: String, T: float) -> void:
if style == "none" or not is_instance_valid(w) or not ("model_root" in w):
return
stop(w) # clean slate; also restores transforms from any prior run
var root: Node3D = w.model_root
var arm_l: Node3D = w.get_node_or_null("ArmL")
if arm_l and not w.has_meta(META_ARM_L_XFORM):
w.set_meta(META_ARM_L_XFORM, arm_l.transform)
var tw := w.create_tween()
w.set_meta(META_TWEEN, tw)
match style:
"boltmag":
_mag_sequence(w, root, arm_l, tw, T, true)
"break":
_break_sequence(w, root, arm_l, tw, T)
"tube":
_tube_sequence(w, root, arm_l, tw, T)
"cell":
_cell_sequence(w, root, arm_l, tw, T)
_:
_mag_sequence(w, root, arm_l, tw, T, false)
tw.tween_callback(func(): stop(w))
## Kill the running choreography and restore the resting pose.
static func stop(w: Node3D) -> void:
if not is_instance_valid(w):
return
if w.has_meta(META_TWEEN):
var tw = w.get_meta(META_TWEEN)
if tw is Tween and tw.is_valid():
tw.kill()
w.remove_meta(META_TWEEN)
if w.has_meta(META_PROPS):
for p in w.get_meta(META_PROPS):
if is_instance_valid(p):
p.queue_free()
w.remove_meta(META_PROPS)
if "model_root" in w and is_instance_valid(w.model_root):
w.model_root.transform = Transform3D.IDENTITY
var arm_l: Node3D = w.get_node_or_null("ArmL")
if arm_l and w.has_meta(META_ARM_L_XFORM):
arm_l.transform = w.get_meta(META_ARM_L_XFORM)
## Per-shot viewmodel kick: the gun slides back into the grip and the muzzle
## flips up, scaled by the weapon's recoil so every gun has its own punch.
## Weapons call this from _process with their decaying kick value 0..1.
static func apply_kick(w: Node3D, kick: float, amplitude: float) -> void:
if not ("model_root" in w) or not is_instance_valid(w.model_root):
return
var root: Node3D = w.model_root
root.position.z = kick * amplitude * 2.2
root.rotation.x = kick * amplitude * 5.0
# ── Sequences ────────────────────────────────────────────────────────────────
# Times are fractions of the weapon's reload_time so a 1.6 s M4 mag swap and
# a 3 s AWP reload both fill their whole window.
static func _mag_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float, bolt_cycle: bool) -> void:
# Mag prop rides the left hand. Sits at the gun's mag well when stowed.
var mag := _make_prop(w, arm_l, Vector3(0.07, 0.22, 0.11),
Color(0.42, 0.44, 0.50), Vector3(0.02, -0.30, -0.30))
var mag_t := 0.55 if bolt_cycle else 0.78 # leave room for the bolt rock
# 1. Bring the gun UP into view and roll it so the mag well faces the
# player — real reloads happen at chest height, gun clearly on screen.
tw.tween_property(root, "rotation", Vector3(0.05, 0.15, 0.45), 0.12 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.16, 0.18), 0.12 * T)
# Left hand leaves the foregrip for the mag well.
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(0.02, -0.10, -0.34), 0.12 * T)
# 2. Mag out: hand rips it down and back (mag visibly leaves the gun).
tw.tween_callback(func(): mag.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.02, -0.10, -0.34), Vector3(-0.12, -0.40, -0.14), 0.18 * mag_t * T) \
.set_ease(Tween.EASE_IN_OUT)
# 3. Discard, grab fresh mag low off-screen.
tw.tween_callback(func(): mag.visible = false)
tw.tween_interval(0.14 * mag_t * T)
tw.tween_callback(func(): mag.visible = true)
# 4. Fresh mag back up to the well.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.12, -0.40, -0.14), Vector3(0.02, -0.10, -0.34), 0.22 * mag_t * T) \
.set_ease(Tween.EASE_IN_OUT)
# 5. Seat it (small upward jolt on the gun), hide the prop.
tw.tween_property(root, "position", Vector3(-0.10, 0.19, 0.18), 0.05 * T) \
.set_ease(Tween.EASE_OUT)
tw.tween_callback(func(): mag.visible = false)
tw.tween_property(root, "position", Vector3(-0.10, 0.16, 0.18), 0.06 * T)
# 6. Hand back to the foregrip while the gun levels out.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.06, -0.34), Vector3(-0.02, -0.02, -0.3), 0.12 * T)
tw.parallel().tween_property(root, "rotation", Vector3.ZERO, 0.12 * T)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.12 * T)
# 7. Charge: sharp pull back, snap forward. Bolt guns rock the whole gun.
if bolt_cycle:
tw.tween_property(root, "rotation", Vector3(-0.14, 0.0, -0.08), 0.10 * T)
tw.parallel().tween_property(root, "position", Vector3(0, 0.01, 0.10), 0.10 * T)
tw.tween_interval(0.06 * T)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.08 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.08 * T)
else:
tw.tween_property(root, "position", Vector3(0, 0, 0.06), 0.06 * T)
tw.tween_property(root, "position", Vector3.ZERO, 0.05 * T) \
.set_ease(Tween.EASE_OUT)
static func _break_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var shell_a := _make_prop(w, root, Vector3(0.028, 0.028, 0.07),
Color(0.75, 0.25, 0.15), Vector3(-0.01, 0.03, -0.25))
var shell_b := _make_prop(w, root, Vector3(0.028, 0.028, 0.07),
Color(0.75, 0.25, 0.15), Vector3(0.02, 0.03, -0.25))
# Hinge open — barrels drop forward-down, breech rises into view — and
# flick the spent shells up and back.
tw.tween_property(root, "rotation", Vector3(-0.42, 0.15, 0.06), 0.18 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.10, 0.16), 0.18 * T)
tw.tween_callback(func():
shell_a.visible = true
shell_b.visible = true)
tw.tween_property(shell_a, "position", shell_a.position + Vector3(-0.06, 0.28, 0.25), 0.2 * T)
tw.parallel().tween_property(shell_b, "position", shell_b.position + Vector3(0.08, 0.24, 0.28), 0.2 * T)
tw.parallel().tween_property(shell_a, "rotation", Vector3(2.5, 0, 1.0), 0.2 * T)
tw.parallel().tween_property(shell_b, "rotation", Vector3(2.1, 0, -1.2), 0.2 * T)
tw.tween_callback(func():
shell_a.visible = false
shell_b.visible = false
shell_a.position = Vector3(-0.01, 0.10, -0.25)
shell_b.position = Vector3(0.02, 0.10, -0.25)
shell_a.rotation = Vector3.ZERO
shell_b.rotation = Vector3.ZERO)
# Left hand brings two fresh shells and drops them in.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(-0.04, -0.5, -0.1), 0.14 * T)
tw.tween_callback(func():
shell_a.visible = true
shell_b.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.04, -0.5, -0.1), Vector3(0.0, -0.06, -0.3), 0.16 * T)
tw.tween_property(shell_a, "position", Vector3(-0.01, 0.03, -0.25), 0.12 * T)
tw.parallel().tween_property(shell_b, "position", Vector3(0.02, 0.03, -0.25), 0.12 * T)
tw.tween_callback(func():
shell_a.visible = false
shell_b.visible = false)
# Snap shut with a little overshoot.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.06, -0.3), Vector3(-0.02, -0.02, -0.3), 0.1 * T)
tw.parallel().tween_property(root, "rotation", Vector3(0.06, 0.0, 0.0), 0.10 * T) \
.set_ease(Tween.EASE_IN)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.10 * T)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.06 * T) \
.set_ease(Tween.EASE_OUT)
static func _tube_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var rocket := _make_prop(w, arm_l if arm_l else root,
Vector3(0.045, 0.045, 0.30), Color(0.35, 0.55, 0.35),
Vector3(0, -0.1, -0.58))
# Bring the tube up across the chest and tip it toward the player.
tw.tween_property(root, "rotation", Vector3(0.28, 0.35, 0.0), 0.18 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.06, 0.16), 0.18 * T)
# Left hand fetches a fresh rocket from below…
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(-0.08, -0.55, -0.05), 0.16 * T)
tw.tween_callback(func(): rocket.visible = true)
# …lines it up with the muzzle and shoves it home.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.08, -0.55, -0.05), Vector3(0.0, -0.12, -0.55), 0.24 * T) \
.set_ease(Tween.EASE_IN_OUT)
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.12, -0.55), Vector3(0.0, -0.05, -0.30), 0.16 * T) \
.set_ease(Tween.EASE_IN)
tw.tween_callback(func(): rocket.visible = false)
# Shoulder it again.
tw.tween_property(root, "rotation", Vector3.ZERO, 0.16 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.16 * T)
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(0.0, -0.05, -0.30), Vector3(-0.02, -0.02, -0.3), 0.14 * T)
static func _cell_sequence(w: Node3D, root: Node3D, arm_l: Node3D,
tw: Tween, T: float) -> void:
var cell := _make_prop(w, arm_l if arm_l else root,
Vector3(0.05, 0.09, 0.05), Color(0.2, 0.95, 0.9),
Vector3(0, -0.1, -0.58), true)
# Raise across the chest and quarter-roll so the cell port faces up.
tw.tween_property(root, "rotation", Vector3(0.08, 0.2, -0.35), 0.14 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3(-0.10, 0.08, 0.14), 0.14 * T)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.02, -0.02, -0.3), Vector3(0.03, -0.10, -0.30), 0.12 * T)
tw.tween_callback(func(): cell.visible = true)
# Pull the spent cell, flick it away, slot a fresh one.
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(0.03, -0.10, -0.30), Vector3(-0.10, -0.45, -0.12), 0.2 * T)
tw.tween_callback(func(): cell.visible = false)
tw.tween_interval(0.12 * T)
tw.tween_callback(func(): cell.visible = true)
if arm_l:
tw.tween_method(_aim_arm.bind(arm_l),
Vector3(-0.10, -0.45, -0.12), Vector3(0.03, -0.10, -0.30), 0.2 * T)
tw.tween_callback(func(): cell.visible = false)
tw.tween_property(root, "rotation", Vector3.ZERO, 0.14 * T) \
.set_ease(Tween.EASE_OUT)
tw.parallel().tween_property(root, "position", Vector3.ZERO, 0.14 * T)
if arm_l:
tw.parallel().tween_method(_aim_arm.bind(arm_l),
Vector3(0.03, -0.10, -0.30), Vector3(-0.02, -0.02, -0.3), 0.14 * T)
# ── Helpers ──────────────────────────────────────────────────────────────────
## Re-aims an arm pivot (shoulder stays put, hand tracks `target` in weapon
## space) — same math WeaponManager used to place the arm initially.
static func _aim_arm(target: Vector3, arm: Node3D) -> void:
if not is_instance_valid(arm):
return
var shoulder := arm.position
var dir := target - shoulder
if dir.length_squared() < 0.0001:
return
arm.transform = Transform3D(Basis.looking_at(dir.normalized(), Vector3.UP), shoulder)
## An invisible-until-needed prop (mag/shell/rocket/cell) parented so it moves
## with the left hand or the gun. Registered for cleanup on stop().
static func _make_prop(w: Node3D, parent: Node3D, size: Vector3, color: Color,
pos: Vector3, emissive: bool = false) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = size
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.9
if emissive:
mat.emission_enabled = true
mat.emission = color
mat.emission_energy_multiplier = 1.6
mesh.material = mat
mi.mesh = mesh
mi.position = pos
mi.visible = false
mi.layers = 1 << 19 # viewmodel layer, same as the rest of the gun
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
(parent if parent else w).add_child(mi)
var props: Array = w.get_meta(META_PROPS) if w.has_meta(META_PROPS) else []
props.append(mi)
w.set_meta(META_PROPS, props)
return mi
+1
View File
@@ -0,0 +1 @@
uid://b3ojytkqb8i88
+7 -4
View File
@@ -272,15 +272,16 @@ func _set_layer_recursive(node: Node, layer_mask: int) -> void:
_set_layer_recursive(child, layer_mask) _set_layer_recursive(child, layer_mask)
func _add_procedural_arms(weapon: Node3D) -> void: func _add_procedural_arms(weapon: Node3D) -> void:
# Attach to weapon instead of model_root to avoid arms spinning on reload # Attach to weapon instead of model_root so reload choreography can move
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05)) # the gun (model_root) and each hand (named pivots) independently.
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05), "ArmR")
if "weapon_name" in weapon and weapon.weapon_name != "Knife": if "weapon_name" in weapon and weapon.weapon_name != "Knife":
_build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3)) _build_arm(weapon, Vector3(-0.25, -0.3, 0.4), Vector3(-0.02, -0.02, -0.3), "ArmL")
## A first-person arm styled after the character skin: dark detached sleeve, ## A first-person arm styled after the character skin: dark detached sleeve,
## glowing cuff, bare hand — instead of the old featureless blue slab. ## glowing cuff, bare hand — instead of the old featureless blue slab.
func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3) -> void: func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3, arm_name: String = "") -> void:
var sleeve_mat = StandardMaterial3D.new() var sleeve_mat = StandardMaterial3D.new()
sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve
sleeve_mat.roughness = 0.8 sleeve_mat.roughness = 0.8
@@ -293,6 +294,8 @@ func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3) -> void:
skin_mat.roughness = 0.9 skin_mat.roughness = 0.9
var pivot = Node3D.new() var pivot = Node3D.new()
if arm_name != "":
pivot.name = arm_name
pivot.position = shoulder pivot.position = shoulder
pivot.look_at_from_position(shoulder, hand, Vector3.UP) pivot.look_at_from_position(shoulder, hand, Vector3.UP)