Add enemy foundation: definitions, components, ranged damage (#44)

Enemy.gd now resolves stats/hull/color from EnemyDefinition resources
and composes behaviour from capability components (EnemyPursuitComponent
holds today's chase-the-nearest-ship logic), the same shape Ship.gd
already uses for vessels. Fleet lookups go through a "player_fleet"
group instead of a hardcoded ../Fleet sibling path, so an enemy parented
anywhere (e.g. under a planet surface's SurfaceFeatures) still finds it.

Also closes a real gap: enemies previously had no way to damage the
fleet at range at all (Ship only reacted to physical contact,
Projectile.gd was player-only). Ship.take_damage() is now a proper
entry point - contact damage routes through it unchanged - and
EnemyProjectile lets an enemy component fire at player_ships. Both
ranged enemy archetypes in #45/#46 build on this.

Part of the #40 conquest-mechanic breakdown.
This commit is contained in:
Loïc Wisniewski 2026-08-15 14:43:29 +02:00
commit f14ee656ea
20 changed files with 393 additions and 42 deletions

View file

@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://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)
component_scenes = Array[PackedScene]([ExtResource("2")])

View file

@ -2,50 +2,53 @@ extends Area2D
signal destroyed
@export var speed: float = 140.0
@export var acceleration: float = 260.0
@export var rotation_speed: float = 6.0
@export var max_hp: int = 3
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
var velocity: Vector2 = Vector2.ZERO
@export var enemy_id: String = "chaser"
var definition: Resource
var hp: int
@onready var fleet: Node2D = get_node("../Fleet")
# 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:
hp = max_hp
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:
var target_pos: Vector2 = _nearest_ship_position()
var to_target: Vector2 = target_pos - position
if to_target.length() > 1.0:
var desired_velocity: Vector2 = to_target.normalized() * speed
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
position += velocity * delta
if velocity.length() > 5.0:
var target_angle: float = velocity.angle() + PI / 2.0
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
func _nearest_ship_position() -> Vector2:
if fleet.ships.is_empty():
return position
var nearest: Node2D = fleet.ships[0]
var best_dist: float = position.distance_to(nearest.position)
for ship in fleet.ships:
var d: float = position.distance_to(ship.position)
if d < best_dist:
best_dist = d
nearest = ship
return nearest.position
for child in get_children():
if child.has_method("tick"):
child.tick(delta)
func take_damage(amount: int) -> void:
@ -57,8 +60,9 @@ func take_damage(amount: int) -> void:
func _draw() -> void:
var points: PackedVector2Array = PackedVector2Array([
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0)
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
])
var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2)
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
NeonDraw.draw_glowing_polygon(self, points, Color(1.0, 0.15, 0.2), 1.0)
NeonDraw.draw_glowing_polygon(self, points, glow, 1.0)

19
EnemyDefinition.gd Normal file
View file

@ -0,0 +1,19 @@
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)
@export var component_scenes: Array[PackedScene] = []

1
EnemyDefinition.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://bkgt6o1m12l0g

19
EnemyDefinitions.gd Normal file
View file

@ -0,0 +1,19 @@
class_name EnemyDefinitions
extends RefCounted
const DEFAULT_ID: String = "chaser"
const ORDER: PackedStringArray = ["chaser"]
const DEFINITIONS: Dictionary = {
"chaser": preload("res://ChaserEnemyDefinition.tres"),
}
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
EnemyDefinitions.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cfc4w3atpf8yj

45
EnemyProjectile.gd Normal file
View 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
EnemyProjectile.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://b4hja3oj3rdrc

12
EnemyProjectile.tscn Normal file
View file

@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://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")

38
EnemyPursuitComponent.gd Normal file
View file

@ -0,0 +1,38 @@
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
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target_pos: Vector2 = _nearest_ship_position(enemy)
var to_target: Vector2 = target_pos - 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_position(enemy: Node2D) -> Vector2:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return enemy.position
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.position

View file

@ -0,0 +1 @@
uid://n8wohkrnl42e

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"]
[node name="EnemyPursuitComponent" type="Node"]
script = ExtResource("1")

View file

@ -32,6 +32,10 @@ var steer_direction: Vector2 = Vector2.ZERO
func _ready() -> void:
# Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling
# path - enemies parented under different hosts (e.g. a planet surface's
# SurfaceFeatures) need this to find their target.
add_to_group("player_fleet")
# Researching a vessel upgrade improves the ships already flying, not
# just the next one built (#5).
GameState.vessel_upgraded.connect(_on_vessel_upgraded)

22
Ship.gd
View file

@ -115,17 +115,27 @@ func _physics_process(delta: float) -> void:
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
func take_damage(_amount: int) -> bool:
# Ships have no hit points - any unabsorbed hit is lethal. A shield
# charge absorbs a hit entirely regardless of its amount, matching
# VesselShieldComponent's existing all-or-nothing semantics; `_amount`
# is accepted (not `int`-clamped away) so callers like EnemyProjectile
# have a normal damage parameter to pass even though it isn't graded here.
for child in get_children():
if child.has_method("absorb_hit") and child.absorb_hit():
if area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
return
return true
ShipExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
return false
func _on_area_entered(area: Area2D) -> void:
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
return
var absorbed: bool = take_damage(1)
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
area.queue_free()
func _draw() -> void:

View file

@ -39,5 +39,5 @@ func _ready() -> void:
for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]:
var enemy: Node = load("res://Enemy.tscn").instantiate()
enemy.position = skill.aim_target + offset
enemy.speed = 0.0
main.add_child(enemy)
enemy.speed = 0.0 # after add_child - _ready() sets it from the definition

View file

@ -0,0 +1,78 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_composition.gd
# Verifies:
# 1. an Enemy resolves its stats/hull/color from EnemyDefinitions, not
# hardcoded fields
# 2. an enemy instantiated under a node that is NOT a sibling of Fleet
# still finds the fleet and pursues it (the old `../Fleet` path would
# break here - this is exactly the planet-surface hazard #44 exists
# to close before #45/#46 land)
var main_scene: Node
var fleet: Node
var nested_host: Node2D
var enemy: Node
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
var chaser_def: Resource = EnemyDefinitions.get_definition("chaser")
if chaser_def.enemy_id != "chaser" or chaser_def.max_hp != 3:
return _fail("EnemyDefinitions did not resolve the chaser definition")
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
main_scene.get_node("EnemySpawner").set_process(false)
# Deliberately NOT a sibling of Fleet - two levels deep under an
# unrelated node, the way a planet surface's SurfaceFeatures parents
# things.
nested_host = Node2D.new()
nested_host.name = "NestedHost"
main_scene.add_child(nested_host)
var inner: Node2D = Node2D.new()
inner.name = "Inner"
nested_host.add_child(inner)
enemy = load("res://Enemy.tscn").instantiate()
enemy.position = fleet.get_centroid() + Vector2(600, 0)
inner.add_child(enemy)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.1:
return false
if enemy.definition == null or enemy.definition.enemy_id != "chaser":
return _fail("enemy did not resolve its definition on _ready")
if enemy.hp != enemy.definition.max_hp:
return _fail("enemy hp did not come from the definition")
if not enemy.has_capability("pursuit"):
return _fail("chaser did not compose a pursuit component")
elapsed = 0.0
stage = 1
return false
1:
# Give the pursuit component a few physics ticks to close distance.
if elapsed < 1.0:
return false
var distance: float = enemy.position.distance_to(fleet.get_centroid())
print("distance_after_1s=%.1f (started at 600)" % distance)
if distance >= 550.0:
return _fail("enemy under a non-sibling host never approached the fleet")
print("TEST_ENEMY_COMPOSITION: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_ENEMY_COMPOSITION: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1 @@
uid://dltu3a0n318lo

View file

@ -0,0 +1,91 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_projectile_damage.gd
# Verifies the ranged damage path #44 adds: an EnemyProjectile destroys an
# unshielded ship on contact, and is absorbed by a shielded ship, removing
# exactly one charge rather than destroying it.
var game_state: Node
var scene: Node
var fleet: Node
var interceptor: Node
var shield_ship: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.stored_vessels.clear()
game_state.build_time_scale = 0.02
game_state.reset_progression()
game_state.rocks = 2000
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
func _fire_at(ship: Node) -> void:
var projectile: Area2D = load("res://EnemyProjectile.tscn").instantiate()
projectile.global_position = ship.global_position
projectile.launch(Vector2.ZERO) # already on top of the target
scene.add_child(projectile)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
fleet.spawn_ships(1, Vector2.ZERO)
interceptor = fleet.ships[0]
fleet.add_ship(GameState.VESSEL_SHIELD)
shield_ship = fleet.ships[1]
shield_ship.position = Vector2(2000.0, 0.0)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
if not shield_ship.has_shield():
return _fail("a fresh shield vessel has no shield")
_fire_at(interceptor)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
if is_instance_valid(interceptor):
return _fail("an unshielded ship survived an enemy projectile")
print("unshielded interceptor destroyed by an enemy projectile")
_fire_at(shield_ship)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.4:
return false
if not is_instance_valid(shield_ship):
return _fail("a shielded ship was destroyed by an enemy projectile")
if shield_ship.shield_charges != 0:
return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" %
shield_ship.shield_charges)
print("shielded ship absorbed the hit and lost exactly one charge")
print("TEST_ENEMY_PROJECTILE_DAMAGE: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_ENEMY_PROJECTILE_DAMAGE: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1 @@
uid://cx7f3e82jkumb

View file

@ -107,16 +107,19 @@ func _process(delta: float) -> bool:
if main.get_node("Camera2D").zoom.x >= original_zoom:
return _fail_now("long aim did not zoom out")
# speed is set AFTER add_child(): _ready() copies it from the
# enemy's definition, which would otherwise clobber a
# pre-add_child override the instant the enemy entered the tree.
for offset in [Vector2(-60, 0), Vector2(70, 35)]:
var enemy: Node = load("res://Enemy.tscn").instantiate()
enemy.position = skill.aim_target + offset
enemy.speed = 0.0
main.add_child(enemy)
enemy.speed = 0.0
inside_enemies.append(enemy)
outside_enemy = load("res://Enemy.tscn").instantiate()
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
outside_enemy.speed = 0.0
main.add_child(outside_enemy)
outside_enemy.speed = 0.0
var release := InputEventScreenTouch.new()
release.index = 7