# TODO: "Click" sound when attempting to fire when not ready, or empty. # TODO: "Single reload" for revolver & shotgun. # TODO: Add outline around weapon sprites. class_name Weapon extends Item const NETWORK_EPSILON := 0.05 @export var automatic := false @export var fire_rate := 100 # rounds/minute @export var capacity := 12 @export var reload_time := 1.0 @export var knockback := 0.0 @export var spread := 0.0 @export var spread_increase := 0.0 @export var recoil_min := 0.0 @export var recoil_max := 0.0 @export var effective_range := 320 @export var maximum_range := 640 @export var bullet_velocity := 2000 @export var bullets_per_shot := 1 @export var damage := 0.0 @export var bullet_opacity := 0.2 @onready var rounds := capacity @onready var tip_offset := $Tip.position as Vector2 var is_reloading: bool: get: return _reload_delay > 0.0 var reload_progress: float: get: return clampf(1 - _reload_delay / reload_time, 0.0, 1.0) var lowered := false var aim_direction: float # TODO: Tell the server when we're pressing/releasing the trigger. var _trigger_held := -INF var _fire_delay := 0.0 var _reload_delay := 0.0 var _current_spread_inc := 0.0 var _current_recoil := 0.0 # Needs to be kept alive for `draw_mesh` to work. var _mesh: ArrayMesh func _unhandled_input(event: InputEvent) -> void: if not player.network.is_local: return if not is_equipped: return if event.is_action_pressed("interact_primary"): _trigger_held = 0.0 _on_trigger_pressed() if event.is_action_pressed("interact_reload"): reload() func _on_trigger_pressed() -> void: fire() func _on_trigger_released() -> void: pass func _process(delta: float) -> void: var speed_decrease := maxf(TAU / 200, _current_spread_inc * 1.5) var recoil_decrease := maxf(TAU / 600, _current_recoil * 1.5) _current_spread_inc = maxf(0.0, _current_spread_inc - speed_decrease * delta) _current_recoil = maxf(0.0, _current_recoil - recoil_decrease * delta) if not player.health.is_alive: # TODO: Do this once when player respawns. rounds = capacity _trigger_held = -INF _fire_delay = 0.0 _reload_delay = 0.0 if player.network.is_local: queue_redraw() elif not is_equipped: # When switching away from the weapon while reloading, reset reload time. if is_reloading: _reload_delay = reload_time else: if _trigger_held >= 0.0: _trigger_held += delta if is_reloading: _reload_delay -= delta if _reload_delay <= 0.0: _reload_delay = 0.0 rounds = capacity if _fire_delay > 0.0: _fire_delay -= delta # We allow _fireDelay to go into negatives to allow # for more accurate rate of fire for automatic weapons. # Though, if the trigger isn't held down, reset it to 0. if _fire_delay < 0.0 and (!automatic || _trigger_held < 0.0): _fire_delay = 0.0 if player.network.is_local: # Automatically reload when out of rounds. if rounds <= 0: reload() if _trigger_held >= 0.0: if !Input.is_action_pressed("interact_primary"): _trigger_held = -INF _on_trigger_released() elif automatic: fire() # Gun tip_offset C = Cursor # v v b v # x---###########x------------------------------x # | ##==# _____----- # a | ## _____----- # | _____----- c # x_____----- # ^ # B = Player # The length of `a` and `c` as well as the angle of `c` are known. # `a` is the y component of the weapon's TipOffset. # `c` is the line connecting the player and cursor. # Find out the angle `C` to subtract from the already known angle of `c`. # CREDIT to lizzie for helping me figure out this trigonometry problem. var a := tip_offset.y * (1 if (scale.y > 0) else -1) var c := player.position.distance_to(cursor.position) # If the cursor is too close to the player, put the # weapon in a "lowered" state, where it can't be shot. lowered = c < tip_offset.x if lowered: aim_direction = deg_to_rad(30 if cursor.position.x > player.position.x else 150) else: aim_direction = player.position.angle_to_point(cursor.position) - asin(a / c) # TODO: Send aim angle queue_redraw() else: _reload_delay = 0.0 var angle = absf(rad_to_deg(fposmod(aim_direction + PI, TAU) - PI)) if scale.y > 0 and angle > 100: scale.y = -1 if scale.y < 0 and angle < 80: scale.y = 1 rotation = aim_direction - _current_recoil * (1 if scale.y > 0 else -1) # [RPC(MultiplayerAPI.RPCMode.AnyPeer)] # private void SendAimAngle(float value) # { # if (this.GetGame() is Server) { # if ((Player.NetworkID != GetTree().GetRemoteSenderId()) || !Player.IsAlive) return; # if (float.IsNaN(value = Mathf.PosMod(value, Mathf.Tau))) return; # RPC.Unreliable(SendAimAngle, value); # } else if (!Player.IsLocal) # AimDirection = value; # } func fire() -> void: var _seed := randi() if !_fire_internal(aim_direction, scale.y > 0, _seed): return # RPC.Reliable(1, SendFire, AimDirection, Scale.y > 0, _seed); player.velocity -= knockback * Vector2.from_angle(rotation) func _fire_internal(aim: float, to_right: bool, _seed: int) -> bool: var is_server := false var epsilon := 0.0 if is_server else NETWORK_EPSILON if !player.health.is_alive: return false if !is_equipped or lowered or rounds <= 0: return false if _reload_delay > epsilon or _fire_delay > epsilon: return false if !is_server: var stream: AudioStreamPlayer2D = get_node_or_null("Fire") if stream: stream.play() var random := RandomNumberGenerator.new() random.seed = _seed var angle := aim - _current_recoil * (1 if to_right else -1) var tip := (tip_offset if to_right else tip_offset * Vector2(1, -1)).rotated(angle) for i in bullets_per_shot: var spr := (deg_to_rad(spread) + _current_spread_inc) * clampf(random.randfn(0.4), -1, 1) var dir := Vector2.from_angle(angle + spr) var color := Color(player.appearance.color, bullet_opacity) var bullet := Bullet.new( player.global_position + tip, dir, effective_range, maximum_range, bullet_velocity, damage / bullets_per_shot, color) Game.WORLD.add_child(bullet) _current_spread_inc += deg_to_rad(spread_increase) _current_recoil += deg_to_rad(random.randf_range(recoil_min, recoil_max)) if is_server or player.network.is_local: # Do not keep track of fire rate or ammo for other players. _fire_delay += 60.0 / fire_rate rounds -= 1 return true # [RPC(MultiplayerAPI.RPCMode.AnyPeer)] # private void SendFire(float aimDirection, bool toRight, int seed) # { # if (this.GetGame() is Server) { # if (Player.NetworkID != GetTree().GetRemoteSenderId()) return; # if (float.IsNaN(aimDirection = Mathf.PosMod(aimDirection, Mathf.Tau))) return; # // TODO: Only send to players who can see the full path of the bullet. # if (FireInternal(aimDirection, toRight, seed)) # RPC.Reliable(SendFire, aimDirection, toRight, seed); # } else if (!Player.IsLocal) # FireInternal(aimDirection, toRight, seed); # } func reload() -> void: if _reload_internal(): pass # send_reload func _reload_internal() -> bool: if !is_equipped or !player.health.is_alive or rounds >= capacity or is_reloading: return false # TODO: Play reload sound. _reload_delay += reload_time return true # [RPC(MultiplayerAPI.RPCMode.AnyPeer)] # private void SendReload() # { # if (this.GetGame() is Server) { # if (Player.NetworkID != GetTree().GetRemoteSenderId()) return; # if (ReloadInternal()) RPC.Reliable(SendReload); # } else if (!Player.IsLocal) # ReloadInternal(); # } func _draw() -> void: if !player.network.is_local or !player.health.is_alive or lowered: return # Draws an "aiming cone" to show where bullets might travel. var tip := tip_offset + Vector2(4, 0) var angle := sin((deg_to_rad(spread) + _current_spread_inc) / 2) var color := Color.BLACK var points: Array[Vector2] var colors: Array[Color] var append := func(opacity: float, dist: float) -> void: points.push_front(tip + Vector2(1, angle) * dist) points.push_back (tip + Vector2(1, -angle) * dist) colors.push_front(Color(color, opacity)) colors.push_back (Color(color, opacity)) append.call(0.00, maximum_range) append.call(0.15, effective_range) append.call(0.15, 64) append.call(0.00, 0) var st := SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLE_STRIP) st.set_color(colors[0]) st.add_vertex(Vector3(points[0].x, points[0].y, 0)) st.set_color(colors[1]) st.add_vertex(Vector3(points[1].x, points[1].y, 0)) st.add_vertex(Vector3(points[6].x, points[6].y, 0)) st.set_color(colors[2]) st.add_vertex(Vector3(points[2].x, points[2].y, 0)) st.add_vertex(Vector3(points[5].x, points[5].y, 0)) st.set_color(colors[3]) st.add_vertex(Vector3(points[3].x, points[3].y, 0)) st.add_vertex(Vector3(points[4].x, points[5].y, 0)) _mesh = st.commit() draw_mesh(_mesh, null) draw_polyline_colors(points, colors, 1.0, true)