fuzzyworld

godot-shooter / commit

Merge branch 'main' into 'navigation'

9ee37d3cbc000895a2a2cce43bfffc4f2c6924a5 · Pavel Safronov · 2023-12-01 06:36:24 +0000 · parents 6a08f6b29344 f295bf46cd2d

Merge branch 'main' into 'navigation'

# Conflicts:
#   actors/Enemy.gd
#   actors/Player.gd
#   actors/Player.tscn
#   scenes/World.gd
#   scenes/World.tscn
 README.md          |   7 ++--
 actors/Enemy.gd    |   4 ++-
 actors/Player.gd   | 100 +++++++++++++++++++++++++----------------------------
 actors/Player.tscn |  86 ++++++++++++++++++++-------------------------
 scenes/Scores.gd   |  24 +++++++++++++
 scenes/Scores.tscn |  28 +++++++++++++++
 scenes/World.gd    |  24 +------------
 scenes/World.tscn  |  12 -------
 utils/Utils.gd     |  11 ++++--
 weapons/Weapon.gd  |  28 +++++++++++++++
 10 files changed, 182 insertions(+), 142 deletions(-)

diff --git a/README.md b/README.md
index 401373b..e21818c 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Tap on the screen or move the mouse to set the destination of the ship. The ship
 ## TODOs
 
 1. Add shields
-2. Add powerups
-3. Add more enemies
-4. Add shooting enemies
+2. Add more enemies
+3. Add shooting enemies
+4. Add beam attack, [from here](https://www.youtube.com/watch?v=dg0CQ6NPDn8)
+5. Add navigation logic so actors can move around without colliding with each other
diff --git a/actors/Enemy.gd b/actors/Enemy.gd
index f5c9986..d4aa41a 100644
--- a/actors/Enemy.gd
+++ b/actors/Enemy.gd
@@ -10,11 +10,13 @@ const Damage = preload("res://effects/Damage.tscn")
 const Constants = preload("res://utils/Constants.gd")
 
 export var move_speed = Constants.SPEED * 1.05
-export var turn_rate = 0.01
+export var turn_rate = 0.03
 export var health = -1
 export var max_health = 100
 export var is_targeted = false
 export var is_invincible = false
+export var target: Vector2
+export var follow_target = false
 signal enemy_killed(position)
 
 var velocity = Vector2.ZERO
diff --git a/actors/Player.gd b/actors/Player.gd
index 1f0ffe2..631080d 100644
--- a/actors/Player.gd
+++ b/actors/Player.gd
@@ -32,23 +32,26 @@ onready var pause_always_show_intro_check = $UI/PauseMenu/CenterContainer/PanelC
 onready var pause_show_items_in_radar_check = $UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer/Pref/ShowItemsInRadarCheckBox
 onready var health_label = $Graphics/Alive/StaticControls/HealthLabel
 onready var trails = $Graphics/Alive/Trails
-onready var shot_timer = $ShotTimer
 onready var death_screen = $UI/DeathScreen
 onready var final_score_label = $UI/DeathScreen/CenterContainer/PanelContainer/VBoxContainer/FinalScoreLabel
+onready var player_to_target = $Graphics/Alive/PlayerToTarget
 
 const Utils = preload("res://utils/Utils.gd")
 const PopupControl = preload("res://utils/Popup.tscn")
 
 var weapon: Weapon
 var dead = false
-var velocity: Vector2
-var can_fire = true
+var velocity: Vector2 = Vector2.UP * move_speed
 var settings: Settings
 
 func _ready() -> void:
 	velocity= Vector2.UP * move_speed
 	weapon = Weapons.create_non_gun()
 
+func create_default_weapon() -> Weapon:
+	return Weapons.create_basic_gun()
+
+func _ready() -> void:
 #	Utils.remove_user_settings()
 	settings = Utils.load_user_settings()
 	settings.save_user_settings()
@@ -61,7 +64,10 @@ func _process(_delta: float) -> void:
 	if dead:
 		return
 
-#	health_label.text = "%s / %s" % [health, max_health]
+	# set the rotation for static controls
+	static_controls.global_rotation = 0
+
+	# set progress bar value and color
 	var pb: ProgressBar = $Graphics/Alive/StaticControls/ProgressBar
 	var health_ratio = float(health) / float(max_health)
 	var health_percentage = health_ratio * 100
@@ -72,17 +78,46 @@ func _process(_delta: float) -> void:
 		pb.modulate = Color.yellow
 	else:
 		pb.modulate = Color.blue
-		
-	static_controls.global_rotation = 0
+
+	# add history to the trail
 	trails.add_position(global_position)
+
+	# set HUD info, like score and current weapon
 	hud.set_current_weapon(weapon)
 	hud.score = score
+	
+	# shoot at closest enemy
+	var enemies = get_tree().get_nodes_in_group("enemies")
+	for e in enemies:
+		var enemy: Enemy = e
+		enemy.is_targeted = false
+	
+	var filteredEnemies = Utils.filter(funcref(self,"filter_invalid_enemies"), enemies)
+	if filteredEnemies.size() > 0:
+		filteredEnemies.sort_custom(self, "sort_enemies_ascending_distance")
+		var closest_enemy: Enemy = filteredEnemies[0]
+		var dist = closest_enemy.global_position.distance_to(self.global_position)
+		if dist < self.weapon.shooting_range:
+			closest_enemy.is_targeted = true
+			self.shoot_at(closest_enemy.global_position)
+			player_to_target.points[0] = self.global_position
+			player_to_target.points[1] = closest_enemy.global_position
+
+func sort_enemies_ascending_distance(a: Enemy,b: Enemy):
+	var distA = self.global_position.distance_to(a.global_position)
+	var distB = self.global_position.distance_to(b.global_position)
+	var result = distA < distB
+	return result
 
 func _physics_process(delta: float) -> void:
 	if dead:
 		return
+
+	# turn toward target
 	if is_destination_set:
 		turn_towards(destination, delta)
+
+	# limit movement speed
 	var speed = velocity.length()
 	if speed < move_speed:
 		velocity = velocity * (1 + delta * accel_rate)
@@ -90,6 +125,8 @@ func _physics_process(delta: float) -> void:
 	if speed > move_speed:
 		velocity = velocity.normalized() * move_speed
 	velocity = move_and_slide(velocity)
+
+	# rotate to match velocity
 	global_rotation = velocity.angle() + PI/2.0
 
 func show_intro() -> void:
@@ -107,7 +144,7 @@ func update_radar(items: Array, color: Color):
 		var item: Node = i
 		var diff = item.global_position - global_position
 		var dist = diff.length()
-		var ratio = Utils.dist_to_ratio(dist, radar_min_radius, radar_max_radius)
+		var ratio = Utils.dist_to_ratio(dist, radar_min_radius, radar_max_radius) * .8 + .2
 		if dist < radar_min_radius:
 			ratio = 0
 		var angle_to = Vector2.UP.angle_to(diff)
@@ -166,35 +203,11 @@ func turn_towards(dest: Vector2, _delta: float):
 	var dir_diff = to_dir - curr_dir
 	var change = dir_diff * turn_rate * move_speed
 	velocity = (velocity + change).normalized() * move_speed
-
-func shoot():
-	if !can_fire:
-		return
-	can_fire = false
-	shot_timer.wait_time = weapon.shot_period_seconds
-	shot_timer.start()
-	var particle = weapon.particle.instance()
-	owner.add_child(particle)
-	particle.transform = $Barrel.global_transform
 	
 func shoot_at(pos: Vector2):
-	if !can_fire:
-		return
-	can_fire = false
-	if weapon.ammo != INF:
-		if weapon.ammo <= 0:
-			weapon = Weapons.create_basic_gun()
-		weapon.ammo -= 1
-	shot_timer.wait_time = weapon.cooldown_seconds
-	shot_timer.start()
-	var particle = weapon.particle.instance()
-	particle.damage_amount = weapon.damage_amount
-	owner.add_child(particle)
-	particle.global_position = global_position
-	particle.global_rotation = global_position.angle_to_point(pos) + PI / 2.0
-	
-func allow_fire():
-	can_fire = true
+	if weapon.ammo != INF && weapon.ammo <= 0:
+		weapon = create_default_weapon()
+	weapon.try_shoot(self, pos)
 
 func set_objective(objective: String) -> void:
 	hud.objective = objective
@@ -245,7 +258,6 @@ func show_message(message: String, duration_seconds: int = 2) -> void:
 
 func _on_HUD_paused() -> void:
 	get_tree().paused = true
-	set_pause_menu_high_scores()
 	$UI/PauseMenu.show()
 	clear_destination()
 
@@ -256,24 +268,6 @@ func _on_ResumeButton_pressed() -> void:
 func _on_HUD_debug_signal() -> void:
 	emit_signal("debug")
 
-func sort_high_scores(a, b):
-	return (a as Settings.HighScore).score > (b as Settings.HighScore).score
-
-func set_pause_menu_high_scores() -> void:
-	if settings.high_scores.size() > 0:
-		Utils.remove_all_children(scores_list)
-		settings.high_scores.sort_custom(self, "sort_high_scores")
-		var index = 0
-		var now = Time.get_datetime_dict_from_system()
-		for hs in settings.high_scores:
-			index += 1
-			var label = Label.new()
-			var date_dict = hs.date
-			var date_diff_secs = Utils.time_diff_secs(date_dict, now)
-			var date_diff = Utils.timespan_to_english(date_diff_secs)
-			label.text = "%s - %s points, %s" % [index, hs.score, date_diff]
-			scores_list.add_child(label)
-
 func set_destination(pos: Vector2) -> void:
 	destination = pos
 	is_destination_set = true
diff --git a/actors/Player.tscn b/actors/Player.tscn
index 73d41f9..e84a2e9 100644
--- a/actors/Player.tscn
+++ b/actors/Player.tscn
@@ -1,4 +1,4 @@
-[gd_scene load_steps=18 format=2]
+[gd_scene load_steps=19 format=2]
 
 [ext_resource path="res://assets/kenney_simple-space/PNG/Default/ship_sidesA.png" type="Texture" id=1]
 [ext_resource path="res://effects/Explosion.tscn" type="PackedScene" id=2]
@@ -6,6 +6,7 @@
 [ext_resource path="res://assets/kenney_simple-space/PNG/Retina/ship_B.png" type="Texture" id=4]
 [ext_resource path="res://themes/CharacterTheme.tres" type="Theme" id=5]
 [ext_resource path="res://effects/Trails.tscn" type="PackedScene" id=6]
+[ext_resource path="res://scenes/Scores.tscn" type="PackedScene" id=7]
 [ext_resource path="res://assets/neuropolitical rg.otf" type="DynamicFontData" id=8]
 [ext_resource path="res://themes/Theme.tres" type="Theme" id=9]
 [ext_resource path="res://scenes/HUD.tscn" type="PackedScene" id=10]
@@ -100,6 +101,18 @@ percent_visible = false
 modulate = Color( 1, 0, 0, 1 )
 gradient = SubResource( 1 )
 
+[node name="PlayerToTarget" type="Line2D" parent="Graphics/Alive"]
+visible = false
+modulate = Color( 1, 0.984314, 0, 1 )
+points = PoolVector2Array( 2085.69, 283.505, 2067.82, 274.471 )
+width = 3.0
+default_color = Color( 1, 0.976471, 0.4, 1 )
+joint_mode = 1
+begin_cap_mode = 2
+end_cap_mode = 2
+round_precision = 13
+antialiased = true
+
 [node name="Dead" type="Node2D" parent="Graphics"]
 visible = false
 
@@ -148,8 +161,9 @@ align = 1
 valign = 1
 
 [node name="FinalScoreLabel" type="Label" parent="UI/DeathScreen/CenterContainer/PanelContainer/VBoxContainer"]
-margin_right = 40.0
-margin_bottom = 25.0
+margin_top = 33.0
+margin_right = 272.0
+margin_bottom = 58.0
 
 [node name="StartNewGameButton" type="Button" parent="UI/DeathScreen/CenterContainer/PanelContainer/VBoxContainer"]
 margin_top = 135.0
@@ -242,7 +256,7 @@ rect_min_size = Vector2( 450, 235 )
 margin_left = 14.0
 margin_top = 14.0
 margin_right = 436.0
-margin_bottom = 277.0
+margin_bottom = 231.0
 
 [node name="Label" type="Label" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer"]
 margin_right = 422.0
@@ -254,30 +268,9 @@ align = 1
 [node name="TabContainer" type="TabContainer" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer"]
 margin_top = 45.0
 margin_right = 422.0
-margin_bottom = 218.0
-
-[node name="Scores" type="VBoxContainer" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer"]
-anchor_right = 1.0
-anchor_bottom = 1.0
-margin_left = 8.0
-margin_top = 61.0
-margin_right = -8.0
-margin_bottom = -8.0
-
-[node name="Label" type="Label" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer/Scores"]
-margin_right = 406.0
-margin_bottom = 25.0
-text = "High scores:"
-
-[node name="ScoresList" type="VBoxContainer" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer/Scores"]
-margin_top = 33.0
-margin_right = 406.0
-margin_bottom = 58.0
+margin_bottom = 172.0
 
-[node name="Label" type="Label" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer/Scores/ScoresList"]
-margin_right = 406.0
-margin_bottom = 25.0
-text = "No high scores yet!"
+[node name="Scores" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer" instance=ExtResource( 7 )]
 
 [node name="Pref" type="VBoxContainer" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer"]
 visible = false
@@ -323,9 +316,9 @@ focus_mode = 2
 text = "Source code"
 
 [node name="ResumeButton" type="Button" parent="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer"]
-margin_top = 226.0
+margin_top = 180.0
 margin_right = 422.0
-margin_bottom = 263.0
+margin_bottom = 217.0
 theme = ExtResource( 5 )
 text = "Resume"
 
@@ -335,7 +328,6 @@ visible = false
 anchor_right = 1.0
 anchor_bottom = 1.0
 mouse_filter = 2
-theme = ExtResource( 9 )
 
 [node name="CenterContainer" type="CenterContainer" parent="UI/GameFinishedMenu"]
 anchor_right = 1.0
@@ -350,37 +342,36 @@ margin_bottom = 414.0
 [node name="VBoxContainer" type="VBoxContainer" parent="UI/GameFinishedMenu/CenterContainer/PanelContainer"]
 margin_left = 14.0
 margin_top = 14.0
-margin_right = 182.0
-margin_bottom = 208.0
+margin_right = 220.0
+margin_bottom = 217.0
 
 [node name="Label" type="Label" parent="UI/GameFinishedMenu/CenterContainer/PanelContainer/VBoxContainer"]
-margin_right = 168.0
-margin_bottom = 118.0
+margin_right = 206.0
+margin_bottom = 80.0
 theme = ExtResource( 9 )
 text = "You've won!"
 align = 1
 valign = 1
 autowrap = true
 
+[node name="Scores" parent="UI/GameFinishedMenu/CenterContainer/PanelContainer/VBoxContainer" instance=ExtResource( 7 )]
+anchor_right = 0.0
+anchor_bottom = 0.0
+margin_left = 0.0
+margin_top = 88.0
+margin_right = 206.0
+margin_bottom = 146.0
+
 [node name="Button" type="Button" parent="UI/GameFinishedMenu/CenterContainer/PanelContainer/VBoxContainer"]
-margin_top = 126.0
-margin_right = 168.0
-margin_bottom = 194.0
+margin_top = 154.0
+margin_right = 206.0
+margin_bottom = 203.0
+theme = ExtResource( 9 )
 text = "Replay!"
 
 [node name="Camera2D" type="Camera2D" parent="."]
 current = true
 
-[node name="ShotTimer" type="Timer" parent="."]
-wait_time = 0.5
-
-[node name="Barrel" type="Position2D" parent="."]
-position = Vector2( 0, -20 )
-rotation = -3.14044
-__meta__ = {
-"_gizmo_extents_": 14.2
-}
-
 [node name="NavigationObstacle2D" type="NavigationObstacle2D" parent="."]
 estimate_radius = false
 radius = 35.0
@@ -394,4 +385,3 @@ radius = 35.0
 [connection signal="pressed" from="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer/Pref/ShowItemsInRadarCheckBox" to="." method="_on_ShowItemsInRadarCheckBox_pressed"]
 [connection signal="pressed" from="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/TabContainer/About/LinkButton" to="." method="_on_LinkButton_pressed"]
 [connection signal="pressed" from="UI/PauseMenu/CenterContainer/PanelContainer/VBoxContainer/ResumeButton" to="." method="_on_ResumeButton_pressed"]
-[connection signal="timeout" from="ShotTimer" to="." method="allow_fire"]
diff --git a/scenes/Scores.gd b/scenes/Scores.gd
new file mode 100644
index 0000000..2173b2f
--- /dev/null
+++ b/scenes/Scores.gd
@@ -0,0 +1,24 @@
+extends VBoxContainer
+
+const Utils = preload("res://utils/Utils.gd")
+const Weapons = preload("res://weapons/Weapons.gd")
+const PowerUp = preload("res://items/PowerUp.tscn")
+const Settings = preload("res://utils/Settings.gd")
+onready var scores_list = $ScoresList
+
+func _ready() -> void:
+	var settings = Utils.load_user_settings()
+	if settings.high_scores.size() > 0:
+		Utils.remove_all_children(scores_list)
+		settings.high_scores.sort_custom(self, "sort_high_scores")
+		var now = Time.get_datetime_dict_from_system()
+		for hs in settings.high_scores:
+			var label = Label.new()
+			var date_dict = hs.date
+			var date_diff_secs = Utils.time_diff_secs(date_dict, now)
+			var date_diff = Utils.timespan_to_english(date_diff_secs)
+			label.text = " %s points, %s" % [hs.score, date_diff]
+			scores_list.add_child(label)
+
+func sort_high_scores(a, b):
+	return (a as Settings.HighScore).score > (b as Settings.HighScore).score
diff --git a/scenes/Scores.tscn b/scenes/Scores.tscn
new file mode 100644
index 0000000..9df778f
--- /dev/null
+++ b/scenes/Scores.tscn
@@ -0,0 +1,28 @@
+[gd_scene load_steps=2 format=2]
+
+[ext_resource path="res://scenes/Scores.gd" type="Script" id=1]
+
+[node name="Scores" type="VBoxContainer"]
+anchor_right = 1.0
+anchor_bottom = 1.0
+margin_left = 8.0
+margin_top = 61.0
+margin_right = -8.0
+margin_bottom = -8.0
+script = ExtResource( 1 )
+
+[node name="Label" type="Label" parent="."]
+margin_right = 484.0
+margin_bottom = 25.0
+text = "High scores:"
+
+[node name="ScoresList" type="VBoxContainer" parent="."]
+margin_top = 33.0
+margin_right = 484.0
+margin_bottom = 58.0
+
+[node name="Label" type="Label" parent="ScoresList"]
+margin_top = 10.0
+margin_right = 484.0
+margin_bottom = 25.0
+text = "No high scores yet!"
diff --git a/scenes/World.gd b/scenes/World.gd
index f8388f3..57b7734 100644
--- a/scenes/World.gd
+++ b/scenes/World.gd
@@ -25,7 +25,6 @@ var is_dragging = false
 
 func enemy_killed(_position: Vector2):
 	player.score += 5
-	player.show_message("Enemy killed!", 1)
 func health_consumed(_data):
 	player.heal(10)
 	player.score += 1
@@ -197,12 +196,6 @@ func _on_Destination_body_entered(body: Node) -> void:
 	if body == player:
 		player.clear_destination()
 
-func sort_enemies_ascending_distance(a: Enemy,b: Enemy):
-	var distA = player.global_position.distance_to(a.global_position)
-	var distB = player.global_position.distance_to(b.global_position)
-	var result = distA < distB
-	return result
-	
 func filter_invalid_enemies(enemy: Enemy) -> bool:
 	if enemy.dead:
 		return false
@@ -211,21 +204,6 @@ func filter_invalid_enemies(enemy: Enemy) -> bool:
 func _on_ShootTimer_timeout() -> void:
 	if player.dead:
 		return
-	var enemies = get_tree().get_nodes_in_group("enemies")
-	for e in enemies:
-		var enemy: Enemy = e
-		enemy.is_targeted = false
-	
-	var filteredEnemies = Utils.filter(funcref(self,"filter_invalid_enemies"), enemies)
-	if filteredEnemies.size() > 0:
-		filteredEnemies.sort_custom(self, "sort_enemies_ascending_distance")
-		var closest_enemy: Enemy = filteredEnemies[0]
-		var dist = closest_enemy.global_position.distance_to($Player.global_position)
-		if dist < player.weapon.shooting_range:
-			closest_enemy.is_targeted = true
-			player.shoot_at(closest_enemy.global_position)
-			$PlayerToTarget.points[0] = player.global_position
-			$PlayerToTarget.points[1] = closest_enemy.global_position
 
 func _on_Button_pressed() -> void:
 	get_tree().paused = false
@@ -257,7 +235,7 @@ func _on_UpdateTimer_timeout() -> void:
 	var filteredEnemies = Utils.filter(funcref(self,"filter_invalid_enemies"), enemies)
 	for e in filteredEnemies:
 		var enemy: Enemy = e
-		enemy.set_target($Player.position)
+		enemy.turn_towards($Player.position)
 		var diff = enemy.global_position - player.global_position
 		var dist = diff.length()
 		var is_too_far = dist > item_replacement_distance
diff --git a/scenes/World.tscn b/scenes/World.tscn
index aed6610..d306dd5 100644
--- a/scenes/World.tscn
+++ b/scenes/World.tscn
@@ -65,18 +65,6 @@ process_mode = 0
 wait_time = 0.05
 autostart = true
 
-[node name="PlayerToTarget" type="Line2D" parent="."]
-visible = false
-modulate = Color( 1, 0.984314, 0, 1 )
-points = PoolVector2Array( 2085.69, 283.505, 2067.82, 274.471 )
-width = 3.0
-default_color = Color( 1, 0.976471, 0.4, 1 )
-joint_mode = 1
-begin_cap_mode = 2
-end_cap_mode = 2
-round_precision = 13
-antialiased = true
-
 [node name="UpdateTimer" type="Timer" parent="."]
 wait_time = 0.1
 autostart = true
diff --git a/utils/Utils.gd b/utils/Utils.gd
index 9623c56..a8bb2e3 100644
--- a/utils/Utils.gd
+++ b/utils/Utils.gd
@@ -34,8 +34,15 @@ static func load_user_settings(path = SettingsClass.settings_path) -> Settings:
 
 static func log(arg1 = null, arg2 = null, arg3 = null, arg4 = null, arg5 = null, arg6 = null, arg7 = null, arg8 = null, arg9 = null):
 	var text = ""
-	var now = Time.get_datetime_string_from_system(false, true)
-	text += now
+	var now_unix_time = Time.get_unix_time_from_system()
+	var now_dict = Time.get_datetime_dict_from_unix_time(now_unix_time)
+	var seconds_decimal = now_unix_time - floor(now_unix_time)
+	if seconds_decimal >= 1:
+		seconds_decimal = 0
+	var seconds_decimal_text = "%.6f" % seconds_decimal
+	seconds_decimal_text = seconds_decimal_text.replace("0.", "")
+	var now_text = "%s:%s:%s.%s" % [now_dict["hour"], now_dict["minute"], now_dict["second"], seconds_decimal_text]
+	text += now_text
 	text += "> "
 	for argument in [arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9]:
 		if argument != null:
diff --git a/weapons/Weapon.gd b/weapons/Weapon.gd
index 359b386..c7aa55a 100644
--- a/weapons/Weapon.gd
+++ b/weapons/Weapon.gd
@@ -9,3 +9,31 @@ var damage_amount = 1
 var ammo = INF
 var name = "Laser"
 var reload_size = 1
+var last_shoot_time: float
+
+const Utils = preload("res://utils/Utils.gd")
+
+func _init() -> void:
+	var now = Time.get_unix_time_from_system()
+	last_shoot_time = now
+
+func try_shoot(shooter: Node2D, target: Vector2) -> void:
+	var has_ammo = ammo == INF or ammo > 0
+	if not has_ammo:
+		return
+
+	var now = Time.get_unix_time_from_system()
+	var sec_since_shot = now - last_shoot_time
+	var can_shoot = sec_since_shot > cooldown_seconds
+	if not can_shoot:
+		return
+
+	if ammo != INF:
+		ammo = ammo - 1
+
+	var particle_instance = particle.instance()
+	shooter.owner.add_child(particle_instance)
+	particle_instance.transform = shooter.global_transform
+	particle_instance.damage_amount = self.damage_amount
+	particle_instance.global_rotation = shooter.global_position.angle_to_point(target) + PI / 2.0
+	last_shoot_time = now