247 lines
8.1 KiB
GDScript
247 lines
8.1 KiB
GDScript
extends Node3D
|
|
class_name BaseHitscanWeapon
|
|
|
|
@export var weapon_name: String = "Base Hitscan"
|
|
@export var fire_rate: float = 0.1 # Seconds between shots
|
|
@export var max_ammo: int = 30
|
|
@export var reload_time: float = 1.5
|
|
@export var base_damage: float = 10.0
|
|
@export var min_damage: float = 5.0
|
|
@export var falloff_start: float = 10.0
|
|
@export var max_range: float = 100.0
|
|
@export var automatic: bool = true
|
|
@export var spread_angle: float = 0.01
|
|
@export var recoil_amplitude: float = 0.05
|
|
|
|
@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
|
|
var muzzle_flash: OmniLight3D
|
|
var fire_sound: AudioStreamPlayer3D
|
|
var reload_sound: AudioStreamPlayer3D
|
|
var model_root: Node3D
|
|
|
|
func _ready() -> void:
|
|
current_ammo = max_ammo
|
|
set_process_input(true)
|
|
set_process(true)
|
|
|
|
model_root = Node3D.new()
|
|
add_child(model_root)
|
|
_build_model()
|
|
if fire_sound:
|
|
AudioManager.configure_player(fire_sound,
|
|
AudioManager.weapon_sound_id(weapon_name))
|
|
if reload_sound:
|
|
AudioManager.configure_player(reload_sound, "shotgun_reload")
|
|
|
|
func _build_model() -> void:
|
|
# Virtual method to be overridden
|
|
pass
|
|
|
|
func _process(delta: float) -> void:
|
|
if fire_cooldown > 0.0:
|
|
fire_cooldown -= delta
|
|
|
|
if reloading:
|
|
# 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
|
|
if reload_timer <= 0.0:
|
|
current_ammo = max_ammo
|
|
reloading = false
|
|
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
|
|
|
|
if event.is_action_pressed("fire"):
|
|
is_firing = true
|
|
if not automatic and fire_cooldown <= 0.0 and current_ammo > 0 and not reloading:
|
|
_fire()
|
|
elif current_ammo == 0 and not reloading:
|
|
_start_reload()
|
|
elif event.is_action_released("fire"):
|
|
is_firing = false
|
|
|
|
if event.is_action_pressed("reload"):
|
|
if current_ammo < max_ammo and not reloading:
|
|
_start_reload()
|
|
|
|
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()
|
|
|
|
if get_parent() and get_parent().has_method("add_recoil"):
|
|
get_parent().add_recoil(recoil_amplitude, randf_range(-recoil_amplitude * 0.5, recoil_amplitude * 0.5))
|
|
|
|
if fire_sound:
|
|
# The event family already randomizes its recorded variations. Keep the
|
|
# player-level shift tiny so sustained fire stays like one weapon, not
|
|
# a row of differently sized cartoon rifles.
|
|
fire_sound.pitch_scale = 1.0 + randf_range(-0.015, 0.015)
|
|
fire_sound.play()
|
|
|
|
if current_ammo == 0:
|
|
_start_reload()
|
|
|
|
## The muzzle the WORLD should see this shot come from. In first person that is
|
|
## the viewmodel's own muzzle. In third person the viewmodel is parked at the
|
|
## camera, so its muzzle sits inside the player's head — the flash has to come
|
|
## off the gun the character is actually holding instead.
|
|
func world_muzzle() -> Node3D:
|
|
if player and "third_person" in player and player.third_person \
|
|
and player.has_method("get_visual_model"):
|
|
var vis = player.get_visual_model()
|
|
if vis and vis.has_method("get_muzzle_node"):
|
|
var m: Node3D = vis.get_muzzle_node()
|
|
if m:
|
|
return m
|
|
return muzzle_flash
|
|
|
|
|
|
func _play_muzzle_flash() -> void:
|
|
if not muzzle_flash:
|
|
return
|
|
var m := world_muzzle()
|
|
ExplosionVFX.muzzle_flash(m.get_parent(), m.position)
|
|
# Light whichever muzzle the world can see, so the flash actually throws
|
|
# light from the barrel rather than from behind the camera.
|
|
if m is OmniLight3D:
|
|
var lit: OmniLight3D = m
|
|
lit.light_energy = 8.0
|
|
create_tween().tween_property(lit, "light_energy", 0.0, 0.05)
|
|
if m != muzzle_flash:
|
|
muzzle_flash.light_energy = 8.0
|
|
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
|
|
|
|
func _shoot_hitscan() -> void:
|
|
if not camera: return
|
|
|
|
var space_state = camera.get_world_3d().direct_space_state
|
|
var origin = camera.global_position
|
|
var forward = -camera.global_transform.basis.z.normalized()
|
|
var right = camera.global_transform.basis.x.normalized()
|
|
var up = camera.global_transform.basis.y.normalized()
|
|
|
|
# Calculate spread
|
|
var r = sqrt(randf()) * spread_angle
|
|
var theta = randf() * PI * 2.0
|
|
var offset_dir = (right * cos(theta) + up * sin(theta)) * r
|
|
var ray_dir = (forward + offset_dir).normalized()
|
|
|
|
var target = origin + ray_dir * max_range
|
|
var query = PhysicsRayQueryParameters3D.create(origin, target)
|
|
|
|
if player:
|
|
query.exclude = [player.get_rid()]
|
|
|
|
var hits_remaining = penetration_count + 1
|
|
var current_damage_mult = 1.0
|
|
var final_target = target
|
|
|
|
while hits_remaining > 0:
|
|
var result = space_state.intersect_ray(query)
|
|
if not result:
|
|
break # Nothing else in the way
|
|
|
|
var hit_dist = origin.distance_to(result.position)
|
|
var damage = base_damage * current_damage_mult
|
|
|
|
if hit_dist > falloff_start:
|
|
var falloff_factor = clampf((hit_dist - falloff_start) / (max_range - falloff_start), 0.0, 1.0)
|
|
damage = lerpf(base_damage * current_damage_mult, min_damage * current_damage_mult, falloff_factor)
|
|
|
|
if result.collider.has_method("take_damage"):
|
|
result.collider.take_damage(damage, result.position, player)
|
|
elif result.collider is StaticBody3D or result.collider is CSGShape3D:
|
|
# Not a target, spawn a bullet hole
|
|
ImpactSpawner.spawn(get_tree(), "bullet", result.position, result.normal, 0.1)
|
|
|
|
# Add this hit to exclude so next loop goes through it
|
|
var exc = query.exclude
|
|
exc.append(result.rid)
|
|
|
|
# If the collider has multiple shapes, we should really exclude the whole collision object
|
|
# but RID of the body should be enough.
|
|
query.exclude = exc
|
|
|
|
hits_remaining -= 1
|
|
current_damage_mult *= penetration_damage_penalty
|
|
final_target = result.position
|
|
_spawn_tracer(origin, final_target)
|
|
|
|
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
|
# Everyone else sees our CHARACTER, never our viewmodel, so the tracer
|
|
# and the gunshot audio have to leave from the gun in its hands — the
|
|
# camera position we raycast from is inside our own head.
|
|
player.server_play_fire_effects.rpc_id(1, remote_muzzle_position(),
|
|
final_target, name, true)
|
|
|
|
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
|
|
# Cel-styled cosmetic tracer from the barrel tip
|
|
HitscanTracer.spawn_bolt(get_tree().current_scene, muzzle_world_position(), final_target)
|
|
|
|
|
|
## World position the shot visually leaves from. Falls back to the weapon node
|
|
## when a weapon has no muzzle marker at all (the AWP).
|
|
func muzzle_world_position() -> Vector3:
|
|
var m := world_muzzle()
|
|
return m.global_position if m else global_position
|
|
|
|
|
|
## Where OTHER clients should see this shot leave from: always the gun in the
|
|
## character's hand, whatever view mode we happen to be in locally.
|
|
func remote_muzzle_position() -> Vector3:
|
|
if player and player.has_method("get_visual_model"):
|
|
var vis = player.get_visual_model()
|
|
if vis and vis.has_method("get_muzzle_node"):
|
|
var m: Node3D = vis.get_muzzle_node()
|
|
if m:
|
|
return m.global_position
|
|
return muzzle_world_position()
|