#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
baac11ae8b
commit
2f161621c3
495 changed files with 571 additions and 567 deletions
18
enemies/ArtilleryEnemyDefinition.tres
Normal file
18
enemies/ArtilleryEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyArtilleryComponent.tscn" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "artillery"
|
||||
display_name = "Artillery"
|
||||
max_hp = 8
|
||||
speed = 60.0
|
||||
acceleration = 120.0
|
||||
rotation_speed = 3.0
|
||||
hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22)
|
||||
glow_color = Color(1.0, 0.5, 0.2, 1)
|
||||
detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6)
|
||||
detail_rings = PackedVector2Array(0, -20, 8, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
119
enemies/ArtilleryShell.gd
Normal file
119
enemies/ArtilleryShell.gd
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
class_name ArtilleryShell
|
||||
extends Node2D
|
||||
|
||||
# Self-contained travel -> explode sequence for the artillery enemy's attack
|
||||
# (see EnemyArtilleryComponent). Lives independently of the
|
||||
# enemy that fired it once launched - added to the tree root, exactly like
|
||||
# EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't
|
||||
# cancel a shell already in the air.
|
||||
#
|
||||
# Shield interaction: a shield charge absorbs this hit entirely, same
|
||||
# all-or-nothing semantics as every other damage source in this game
|
||||
# (VesselShieldComponent.absorb_hit) - _explode() just calls
|
||||
# Ship.take_damage() like everything else and lets that logic decide.
|
||||
|
||||
signal finished
|
||||
|
||||
# The tower fires as soon as it decides to; the warning is the shell you can
|
||||
# SEE coming and the cross marking where it will land. There used to be a
|
||||
# 1.8s charge-up before anything left the barrel, which arrived long before
|
||||
# the threat was legible and mostly read as the tower being stuck.
|
||||
@export var shell_speed: float = 180.0
|
||||
@export var aoe_radius: float = 220.0
|
||||
@export var damage: int = 3
|
||||
@export var color: Color = Color(1.0, 0.5, 0.2)
|
||||
|
||||
enum Phase { TRAVELING, EXPLODING }
|
||||
|
||||
const EXPLOSION_DURATION: float = 0.8
|
||||
# The impact cross. Small on purpose: it says WHERE, and the shell in flight
|
||||
# already says that something is coming. A full blast-radius ring here read
|
||||
# as an attack that had already happened.
|
||||
const IMPACT_CROSS_RADIUS: float = 20.0
|
||||
|
||||
var phase: int = Phase.TRAVELING
|
||||
var target: Vector2 = Vector2.ZERO
|
||||
var shell_pos: Vector2 = Vector2.ZERO
|
||||
var explosion_time: float = 0.0
|
||||
var trail: PackedVector2Array = PackedVector2Array()
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("artillery_shells")
|
||||
|
||||
|
||||
func launch(from: Vector2, at: Vector2) -> void:
|
||||
target = at
|
||||
shell_pos = from
|
||||
phase = Phase.TRAVELING
|
||||
trail = PackedVector2Array([shell_pos])
|
||||
position = shell_pos
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
match phase:
|
||||
Phase.TRAVELING:
|
||||
var to_target: Vector2 = target - shell_pos
|
||||
var step: float = shell_speed * delta
|
||||
if to_target.length() <= step:
|
||||
shell_pos = target
|
||||
_explode()
|
||||
else:
|
||||
shell_pos += to_target.normalized() * step
|
||||
trail.append(shell_pos)
|
||||
while trail.size() > 14:
|
||||
trail.remove_at(0)
|
||||
position = shell_pos
|
||||
queue_redraw()
|
||||
Phase.EXPLODING:
|
||||
explosion_time -= delta
|
||||
queue_redraw()
|
||||
if explosion_time <= 0.0:
|
||||
finished.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _explode() -> void:
|
||||
phase = Phase.EXPLODING
|
||||
explosion_time = EXPLOSION_DURATION
|
||||
position = target
|
||||
for ship in get_tree().get_nodes_in_group("player_ships"):
|
||||
if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius:
|
||||
ship.take_damage(damage)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
match phase:
|
||||
Phase.TRAVELING:
|
||||
# Where it will land, drawn on the ground. Local coordinates,
|
||||
# because this node rides along with the shell while it flies.
|
||||
_draw_impact_cross(target - shell_pos)
|
||||
for i in range(1, trail.size()):
|
||||
var alpha: float = float(i) / trail.size()
|
||||
draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true)
|
||||
draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35))
|
||||
NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0)
|
||||
Phase.EXPLODING:
|
||||
var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION
|
||||
var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0))
|
||||
draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18))
|
||||
_draw_ring(eradius, 1.0 - eprogress)
|
||||
|
||||
|
||||
# A small cross where the shell will land, plus a dot at the exact point.
|
||||
# Deliberately not a filled circle the size of the blast: the player needs to
|
||||
# read "move off this spot" at a glance, and a large translucent area was
|
||||
# easy to mistake for the explosion itself.
|
||||
func _draw_impact_cross(at: Vector2) -> void:
|
||||
var r: float = IMPACT_CROSS_RADIUS
|
||||
for offset in [Vector2(r, r), Vector2(r, -r)]:
|
||||
draw_line(at - offset, at + offset, Color(color, 0.35), 7.0, true)
|
||||
draw_line(at - offset, at + offset, Color(1.0, 0.92, 0.86, 0.85), 2.5, true)
|
||||
draw_arc(at, r * 0.32, 0.0, TAU, 20, Color(color, 0.9), 2.5, true)
|
||||
|
||||
|
||||
func _draw_ring(radius: float, alpha: float) -> void:
|
||||
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true)
|
||||
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true)
|
||||
draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(1.0, 0.92, 0.86, 0.9 * alpha), 2.0, true)
|
||||
1
enemies/ArtilleryShell.gd.uid
Normal file
1
enemies/ArtilleryShell.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cvxao0mjslo5x
|
||||
6
enemies/ArtilleryShell.tscn
Normal file
6
enemies/ArtilleryShell.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/ArtilleryShell.gd" id="1"]
|
||||
|
||||
[node name="ArtilleryShell" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
17
enemies/ChaserEnemyDefinition.tres
Normal file
17
enemies/ChaserEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyPursuitComponent.tscn" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "chaser"
|
||||
display_name = "Chaser"
|
||||
max_hp = 3
|
||||
speed = 140.0
|
||||
acceleration = 260.0
|
||||
rotation_speed = 6.0
|
||||
hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0)
|
||||
glow_color = Color(1, 0.15, 0.2, 1)
|
||||
dies_on_contact = true
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
82
enemies/Enemy.gd
Normal file
82
enemies/Enemy.gd
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
extends Area2D
|
||||
|
||||
signal destroyed
|
||||
|
||||
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
|
||||
|
||||
@export var enemy_id: String = "chaser"
|
||||
|
||||
var definition: Resource
|
||||
var hp: int
|
||||
|
||||
# Mirrors Ship.gd's own max_speed/acceleration: copied from the definition
|
||||
# once, then mutable, so a test/preview can pin an enemy in place (e.g.
|
||||
# `enemy.speed = 0.0`) without needing to know about definitions at all.
|
||||
var speed: float
|
||||
var acceleration: float
|
||||
var rotation_speed: float
|
||||
var max_hp: int
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemies")
|
||||
definition = ENEMY_DEFINITIONS.get_definition(enemy_id)
|
||||
speed = definition.speed
|
||||
acceleration = definition.acceleration
|
||||
rotation_speed = definition.rotation_speed
|
||||
max_hp = definition.max_hp
|
||||
hp = max_hp
|
||||
for component_scene in definition.component_scenes:
|
||||
var component: Node = component_scene.instantiate()
|
||||
add_child(component)
|
||||
if component.has_method("setup"):
|
||||
component.setup()
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func get_component(capability: String) -> Node:
|
||||
for child in get_children():
|
||||
if child.get("capability_id") == capability:
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
func has_capability(capability: String) -> bool:
|
||||
return get_component(capability) != null
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
for child in get_children():
|
||||
if child.has_method("tick"):
|
||||
child.tick(delta)
|
||||
|
||||
|
||||
# Called by a player vessel this has just collided with. Whether the impact
|
||||
# destroys it is the definition's call - see EnemyDefinition.dies_on_contact.
|
||||
# Routed through take_damage so it dies exactly as it dies to gunfire: same
|
||||
# explosion, same `destroyed` signal, same bookkeeping for whatever is
|
||||
# listening.
|
||||
func collide_with_player_vessel() -> void:
|
||||
if definition != null and bool(definition.dies_on_contact):
|
||||
take_damage(hp)
|
||||
|
||||
|
||||
func take_damage(amount: int) -> void:
|
||||
hp -= amount
|
||||
if hp <= 0:
|
||||
EnemyExplosionVFX.spawn(get_tree().root, position)
|
||||
destroyed.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Drawn through EnemyDefinitions.draw_definition, the same routine the
|
||||
# map editor's markers and palette use, so a definition cannot end up
|
||||
# looking one way where it is placed and another where it is fought.
|
||||
if definition == null:
|
||||
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
|
||||
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
|
||||
]), Color(1.0, 0.15, 0.2), 1.0)
|
||||
return
|
||||
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
|
||||
ENEMY_DEFINITIONS.draw_definition(self, definition, definition.glow_color)
|
||||
1
enemies/Enemy.gd.uid
Normal file
1
enemies/Enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b7b4c2nvmevyl
|
||||
12
enemies/Enemy.tscn
Normal file
12
enemies/Enemy.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/Enemy.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 14.0
|
||||
|
||||
[node name="Enemy" type="Area2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
64
enemies/EnemyArtilleryComponent.gd
Normal file
64
enemies/EnemyArtilleryComponent.gd
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
class_name EnemyArtilleryComponent
|
||||
extends Node
|
||||
|
||||
# Stationary siege piece: drifts slowly toward the fleet (deliberately no
|
||||
# pursuit component - closing distance is incidental, not its purpose) and
|
||||
# fires a slow, clearly visible AOE shell on a long cooldown via a
|
||||
# standalone ArtilleryShell, so an already-launched shot survives this
|
||||
# enemy dying mid-flight, same as EnemyProjectile/Projectile already do.
|
||||
|
||||
var capability_id: String = "artillery"
|
||||
|
||||
@export var fire_cooldown: float = 6.0
|
||||
@export var fire_range: float = 900.0
|
||||
@export var shell_speed: float = 180.0
|
||||
@export var aoe_radius: float = 220.0
|
||||
@export var damage: int = 3
|
||||
@export var drift_speed: float = 20.0
|
||||
@export var color: Color = Color(1.0, 0.5, 0.2)
|
||||
|
||||
var cooldown_left: float = 0.0
|
||||
var shell_scene: PackedScene = preload("res://enemies/ArtilleryShell.tscn")
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
var enemy: Node2D = get_parent()
|
||||
_drift(enemy, delta)
|
||||
|
||||
cooldown_left -= delta
|
||||
if cooldown_left > 0.0:
|
||||
return
|
||||
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return
|
||||
var centroid: Vector2 = fleet.get_centroid()
|
||||
if enemy.global_position.distance_to(centroid) > fire_range:
|
||||
return
|
||||
|
||||
var shell: Node2D = shell_scene.instantiate()
|
||||
shell.shell_speed = shell_speed
|
||||
shell.aoe_radius = aoe_radius
|
||||
shell.damage = damage
|
||||
shell.color = color
|
||||
enemy.get_tree().root.add_child(shell)
|
||||
shell.launch(enemy.global_position, centroid)
|
||||
cooldown_left = fire_cooldown
|
||||
|
||||
|
||||
func _drift(enemy: Node2D, delta: float) -> void:
|
||||
# Local position throughout, same convention as EnemyPursuitComponent/
|
||||
# EnemyStandoffComponent: valid as long as the enemy's container and
|
||||
# Fleet share a common origin, which every scene in this project does.
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return
|
||||
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
|
||||
# #69: gated by the same fire_range it already shells within, rather
|
||||
# than a second tunable that could drift out of sync - outside its own
|
||||
# firing envelope it has nothing to be drifting toward yet.
|
||||
if to_fleet.length() > fire_range:
|
||||
return
|
||||
if to_fleet.length() < 1.0:
|
||||
return
|
||||
enemy.position += to_fleet.normalized() * drift_speed * delta
|
||||
1
enemies/EnemyArtilleryComponent.gd.uid
Normal file
1
enemies/EnemyArtilleryComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b0woyjce8kl6x
|
||||
6
enemies/EnemyArtilleryComponent.tscn
Normal file
6
enemies/EnemyArtilleryComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyArtilleryComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyArtilleryComponent" type="Node"]
|
||||
script = ExtResource("1")
|
||||
106
enemies/EnemyBigLaserBeam.gd
Normal file
106
enemies/EnemyBigLaserBeam.gd
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
class_name EnemyBigLaserBeam
|
||||
extends Node2D
|
||||
|
||||
# Self-contained charge -> fire -> flash sequence for the mothership's big
|
||||
# laser (see EnemyBigLaserComponent). Lives independently of the enemy that
|
||||
# fired it once launched - added to the tree root, exactly like
|
||||
# ArtilleryShell/EnemyProjectile - so the mothership dying mid-charge
|
||||
# doesn't cancel a shot already telegraphed to the player.
|
||||
#
|
||||
# The aim direction is locked once, in launch(), and never touched again:
|
||||
# that is what makes the telegraph dodgeable. Nothing in _process() re-reads
|
||||
# the fleet's position while charging.
|
||||
#
|
||||
# Damage: every ship in group "player_ships" whose perpendicular distance
|
||||
# to the beam segment is within beam_half_width, AND whose projection along
|
||||
# the beam falls inside [0, beam_range], takes Ship.take_damage(damage) -
|
||||
# same all-or-nothing shield semantics as every other damage source in this
|
||||
# game (Ship.take_damage already handles the absorb_hit check itself).
|
||||
|
||||
signal finished
|
||||
|
||||
@export var charge_time: float = 2.5
|
||||
@export var beam_range: float = 1200.0
|
||||
@export var beam_half_width: float = 35.0
|
||||
@export var damage: int = 3
|
||||
@export var color: Color = Color(0.55, 0.05, 0.85)
|
||||
|
||||
enum Phase { CHARGING, FLASH }
|
||||
const FLASH_DURATION: float = 0.25
|
||||
|
||||
var phase: int = Phase.CHARGING
|
||||
var origin: Vector2 = Vector2.ZERO
|
||||
var aim_direction: Vector2 = Vector2.RIGHT
|
||||
var charge_left: float = 0.0
|
||||
var flash_time: float = 0.0
|
||||
|
||||
|
||||
func launch(from: Vector2, toward: Vector2) -> void:
|
||||
origin = from
|
||||
aim_direction = toward.normalized() if toward.length() > 0.001 else Vector2.RIGHT
|
||||
position = origin
|
||||
rotation = aim_direction.angle()
|
||||
charge_left = charge_time
|
||||
phase = Phase.CHARGING
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
match phase:
|
||||
Phase.CHARGING:
|
||||
charge_left -= delta
|
||||
if charge_left <= 0.0:
|
||||
_fire()
|
||||
queue_redraw()
|
||||
Phase.FLASH:
|
||||
flash_time -= delta
|
||||
queue_redraw()
|
||||
if flash_time <= 0.0:
|
||||
finished.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _fire() -> void:
|
||||
phase = Phase.FLASH
|
||||
flash_time = FLASH_DURATION
|
||||
for ship in get_tree().get_nodes_in_group("player_ships"):
|
||||
if not is_instance_valid(ship):
|
||||
continue
|
||||
var to_ship: Vector2 = ship.global_position - origin
|
||||
var along: float = to_ship.dot(aim_direction)
|
||||
if along < 0.0 or along > beam_range:
|
||||
continue
|
||||
var perpendicular: float = (to_ship - aim_direction * along).length()
|
||||
if perpendicular <= beam_half_width:
|
||||
ship.take_damage(damage)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
# Drawn in local space, rotated in launch() so local +X already points
|
||||
# along the locked aim_direction - the same reason the beam segment's
|
||||
# own math is done in world space up in _fire() (global_position/origin)
|
||||
# while _draw() only ever needs the length along that local +X axis.
|
||||
match phase:
|
||||
Phase.CHARGING:
|
||||
var progress: float = clampf(1.0 - charge_left / maxf(charge_time, 0.001), 0.0, 1.0)
|
||||
var width: float = lerpf(4.0, beam_half_width * 2.0, progress)
|
||||
_draw_beam_line(width, 0.65)
|
||||
# Countdown arc at the origin, the same idiom as
|
||||
# ArtilleryShell's growing-ring-plus-arc telegraph.
|
||||
draw_arc(Vector2.ZERO, 40.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 32,
|
||||
Color(color, 0.85), 8.0, true)
|
||||
Phase.FLASH:
|
||||
var fprogress: float = 1.0 - flash_time / FLASH_DURATION
|
||||
var width: float = lerpf(beam_half_width * 2.2, 6.0, fprogress)
|
||||
_draw_beam_line(width, 1.0 - fprogress * 0.3)
|
||||
|
||||
|
||||
func _draw_beam_line(width: float, alpha: float) -> void:
|
||||
# Three-layer glow along local +X - the same soft-halo / core / bright-
|
||||
# hairline structure as ArtilleryShell._draw_ring, adapted from a ring
|
||||
# to a straight line.
|
||||
var to: Vector2 = Vector2(beam_range, 0.0)
|
||||
draw_line(Vector2.ZERO, to, Color(color, 0.16 * alpha), width * 2.6, true)
|
||||
draw_line(Vector2.ZERO, to, Color(color, 0.5 * alpha), width, true)
|
||||
draw_line(Vector2.ZERO, to, Color(1.0, 0.92, 0.98, 0.9 * alpha), width * 0.35, true)
|
||||
1
enemies/EnemyBigLaserBeam.gd.uid
Normal file
1
enemies/EnemyBigLaserBeam.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://coopf1u1lnb8b
|
||||
6
enemies/EnemyBigLaserBeam.tscn
Normal file
6
enemies/EnemyBigLaserBeam.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyBigLaserBeam.gd" id="1"]
|
||||
|
||||
[node name="EnemyBigLaserBeam" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
43
enemies/EnemyBigLaserComponent.gd
Normal file
43
enemies/EnemyBigLaserComponent.gd
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
class_name EnemyBigLaserComponent
|
||||
extends Node
|
||||
|
||||
# Fires a heavily telegraphed EnemyBigLaserBeam at the fleet's current
|
||||
# centroid on a long cooldown - the beam itself owns the whole charge/fire/
|
||||
# flash sequence once launched, same "survives the shooter dying" design as
|
||||
# EnemyArtilleryComponent's ArtilleryShell.
|
||||
|
||||
var capability_id: String = "big_laser"
|
||||
|
||||
@export var charge_time: float = 2.5
|
||||
@export var fire_cooldown: float = 9.0
|
||||
@export var beam_range: float = 1200.0
|
||||
@export var beam_half_width: float = 35.0
|
||||
@export var damage: int = 3
|
||||
@export var color: Color = Color(0.55, 0.05, 0.85)
|
||||
|
||||
var cooldown_left: float = 0.0
|
||||
var beam_scene: PackedScene = preload("res://enemies/EnemyBigLaserBeam.tscn")
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
cooldown_left -= delta
|
||||
if cooldown_left > 0.0:
|
||||
return
|
||||
|
||||
var enemy: Node2D = get_parent()
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return
|
||||
var direction: Vector2 = fleet.get_centroid() - enemy.global_position
|
||||
if direction.length() < 1.0:
|
||||
return
|
||||
|
||||
var beam: Node2D = beam_scene.instantiate()
|
||||
beam.charge_time = charge_time
|
||||
beam.beam_range = beam_range
|
||||
beam.beam_half_width = beam_half_width
|
||||
beam.damage = damage
|
||||
beam.color = color
|
||||
enemy.get_tree().root.add_child(beam)
|
||||
beam.launch(enemy.global_position, direction)
|
||||
cooldown_left = fire_cooldown
|
||||
1
enemies/EnemyBigLaserComponent.gd.uid
Normal file
1
enemies/EnemyBigLaserComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://6oj7ia3em4x0
|
||||
6
enemies/EnemyBigLaserComponent.tscn
Normal file
6
enemies/EnemyBigLaserComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyBigLaserComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyBigLaserComponent" type="Node"]
|
||||
script = ExtResource("1")
|
||||
35
enemies/EnemyDefinition.gd
Normal file
35
enemies/EnemyDefinition.gd
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
class_name EnemyDefinition
|
||||
extends Resource
|
||||
|
||||
# Data-driven enemy stats and appearance, mirroring VesselDefinition's role
|
||||
# for player ships. Enemy.gd instantiates component_scenes exactly like
|
||||
# Ship.gd does, so behaviour (pursuit, ranged fire, ...) lives in capability
|
||||
# components rather than in a type switch here.
|
||||
|
||||
@export var enemy_id: String = "chaser"
|
||||
@export var display_name: String = "Chaser"
|
||||
@export var max_hp: int = 3
|
||||
@export var speed: float = 140.0
|
||||
@export var acceleration: float = 260.0
|
||||
@export var rotation_speed: float = 6.0
|
||||
@export var hull_points: PackedVector2Array = PackedVector2Array([
|
||||
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
|
||||
])
|
||||
@export var glow_color: Color = Color(1.0, 0.15, 0.2)
|
||||
# Interior detail drawn on top of the hull, as consecutive PAIRS of points
|
||||
# (a, b, c, d -> segments a-b and c-d). Every enemy used to be a single flat
|
||||
# outline, which left the artillery and the turret spawner as the same
|
||||
# octagon at two sizes - the silhouette carried no information about what a
|
||||
# thing did. Kept as DATA rather than a per-type draw branch, for the same
|
||||
# reason hull_points is: adding an enemy must not mean editing Enemy.gd.
|
||||
@export var detail_lines: PackedVector2Array = PackedVector2Array()
|
||||
# Rings, as triples of (centre x, centre y, radius). Sensor domes, muzzles
|
||||
# and bay openings all read better as circles than as polygon chords.
|
||||
@export var detail_rings: PackedVector2Array = PackedVector2Array()
|
||||
# #82: this enemy is destroyed by the impact when it reaches a player vessel,
|
||||
# rather than shrugging it off and lining up another pass. Data, not a type
|
||||
# check: the chaser rams because ramming is its whole behaviour, while a
|
||||
# sniper that happens to drift into a ship has not spent itself doing it.
|
||||
@export var dies_on_contact: bool = false
|
||||
@export var core_color: Color = Color(0.06, 0.02, 0.03)
|
||||
@export var component_scenes: Array[PackedScene] = []
|
||||
1
enemies/EnemyDefinition.gd.uid
Normal file
1
enemies/EnemyDefinition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bkgt6o1m12l0g
|
||||
48
enemies/EnemyDefinitions.gd
Normal file
48
enemies/EnemyDefinitions.gd
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
class_name EnemyDefinitions
|
||||
extends RefCounted
|
||||
|
||||
const DEFAULT_ID: String = "chaser"
|
||||
const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
|
||||
const DEFINITIONS: Dictionary = {
|
||||
"chaser": preload("res://enemies/ChaserEnemyDefinition.tres"),
|
||||
"sniper": preload("res://enemies/SniperEnemyDefinition.tres"),
|
||||
"artillery": preload("res://enemies/ArtilleryEnemyDefinition.tres"),
|
||||
"turret_spawner": preload("res://enemies/TurretSpawnerEnemyDefinition.tres"),
|
||||
"enemy_mothership": preload("res://enemies/MothershipEnemyDefinition.tres"),
|
||||
}
|
||||
|
||||
|
||||
# The single place that knows what an enemy LOOKS like. Three things draw
|
||||
# enemies - the enemy itself, the map editor's markers, and the editor's
|
||||
# palette buttons - and each used to reimplement it from hull_points alone.
|
||||
# Adding interior detail to the definitions therefore showed up in the game
|
||||
# and nowhere else, so an editor marker no longer matched the ship it placed.
|
||||
#
|
||||
# `at` and `scale` exist because the palette draws into a small button
|
||||
# centred on itself, while the other two draw around their own origin at
|
||||
# full size.
|
||||
static func draw_definition(item: CanvasItem, definition: Resource, glow: Color,
|
||||
scale: float = 1.0, at: Vector2 = Vector2.ZERO) -> void:
|
||||
if definition == null:
|
||||
return
|
||||
var core: Color = definition.core_color
|
||||
item.draw_set_transform(at, 0.0, Vector2(scale, scale))
|
||||
NeonDraw.draw_glowing_polygon(item, definition.hull_points, glow, 1.0, core)
|
||||
var lines: PackedVector2Array = definition.detail_lines
|
||||
for i in range(0, lines.size() - 1, 2):
|
||||
NeonDraw.draw_glowing_line(item, lines[i], lines[i + 1], glow, 0.85, 0.55, core)
|
||||
var rings: PackedVector2Array = definition.detail_rings
|
||||
for i in range(0, rings.size() - 1, 2):
|
||||
NeonDraw.draw_glowing_circle(item, rings[i], rings[i + 1].x, glow, 0.85, core)
|
||||
item.draw_set_transform(Vector2.ZERO, 0.0, Vector2.ONE)
|
||||
|
||||
|
||||
static func get_definition(enemy_id: String) -> Resource:
|
||||
return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID])
|
||||
|
||||
|
||||
static func all() -> Array:
|
||||
var result: Array = []
|
||||
for enemy_id in ORDER:
|
||||
result.append(DEFINITIONS[enemy_id])
|
||||
return result
|
||||
1
enemies/EnemyDefinitions.gd.uid
Normal file
1
enemies/EnemyDefinitions.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cfc4w3atpf8yj
|
||||
19
enemies/EnemyMarker.gd
Normal file
19
enemies/EnemyMarker.gd
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
class_name EnemyMarker
|
||||
extends Node2D
|
||||
|
||||
# An enemy as the map editor shows it. Exists so the editor can draw a
|
||||
# spawn through EnemyDefinitions.draw_definition - the same routine the
|
||||
# real enemy uses - instead of reimplementing it from hull_points, which is
|
||||
# how the two drifted apart in the first place.
|
||||
#
|
||||
# glow_color is separate from the definition's own so the editor can tint a
|
||||
# selected marker without touching the definition.
|
||||
|
||||
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
|
||||
|
||||
var definition: Resource
|
||||
var glow_color: Color = Color(1.0, 0.15, 0.2)
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
ENEMY_DEFINITIONS.draw_definition(self, definition, glow_color)
|
||||
1
enemies/EnemyMarker.gd.uid
Normal file
1
enemies/EnemyMarker.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c6ldnity1nxq7
|
||||
45
enemies/EnemyProjectile.gd
Normal file
45
enemies/EnemyProjectile.gd
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
extends Area2D
|
||||
|
||||
# Mirrors Projectile.gd, but travels the other direction: fired by an enemy
|
||||
# component (see EnemyRangedWeaponComponent, #45) and damages player_ships.
|
||||
# This is the half of ranged combat that never existed before #44 - enemies
|
||||
# could previously only hurt the fleet by physical contact.
|
||||
|
||||
@export var speed: float = 260.0
|
||||
@export var lifetime: float = 3.0
|
||||
@export var damage: int = 1
|
||||
@export var bolt_length: float = 20.0
|
||||
@export var color: Color = Color(1.0, 0.4, 0.35)
|
||||
|
||||
var velocity: Vector2 = Vector2.ZERO
|
||||
var age: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
add_to_group("enemy_projectiles")
|
||||
area_entered.connect(_on_area_entered)
|
||||
queue_redraw()
|
||||
|
||||
|
||||
func launch(direction: Vector2) -> void:
|
||||
velocity = direction.normalized() * speed
|
||||
rotation = direction.angle() + PI / 2.0
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
position += velocity * delta
|
||||
age += delta
|
||||
if age >= lifetime:
|
||||
queue_free()
|
||||
|
||||
|
||||
func _on_area_entered(area: Area2D) -> void:
|
||||
if area.is_in_group("player_ships"):
|
||||
area.take_damage(damage)
|
||||
queue_free()
|
||||
|
||||
|
||||
func _draw() -> void:
|
||||
var tip: Vector2 = Vector2(0, -bolt_length * 0.6)
|
||||
var tail: Vector2 = Vector2(0, bolt_length * 0.4)
|
||||
NeonDraw.draw_glowing_line(self, tail, tip, color, 1.0, 0.5)
|
||||
1
enemies/EnemyProjectile.gd.uid
Normal file
1
enemies/EnemyProjectile.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://b4hja3oj3rdrc
|
||||
12
enemies/EnemyProjectile.tscn
Normal file
12
enemies/EnemyProjectile.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyProjectile.gd" id="1"]
|
||||
|
||||
[sub_resource type="CircleShape2D" id="1"]
|
||||
radius = 4.0
|
||||
|
||||
[node name="EnemyProjectile" type="Area2D"]
|
||||
script = ExtResource("1")
|
||||
|
||||
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||
shape = SubResource("1")
|
||||
47
enemies/EnemyPursuitComponent.gd
Normal file
47
enemies/EnemyPursuitComponent.gd
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
class_name EnemyPursuitComponent
|
||||
extends Node
|
||||
|
||||
# Chases the nearest player ship. Moved out of Enemy.gd verbatim so the
|
||||
# behaviour is opt-in per definition rather than hardcoded into every enemy.
|
||||
|
||||
var capability_id: String = "pursuit"
|
||||
var velocity: Vector2 = Vector2.ZERO
|
||||
|
||||
# #69: used to beeline from anywhere on the map the instant it spawned,
|
||||
# which read as omniscient rather than alert. Idle until the fleet actually
|
||||
# comes within this radius. A persistent stalking threat wants the opposite
|
||||
# of this - see EnemyPursuitComponentMothership.tscn, which overrides it to
|
||||
# an effectively unlimited value for the mothership's own instance.
|
||||
@export var detection_range: float = 850.0
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
var enemy: Node2D = get_parent()
|
||||
var target: Node2D = _nearest_ship(enemy)
|
||||
if target == null or enemy.position.distance_to(target.position) > detection_range:
|
||||
return
|
||||
var to_target: Vector2 = target.position - enemy.position
|
||||
|
||||
if to_target.length() > 1.0:
|
||||
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
|
||||
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||
enemy.position += velocity * delta
|
||||
|
||||
if velocity.length() > 5.0:
|
||||
var target_angle: float = velocity.angle() + PI / 2.0
|
||||
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
|
||||
|
||||
|
||||
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return null
|
||||
|
||||
var nearest: Node2D = fleet.ships[0]
|
||||
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||
for ship in fleet.ships:
|
||||
var d: float = enemy.position.distance_to(ship.position)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
nearest = ship
|
||||
return nearest
|
||||
1
enemies/EnemyPursuitComponent.gd.uid
Normal file
1
enemies/EnemyPursuitComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://n8wohkrnl42e
|
||||
6
enemies/EnemyPursuitComponent.tscn
Normal file
6
enemies/EnemyPursuitComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyPursuitComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyPursuitComponent" type="Node"]
|
||||
script = ExtResource("1")
|
||||
7
enemies/EnemyPursuitComponentMothership.tscn
Normal file
7
enemies/EnemyPursuitComponentMothership.tscn
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyPursuitComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyPursuitComponentMothership" type="Node"]
|
||||
script = ExtResource("1")
|
||||
detection_range = 1000000.0
|
||||
54
enemies/EnemyRangedWeaponComponent.gd
Normal file
54
enemies/EnemyRangedWeaponComponent.gd
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
class_name EnemyRangedWeaponComponent
|
||||
extends Node
|
||||
|
||||
# Same shape as VesselWeaponComponent, mirrored for an enemy: fires an
|
||||
# EnemyProjectile at the nearest player ship's CURRENT position, not led,
|
||||
# so a moving ship can dodge it.
|
||||
|
||||
var capability_id: String = "ranged_weapon"
|
||||
|
||||
@export var fire_range: float = 700.0
|
||||
@export var fire_cooldown: float = 2.2
|
||||
@export var projectile_speed: float = 260.0
|
||||
@export var damage: int = 1
|
||||
|
||||
var fire_timer: float = 0.0
|
||||
var projectile_scene: PackedScene = preload("res://enemies/EnemyProjectile.tscn")
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
fire_timer -= delta
|
||||
if fire_timer > 0.0:
|
||||
return
|
||||
var enemy: Node2D = get_parent()
|
||||
var target: Node2D = _nearest_ship_in_range(enemy)
|
||||
if target == null:
|
||||
fire_timer = 0.25
|
||||
return
|
||||
var projectile: Area2D = projectile_scene.instantiate()
|
||||
# global_position: the projectile is reparented to root, a different
|
||||
# node than the enemy's own (possibly nested) parent, so local
|
||||
# position would be wrong the moment an enemy isn't a direct root
|
||||
# child - exactly the case #44 introduced (planet-surface hosting).
|
||||
projectile.position = enemy.global_position
|
||||
projectile.damage = damage
|
||||
projectile.speed = projectile_speed
|
||||
enemy.get_tree().root.add_child(projectile)
|
||||
projectile.launch(target.global_position - enemy.global_position)
|
||||
fire_timer = fire_cooldown
|
||||
|
||||
|
||||
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null:
|
||||
return null
|
||||
var nearest: Node2D = null
|
||||
var best_distance: float = fire_range
|
||||
for ship in fleet.ships:
|
||||
if not is_instance_valid(ship):
|
||||
continue
|
||||
var distance: float = enemy.global_position.distance_to(ship.global_position)
|
||||
if distance <= best_distance:
|
||||
best_distance = distance
|
||||
nearest = ship
|
||||
return nearest
|
||||
1
enemies/EnemyRangedWeaponComponent.gd.uid
Normal file
1
enemies/EnemyRangedWeaponComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dx1lmxw6ia66a
|
||||
6
enemies/EnemyRangedWeaponComponent.tscn
Normal file
6
enemies/EnemyRangedWeaponComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyRangedWeaponComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyRangedWeaponComponent" type="Node"]
|
||||
script = ExtResource("1")
|
||||
42
enemies/EnemySpawner.gd
Normal file
42
enemies/EnemySpawner.gd
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
extends Node2D
|
||||
|
||||
@export var min_spawn_interval: float = 3.0
|
||||
@export var max_spawn_interval: float = 6.0
|
||||
@export var spawn_margin: float = 150.0
|
||||
|
||||
@onready var fleet: Node2D = get_node("../Fleet")
|
||||
|
||||
var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
|
||||
var timer: float = 0.0
|
||||
var next_spawn_time: float = 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
randomize()
|
||||
_reset_timer()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
timer += delta
|
||||
if timer >= next_spawn_time:
|
||||
timer = 0.0
|
||||
_reset_timer()
|
||||
_spawn_enemy()
|
||||
|
||||
|
||||
func _reset_timer() -> void:
|
||||
next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval)
|
||||
|
||||
|
||||
func _spawn_enemy() -> void:
|
||||
if fleet.ships.is_empty():
|
||||
return
|
||||
|
||||
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
|
||||
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
|
||||
var angle: float = randf() * TAU
|
||||
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius
|
||||
|
||||
var enemy: Node2D = enemy_scene.instantiate()
|
||||
enemy.position = spawn_pos
|
||||
get_parent().add_child(enemy)
|
||||
1
enemies/EnemySpawner.gd.uid
Normal file
1
enemies/EnemySpawner.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://g3c73d6givuo
|
||||
78
enemies/EnemySpawnerComponent.gd
Normal file
78
enemies/EnemySpawnerComponent.gd
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
class_name EnemySpawnerComponent
|
||||
extends Node
|
||||
|
||||
# Continuously spawns another enemy type beside its host, at a fixed
|
||||
# interval, capped at max_alive concurrently. The spawned enemies are
|
||||
# added as children of the spawner's OWN PARENT - not the spawner itself -
|
||||
# so killing the spawner never frees its already-spawned children (they
|
||||
# are siblings, not descendants) and so a surface-hosted spawner keeps its
|
||||
# spawns under the same host a `../Fleet`-style path assumption would miss
|
||||
# (see #44's note on why that hazard exists for anything parented under a
|
||||
# planet surface's PlanetSetupHost rather than directly under Main.tscn).
|
||||
|
||||
var capability_id: String = "spawner"
|
||||
|
||||
@export var spawn_interval: float = 4.0
|
||||
@export var spawned_enemy_id: String = "chaser"
|
||||
@export var max_alive: int = 6
|
||||
@export var spawn_radius: float = 90.0
|
||||
|
||||
var spawn_timer: float = 0.0
|
||||
var spawned: Array = []
|
||||
# Resolved lazily via load() on first use rather than preload()d as a
|
||||
# compile-time constant: when a definition's component_scenes chain leads
|
||||
# BACK to Enemy.tscn while it is still mid-load (e.g. two different preset
|
||||
# scenes of this same script both live on one definition, as the
|
||||
# mothership's chaser/artillery spawners are), the preloaded constant can
|
||||
# get permanently baked in as a broken, empty PackedScene - confirmed by
|
||||
# ResourceLoader.has_cached() being true (a good copy exists) while the
|
||||
# preloaded reference's own resource_path was empty. load() at first real
|
||||
# use instead always resolves against whatever is actually in the cache
|
||||
# by then.
|
||||
var enemy_scene: PackedScene = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Start counting down from a full interval rather than firing on the
|
||||
# very first tick: besides reading more naturally (a freshly-placed
|
||||
# spawner doesn't insta-spawn), this keeps two sibling spawners with
|
||||
# different intervals (e.g. the mothership's chaser/artillery presets)
|
||||
# from both landing their first attempt on the exact same physics
|
||||
# frame - which hit a transient PackedScene.instantiate() failure on
|
||||
# this engine build when it happened.
|
||||
spawn_timer = spawn_interval
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
spawned = spawned.filter(func(node): return is_instance_valid(node))
|
||||
if spawned.size() >= max_alive:
|
||||
return
|
||||
|
||||
spawn_timer -= delta
|
||||
if spawn_timer > 0.0:
|
||||
return
|
||||
spawn_timer = spawn_interval
|
||||
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return
|
||||
|
||||
var spawner: Node2D = get_parent()
|
||||
var host: Node = spawner.get_parent()
|
||||
if host == null:
|
||||
return
|
||||
|
||||
var angle: float = randf() * TAU
|
||||
var offset: Vector2 = Vector2(cos(angle), sin(angle)) * spawn_radius
|
||||
|
||||
if enemy_scene == null:
|
||||
enemy_scene = load("res://enemies/Enemy.tscn")
|
||||
var new_enemy: Node2D = enemy_scene.instantiate()
|
||||
new_enemy.enemy_id = spawned_enemy_id
|
||||
# spawner.position is already expressed in `host`'s local space, and
|
||||
# new_enemy is about to be added to that same `host` - unlike
|
||||
# EnemyRangedWeaponComponent's projectile, which reparents to root and
|
||||
# needs global_position, there is no space conversion needed here.
|
||||
new_enemy.position = spawner.position + offset
|
||||
host.add_child(new_enemy)
|
||||
spawned.append(new_enemy)
|
||||
1
enemies/EnemySpawnerComponent.gd.uid
Normal file
1
enemies/EnemySpawnerComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://eqkp0coeoh4d
|
||||
6
enemies/EnemySpawnerComponent.tscn
Normal file
6
enemies/EnemySpawnerComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemySpawnerComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemySpawnerComponent" type="Node"]
|
||||
script = ExtResource("1")
|
||||
10
enemies/EnemySpawnerComponentArtillery.tscn
Normal file
10
enemies/EnemySpawnerComponentArtillery.tscn
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemySpawnerComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemySpawnerComponentArtillery" type="Node"]
|
||||
script = ExtResource("1")
|
||||
spawn_interval = 18.0
|
||||
spawned_enemy_id = "artillery"
|
||||
max_alive = 2
|
||||
spawn_radius = 130.0
|
||||
10
enemies/EnemySpawnerComponentChaser.tscn
Normal file
10
enemies/EnemySpawnerComponentChaser.tscn
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemySpawnerComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemySpawnerComponentChaser" type="Node"]
|
||||
script = ExtResource("1")
|
||||
spawn_interval = 5.0
|
||||
spawned_enemy_id = "chaser"
|
||||
max_alive = 6
|
||||
spawn_radius = 110.0
|
||||
62
enemies/EnemyStandoffComponent.gd
Normal file
62
enemies/EnemyStandoffComponent.gd
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
class_name EnemyStandoffComponent
|
||||
extends Node
|
||||
|
||||
# Holds a preferred distance from the nearest player ship instead of
|
||||
# ramming: advances when too far, retreats when too close, drifts
|
||||
# laterally in the comfortable band between so it still reads as alive
|
||||
# rather than frozen. Never pair with a pursuit component on the same
|
||||
# enemy - the two would fight over velocity every tick.
|
||||
|
||||
var capability_id: String = "standoff"
|
||||
|
||||
@export var preferred_range: float = 620.0
|
||||
@export var retreat_range_factor: float = 0.75
|
||||
# #69: kept comfortably beyond preferred_range so it still has room to close
|
||||
# in before settling at stand-off distance, but the fleet has to actually be
|
||||
# somewhere on the board first - see EnemyPursuitComponent's identical fix.
|
||||
@export var detection_range: float = 1200.0
|
||||
|
||||
var velocity: Vector2 = Vector2.ZERO
|
||||
|
||||
|
||||
func tick(delta: float) -> void:
|
||||
var enemy: Node2D = get_parent()
|
||||
var target: Node2D = _nearest_ship(enemy)
|
||||
if target == null:
|
||||
return
|
||||
var to_target: Vector2 = target.position - enemy.position
|
||||
var distance: float = to_target.length()
|
||||
if distance < 1.0 or distance > detection_range:
|
||||
return
|
||||
var direction: Vector2 = to_target / distance
|
||||
var retreat_range: float = preferred_range * retreat_range_factor
|
||||
|
||||
var desired_velocity: Vector2
|
||||
if distance > preferred_range:
|
||||
desired_velocity = direction * enemy.speed
|
||||
elif distance < retreat_range:
|
||||
desired_velocity = -direction * enemy.speed
|
||||
else:
|
||||
var lateral: Vector2 = Vector2(-direction.y, direction.x)
|
||||
desired_velocity = lateral * enemy.speed * 0.5
|
||||
|
||||
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||
enemy.position += velocity * delta
|
||||
|
||||
if velocity.length() > 5.0:
|
||||
var target_angle: float = velocity.angle() + PI / 2.0
|
||||
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
|
||||
|
||||
|
||||
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return null
|
||||
var nearest: Node2D = fleet.ships[0]
|
||||
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||
for ship in fleet.ships:
|
||||
var d: float = enemy.position.distance_to(ship.position)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
nearest = ship
|
||||
return nearest
|
||||
1
enemies/EnemyStandoffComponent.gd.uid
Normal file
1
enemies/EnemyStandoffComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://c7axi54f6p7gk
|
||||
6
enemies/EnemyStandoffComponent.tscn
Normal file
6
enemies/EnemyStandoffComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyStandoffComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyStandoffComponent" type="Node"]
|
||||
script = ExtResource("1")
|
||||
21
enemies/MothershipEnemyDefinition.tres
Normal file
21
enemies/MothershipEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=6 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyPursuitComponentMothership.tscn" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemySpawnerComponentChaser.tscn" id="3"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemySpawnerComponentArtillery.tscn" id="4"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyBigLaserComponent.tscn" id="5"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "enemy_mothership"
|
||||
display_name = "Mothership"
|
||||
max_hp = 60
|
||||
speed = 35.0
|
||||
acceleration = 45.0
|
||||
rotation_speed = 1.5
|
||||
hull_points = PackedVector2Array(0, -50, 35, -25, 48, 10, 20, 48, -20, 48, -48, 10, -35, -25)
|
||||
glow_color = Color(0.55, 0.05, 0.85, 1)
|
||||
detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40)
|
||||
detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")])
|
||||
19
enemies/SniperEnemyDefinition.tres
Normal file
19
enemies/SniperEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=4 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyStandoffComponent.tscn" id="2"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyRangedWeaponComponent.tscn" id="3"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "sniper"
|
||||
display_name = "Sniper"
|
||||
max_hp = 4
|
||||
speed = 90.0
|
||||
acceleration = 180.0
|
||||
rotation_speed = 5.0
|
||||
hull_points = PackedVector2Array(0, -34, 5, -20, 9, 4, 16, 20, 6, 18, 4, 26, -4, 26, -6, 18, -16, 20, -9, 4, -5, -20)
|
||||
glow_color = Color(0.42, 0.72, 1.0, 1)
|
||||
detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12)
|
||||
detail_rings = PackedVector2Array(0, -2, 5, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
|
||||
18
enemies/TurretSpawnerEnemyDefinition.tres
Normal file
18
enemies/TurretSpawnerEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemySpawnerComponent.tscn" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "turret_spawner"
|
||||
display_name = "Turret Spawner"
|
||||
max_hp = 20
|
||||
speed = 0.0
|
||||
acceleration = 0.0
|
||||
rotation_speed = 0.0
|
||||
hull_points = PackedVector2Array(0, -32, 22.6, -22.6, 32, 0, 22.6, 22.6, 0, 32, -22.6, 22.6, -32, 0, -22.6, -22.6)
|
||||
glow_color = Color(0.68, 0.92, 0.18, 1)
|
||||
detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16)
|
||||
detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0)
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
Loading…
Reference in a new issue