228 lines
7.3 KiB
GDScript
228 lines
7.3 KiB
GDScript
extends Node3D
|
|
class_name DoubleBarrelShotgun
|
|
|
|
@export var reload_time: float = 1.0
|
|
|
|
## The name and the capacity every other weapon in the set declares.
|
|
##
|
|
## This one did not, and it had "2" written inline in four places. The HUD had no
|
|
## way to ask how big a full load was, so the level's ammo panel carried a
|
|
## special case — `elif active_weapon is DoubleBarrelShotgun` — to fill in the
|
|
## name, and the capacity was simply hardcoded there as well. Anything that ever
|
|
## wanted to display this weapon had to know about it specifically.
|
|
@export var weapon_name: String = "Double Barrel"
|
|
@export var max_shells: int = 2
|
|
|
|
var shells: int = max_shells
|
|
var reloading: bool = false
|
|
var reload_timer: float = 0.0
|
|
var _vm_kick: float = 0.0
|
|
|
|
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:
|
|
set_process_input(true)
|
|
set_process(true)
|
|
_build_model()
|
|
AudioManager.configure_player(fire_sound, "shotgun_fire")
|
|
AudioManager.configure_player(reload_sound, "shotgun_reload")
|
|
|
|
func _build_model() -> void:
|
|
if not model_root:
|
|
model_root = Node3D.new()
|
|
add_child(model_root)
|
|
position = Vector3(0.3, -0.3, -0.690)
|
|
|
|
var model_scene = load("res://assets/weapons/double barrel/source/Double Deuce.fbx")
|
|
if model_scene:
|
|
var model = model_scene.instantiate()
|
|
model_root.add_child(model)
|
|
model.scale = Vector3(0.275, 0.275, 0.275)
|
|
model.rotation_degrees.y = 90
|
|
for light in model.find_children("*", "Light3D", true, false):
|
|
light.queue_free()
|
|
# Muzzle Flash Light
|
|
muzzle_flash = OmniLight3D.new()
|
|
muzzle_flash.light_color = Color(1.0, 0.8, 0.3)
|
|
muzzle_flash.light_energy = 0.0
|
|
muzzle_flash.omni_range = 4.0
|
|
muzzle_flash.position = Vector3(0, 0.03, -0.45)
|
|
model_root.add_child(muzzle_flash)
|
|
|
|
# Audio Players
|
|
fire_sound = AudioStreamPlayer3D.new()
|
|
fire_sound.bus = "SFX"
|
|
fire_sound.stream = AudioManager.stream_for("shotgun_fire", "res://assets/sounds/shotgun_fire.wav")
|
|
fire_sound.position = muzzle_flash.position
|
|
fire_sound.volume_db = -20.0
|
|
add_child(fire_sound)
|
|
|
|
reload_sound = AudioStreamPlayer3D.new()
|
|
reload_sound.bus = "SFX"
|
|
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
|
|
add_child(reload_sound)
|
|
|
|
func _process(delta: float) -> void:
|
|
if reloading:
|
|
reload_timer -= delta
|
|
if reload_timer <= 0.0:
|
|
shells = max_shells
|
|
reloading = false
|
|
|
|
# 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:
|
|
return
|
|
|
|
if event.is_action_pressed("fire"):
|
|
_try_fire()
|
|
elif event.is_action_released("fire"):
|
|
_try_fire()
|
|
|
|
if event.is_action_pressed("reload"):
|
|
if shells < max_shells and not reloading:
|
|
_start_reload()
|
|
|
|
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()
|
|
if fire_sound:
|
|
fire_sound.play()
|
|
|
|
# Start reload timer if we just shot the LAST shell
|
|
if shells == 0:
|
|
_start_reload()
|
|
|
|
elif shells == 0 and not reloading:
|
|
# Empty click, force reload
|
|
_start_reload()
|
|
|
|
func _apply_impulse() -> void:
|
|
if not player or not camera:
|
|
return
|
|
|
|
# Get the shotgun impulse parameter from the player's movement controller
|
|
var impulse_strength = 20.0
|
|
if player.has_method("get") and "params" in player:
|
|
if player.params and "shotgun_jump_impulse" in player.params:
|
|
impulse_strength = player.params.shotgun_jump_impulse
|
|
|
|
# Impulse direction is opposite to the camera's forward direction
|
|
var push_dir = camera.global_transform.basis.z.normalized()
|
|
|
|
# Apply impulse to player velocity
|
|
var final_force = push_dir * impulse_strength
|
|
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
|
player.server_apply_impulse.rpc_id(1, final_force)
|
|
else:
|
|
player.apply_impulse(final_force)
|
|
|
|
## This one extends Node3D directly rather than BaseHitscanWeapon, so it needs
|
|
## its own copy. See BaseHitscanWeapon.world_muzzle(): in third person the
|
|
## viewmodel's muzzle sits inside the player's head, so world-visible effects
|
|
## have to come off the gun the character is actually holding.
|
|
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)
|
|
if m is OmniLight3D:
|
|
var lit: OmniLight3D = m
|
|
lit.light_energy = 8.0
|
|
create_tween().tween_property(lit, "light_energy", 0.0, 0.1)
|
|
if m != muzzle_flash:
|
|
muzzle_flash.light_energy = 8.0
|
|
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
|
|
|
|
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()
|
|
|
|
var num_pellets = 12
|
|
var max_range = 50.0
|
|
var base_dmg = 15.0
|
|
var min_dmg = 3.0
|
|
var falloff_start = 8.0
|
|
var spread_angle = 0.08 # Radians
|
|
|
|
for i in range(num_pellets):
|
|
# Random spread inside cone
|
|
var r = sqrt(randf()) * spread_angle
|
|
var theta = randf() * PI * 2.0
|
|
var offset_dir = (right * cos(theta) + up * sin(theta)) * r
|
|
var pellet_dir = (forward + offset_dir).normalized()
|
|
|
|
var target = origin + pellet_dir * max_range
|
|
|
|
var query = PhysicsRayQueryParameters3D.create(origin, target)
|
|
if player:
|
|
query.exclude = [player.get_rid()]
|
|
|
|
var final_target = target
|
|
var result = space_state.intersect_ray(query)
|
|
if result:
|
|
final_target = result.position
|
|
var hit_dist = origin.distance_to(result.position)
|
|
var damage = base_dmg
|
|
|
|
if hit_dist > falloff_start:
|
|
var falloff_factor = clampf((hit_dist - falloff_start) / (max_range - falloff_start), 0.0, 1.0)
|
|
damage = lerpf(base_dmg, min_dmg, falloff_factor)
|
|
|
|
if result.collider.has_method("apply_impulse"):
|
|
result.collider.apply_impulse(pellet_dir * 2.5) # Apply strong physical force for ragdoll
|
|
|
|
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:
|
|
# ImpactSpawner not available in this build, skip
|
|
pass
|
|
|
|
# Cel-styled cosmetic tracer per pellet
|
|
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
|
|
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
|