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() 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: # Slight pitch variation fire_sound.pitch_scale = 1.0 + randf_range(-0.05, 0.05) 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()