You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
272 lines
8.9 KiB
272 lines
8.9 KiB
# 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)
|
|
|