feat: real per-weapon reload/recoil choreography + knife slash, fully matte hair
Weapons no longer reload by spinning the gun 360 (that was the upside-down
flip; nothing detached). New ViewmodelAnim module drives per-style manual-of-
arms sequences on the existing model_root + named arm pivots, with a temporary
magazine/shell/rocket/cell prop that visibly leaves and returns to the gun:
- mag (M4/AK/MP7/DMR): roll gun to present the well, hand rips the mag
down and away, fresh mag up, seat, charge. Gun stays UPRIGHT.
- boltmag (AWP): mag swap + a distinct bolt-cycle rock.
- break (double barrel): hinge open, flick both shells out, drop fresh
shells in, snap shut.
- tube (rocket launcher): tip the tube in, shove a rocket home, shoulder.
- cell (plasma/nail gun): glowing energy cell swapped on the side.
Per-shot viewmodel recoil kick (ViewmodelAnim.apply_kick) scaled by each
weapon's recoil_amplitude, so an AWP slams and an MP7 chatters — replaces the
old flat model behaviour. Weapons expose reload_style; base classes call
play_reload / stop (on unequip) / apply_kick.
Knife: the old barely-visible flick is now a real diagonal slash that
alternates backhand/forehand, whips the blade through screen centre, and
fires the third-person melee arm-swing action (synced to other players).
Hair/materials: specular fully to 0 (even a 2% stepped glint swept as shine
across Taila's hair when the camera moved); rim trimmed to a whisper. Fully
matte cloth/hair now.
Verify: debug/fp_weapon_capture.gd screenshots each weapon's reload phases,
the knife slash, and the fire kick.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d46530bd8c
commit
df0bf18e0f
@@ -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 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
|
||||
// Matte-anime defaults: a whisper of rim and near-zero specular. Anything
|
||||
// hotter reads as wet plastic / patent leather on dark surfaces and makes
|
||||
// light hair look shiny.
|
||||
uniform float rim_strength : hint_range(0.0, 2.0) = 0.08;
|
||||
uniform float rim_width : hint_range(0.0, 1.0) = 0.32;
|
||||
uniform float specular_strength : hint_range(0.0, 1.0) = 0.025;
|
||||
// Matte-anime defaults: zero specular (any stepped glint reads as shine
|
||||
// sweeping across hair/cloth when the camera moves), whisper of rim.
|
||||
uniform float rim_strength : hint_range(0.0, 2.0) = 0.05;
|
||||
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;
|
||||
|
||||
@@ -70,6 +70,7 @@ const ACTIONS := {
|
||||
"reload": ["PistolReload", 1.15],
|
||||
"throw": ["Throw", 0.55],
|
||||
"shoot": ["PistolShoot", 0.2],
|
||||
"melee": ["Throw", 0.45], # overhand arm swing doubles as the knife slash
|
||||
}
|
||||
|
||||
var skeleton: Skeleton3D
|
||||
|
||||
@@ -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 @@
|
||||
uid://dxueyw1h4l7ao
|
||||
@@ -85,12 +85,12 @@ static func toonify(src: Material) -> Material:
|
||||
mat.set_shader_parameter("has_texture", tex != null)
|
||||
mat.set_shader_parameter("use_triplanar", false)
|
||||
mat.set_shader_parameter("albedo_color", col)
|
||||
# Matte anime surfaces: a whisper of rim for silhouette pop, near-zero
|
||||
# specular. Characters should read as cloth/hair, not vinyl — any hotter
|
||||
# and dark surfaces gloss up and light hair looks wet.
|
||||
mat.set_shader_parameter("rim_strength", 0.08)
|
||||
mat.set_shader_parameter("rim_width", 0.32)
|
||||
mat.set_shader_parameter("specular_strength", 0.025)
|
||||
# Fully matte characters: NO specular (even a 2% stepped glint reads as
|
||||
# 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
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ func _init() -> void:
|
||||
max_range = 300.0
|
||||
automatic = false
|
||||
spread_angle = 0.0
|
||||
reload_style = "boltmag"
|
||||
penetration_count = 1
|
||||
penetration_damage_penalty = 0.5
|
||||
|
||||
|
||||
@@ -16,11 +16,16 @@ class_name BaseHitscanWeapon
|
||||
@export var penetration_count: int = 0
|
||||
@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 reloading: bool = false
|
||||
var reload_timer: float = 0.0
|
||||
var fire_cooldown: float = 0.0
|
||||
var is_firing: bool = false
|
||||
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
|
||||
|
||||
var player: CharacterBody3D
|
||||
var camera: Camera3D
|
||||
@@ -47,23 +52,22 @@ func _process(delta: float) -> void:
|
||||
fire_cooldown -= delta
|
||||
|
||||
if reloading:
|
||||
var spin_speed = PI * 8.0
|
||||
model_root.rotation.x += spin_speed * delta
|
||||
|
||||
if reload_timer > 0.0:
|
||||
# The manual-of-arms animation runs via ViewmodelAnim; here we just
|
||||
# track the timer and hand the ammo over at the end.
|
||||
reload_timer -= delta
|
||||
else:
|
||||
# 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:
|
||||
if reload_timer <= 0.0:
|
||||
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:
|
||||
_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:
|
||||
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||
return
|
||||
@@ -85,15 +89,24 @@ func _start_reload() -> void:
|
||||
reloading = true
|
||||
reload_timer = reload_time
|
||||
is_firing = false
|
||||
_vm_kick = 0.0
|
||||
ViewmodelAnim.play_reload(self, reload_style, reload_time)
|
||||
if reload_sound:
|
||||
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:
|
||||
if current_ammo <= 0 or reloading:
|
||||
return
|
||||
|
||||
current_ammo -= 1
|
||||
fire_cooldown = fire_rate
|
||||
_vm_kick = minf(_vm_kick + 1.0, 1.4)
|
||||
|
||||
_shoot_hitscan()
|
||||
_play_muzzle_flash()
|
||||
|
||||
@@ -16,11 +16,16 @@ class_name BaseProjectileWeapon
|
||||
@export var projectile_speed: float = 50.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 reloading: bool = false
|
||||
var reload_timer: float = 0.0
|
||||
var fire_cooldown: float = 0.0
|
||||
var is_firing: bool = false
|
||||
var _vm_kick: float = 0.0 # per-shot viewmodel recoil, decays in _process
|
||||
|
||||
var player: CharacterBody3D
|
||||
var camera: Camera3D
|
||||
@@ -46,23 +51,20 @@ func _process(delta: float) -> void:
|
||||
fire_cooldown -= delta
|
||||
|
||||
if reloading:
|
||||
var spin_speed = PI * 8.0
|
||||
model_root.rotation.x += spin_speed * delta
|
||||
|
||||
if reload_timer > 0.0:
|
||||
# The manual-of-arms animation runs via ViewmodelAnim; here we just
|
||||
# track the timer and hand the ammo over at the end.
|
||||
reload_timer -= delta
|
||||
else:
|
||||
# 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:
|
||||
if reload_timer <= 0.0:
|
||||
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:
|
||||
_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:
|
||||
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||
return
|
||||
@@ -84,15 +86,24 @@ func _start_reload() -> void:
|
||||
reloading = true
|
||||
reload_timer = reload_time
|
||||
is_firing = false
|
||||
_vm_kick = 0.0
|
||||
ViewmodelAnim.play_reload(self, reload_style, reload_time)
|
||||
if reload_sound:
|
||||
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:
|
||||
if current_ammo <= 0 or reloading:
|
||||
return
|
||||
|
||||
current_ammo -= 1
|
||||
fire_cooldown = fire_rate
|
||||
_vm_kick = minf(_vm_kick + 1.0, 1.4)
|
||||
|
||||
_shoot_projectile()
|
||||
_play_muzzle_flash()
|
||||
|
||||
@@ -6,6 +6,7 @@ class_name DoubleBarrelShotgun
|
||||
var shells: int = 2
|
||||
var reloading: bool = false
|
||||
var reload_timer: float = 0.0
|
||||
var _vm_kick: float = 0.0
|
||||
|
||||
var player: CharacterBody3D
|
||||
var camera: Camera3D
|
||||
@@ -57,16 +58,14 @@ func _build_model() -> void:
|
||||
func _process(delta: float) -> void:
|
||||
if reloading:
|
||||
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:
|
||||
shells = 2
|
||||
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:
|
||||
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||
@@ -84,13 +83,22 @@ func _input(event: InputEvent) -> void:
|
||||
func _start_reload() -> void:
|
||||
reloading = true
|
||||
reload_timer = reload_time
|
||||
_vm_kick = 0.0
|
||||
ViewmodelAnim.play_reload(self, "break", reload_time)
|
||||
if reload_sound:
|
||||
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:
|
||||
if shells > 0 and not reloading:
|
||||
shells -= 1
|
||||
print("BANG! Shells left: ", shells)
|
||||
_vm_kick = minf(_vm_kick + 1.0, 1.4)
|
||||
_apply_impulse()
|
||||
_shoot_hitscan()
|
||||
_play_muzzle_flash()
|
||||
|
||||
+27
-15
@@ -72,33 +72,45 @@ func do_quick_melee(prev_slot: int) -> void:
|
||||
get_parent()._equip_slot(auto_switch_slot)
|
||||
auto_switch_slot = -1
|
||||
|
||||
var _swing_alt: bool = false # alternate slash direction each attack
|
||||
|
||||
func _swing() -> void:
|
||||
is_swinging = true
|
||||
fire_cooldown = fire_rate
|
||||
_swing_alt = not _swing_alt
|
||||
|
||||
if fire_sound:
|
||||
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()
|
||||
# Windup: cock back and up on the swing-origin side, blade rolled outward.
|
||||
tween.tween_property(self, "position",
|
||||
Vector3(0.55 * s, 0.05, -0.55), 0.08).set_ease(Tween.EASE_OUT)
|
||||
tween.parallel().tween_property(self, "rotation", Vector3(
|
||||
deg_to_rad(35), deg_to_rad(70 * s), deg_to_rad(-70 * s)), 0.08)
|
||||
# Slash: whip across to the other side, low — a full diagonal cut.
|
||||
tween.tween_property(self, "position",
|
||||
Vector3(-0.55 * s, -0.42, -0.80), 0.09).set_ease(Tween.EASE_IN)
|
||||
tween.parallel().tween_property(self, "rotation", Vector3(
|
||||
deg_to_rad(-25), deg_to_rad(120 * s), deg_to_rad(-100 * s)), 0.09)
|
||||
# Follow-through drift before recovering to guard.
|
||||
tween.tween_property(self, "position",
|
||||
Vector3(-0.62 * s, -0.5, -0.72), 0.06)
|
||||
tween.tween_property(self, "position", default_pos, 0.18).set_ease(Tween.EASE_OUT)
|
||||
tween.parallel().tween_property(self, "rotation", Vector3.ZERO, 0.18)
|
||||
|
||||
# Windup: Move to the right, point the blade left and tilt it
|
||||
tween.tween_property(self, "position", Vector3(0.6, -0.2, -0.630), 0.1)
|
||||
tween.parallel().tween_property(self, "rotation", Vector3(deg_to_rad(10), deg_to_rad(80), deg_to_rad(-40)), 0.1)
|
||||
# Third-person: the model swings an arm (synced to other players too).
|
||||
if player and player.has_method("_trigger_action"):
|
||||
player._trigger_action("melee")
|
||||
|
||||
# Swipe: Move across the screen to the left quickly
|
||||
tween.tween_property(self, "position", Vector3(-0.6, -0.3, -0.788), 0.1)
|
||||
tween.parallel().tween_property(self, "rotation", Vector3(deg_to_rad(10), deg_to_rad(110), deg_to_rad(-60)), 0.1)
|
||||
|
||||
# Return to idle
|
||||
tween.tween_property(self, "position", default_pos, 0.2)
|
||||
tween.parallel().tween_property(self, "rotation", Vector3.ZERO, 0.2)
|
||||
|
||||
# Damage occurs partway through the swing
|
||||
get_tree().create_timer(0.15).timeout.connect(_do_damage)
|
||||
# Damage lands mid-slash.
|
||||
get_tree().create_timer(0.12).timeout.connect(_do_damage)
|
||||
|
||||
# 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:
|
||||
if not camera: return
|
||||
|
||||
@@ -3,6 +3,7 @@ class_name NailGun
|
||||
|
||||
func _init() -> void:
|
||||
weapon_name = "Nail Gun"
|
||||
reload_style = "cell"
|
||||
fire_rate = 0.06 # Very fast (1000 RPM)
|
||||
max_ammo = 60
|
||||
reload_time = 2.0
|
||||
|
||||
@@ -3,6 +3,7 @@ class_name PlasmaGun
|
||||
|
||||
func _init() -> void:
|
||||
weapon_name = "Plasma Gun"
|
||||
reload_style = "cell"
|
||||
fire_rate = 0.15 # Moderate
|
||||
max_ammo = 25
|
||||
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)
|
||||
|
||||
func _add_procedural_arms(weapon: Node3D) -> void:
|
||||
# Attach to weapon instead of model_root to avoid arms spinning on reload
|
||||
_build_arm(weapon, Vector3(0.25, -0.3, 0.5), Vector3(0.04, -0.05, 0.05))
|
||||
# Attach to weapon instead of model_root so reload choreography can move
|
||||
# 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":
|
||||
_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,
|
||||
## 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()
|
||||
sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve
|
||||
sleeve_mat.roughness = 0.8
|
||||
@@ -293,6 +294,8 @@ func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3) -> void:
|
||||
skin_mat.roughness = 0.9
|
||||
|
||||
var pivot = Node3D.new()
|
||||
if arm_name != "":
|
||||
pivot.name = arm_name
|
||||
pivot.position = shoulder
|
||||
pivot.look_at_from_position(shoulder, hand, Vector3.UP)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user