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)