#101 (#97c): waves attack buildings

Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.

EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.

Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.

What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.

Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 13:53:17 +02:00
commit 3debcd7edd
18 changed files with 403 additions and 54 deletions

View file

@ -16,3 +16,4 @@ 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")])
contact_damage = 6

View file

@ -15,3 +15,4 @@ 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")])
contact_damage = 16

View file

@ -57,10 +57,27 @@ func _physics_process(delta: float) -> void:
# explosion, same `destroyed` signal, same bookkeeping for whatever is
# listening.
func collide_with_player_vessel() -> void:
_resolve_ram()
# #101: a structure resolves an impact by the same rule - whether it costs
# the enemy its life is the definition's call, not the target's type.
func collide_with_player_building() -> void:
_resolve_ram()
func _resolve_ram() -> void:
if definition != null and bool(definition.dies_on_contact):
take_damage(hp)
# What one strike against a structure takes off it. Data on the definition,
# for the same reason dies_on_contact is: a lancer arriving at 950px/s is not
# a sniper drifting into a wall.
func contact_damage() -> int:
return 6 if definition == null else int(definition.contact_damage)
func take_damage(amount: int) -> void:
hp -= amount
if hp <= 0:

View file

@ -31,5 +31,9 @@ extends Resource
# 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
# #101: what one strike against a player structure takes off it. A rammer
# spends itself on the hit and so hits hardest; a stand-off enemy that drifts
# into a wall barely scratches it, its real threat being its weapon.
@export var contact_damage: int = 6
@export var core_color: Color = Color(0.06, 0.02, 0.03)
@export var component_scenes: Array[PackedScene] = []

View file

@ -162,18 +162,11 @@ func _face(enemy: Node2D, direction: Vector2, delta: float) -> void:
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
# #101: a lancer will lock a structure exactly as it locks a vessel - the
# wind-up, the line and the charge are the same, and so is what happens when
# it arrives.
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.global_position.distance_to(nearest.global_position)
for ship in fleet.ships:
var d: float = enemy.global_position.distance_to(ship.global_position)
if d < best_dist:
best_dist = d
nearest = ship
return nearest
return EnemyTargets.nearest_target(self, enemy.global_position)
func _draw() -> void:

View file

@ -37,6 +37,12 @@ func _on_area_entered(area: Area2D) -> void:
if area.is_in_group("player_ships"):
area.take_damage(damage)
queue_free()
return
# #101: structures are targets too. The hitbox is a child Area2D of the
# Building, which is a plain Node2D and could not be hit otherwise.
if area.is_in_group("player_buildings"):
area.take_damage(damage)
queue_free()
func _draw() -> void:

View file

@ -17,15 +17,18 @@ var velocity: Vector2 = Vector2.ZERO
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:
# #101: a structure is a target now, not scenery. EnemyTargets keeps a
# ship in the enemy's face ahead of a building further off.
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null \
or enemy.global_position.distance_to(target.global_position) > detection_range:
return
var to_target: Vector2 = target.position - enemy.position
var to_target: Vector2 = target.global_position - enemy.global_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
enemy.global_position += velocity * delta
if velocity.length() > 5.0:
var target_angle: float = velocity.angle() + PI / 2.0
@ -33,15 +36,4 @@ func tick(delta: float) -> void:
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
return EnemyTargets.nearest_ship(self, enemy.global_position)

View file

@ -38,17 +38,12 @@ func tick(delta: float) -> void:
fire_timer = fire_cooldown
# #101: shoots at whatever EnemyTargets says it is coming for, so a sniper
# shells a depot rather than sitting in range of one doing nothing.
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null:
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == 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
if enemy.global_position.distance_to(target.global_position) > fire_range:
return null
return target

View file

@ -21,10 +21,11 @@ var velocity: Vector2 = Vector2.ZERO
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target: Node2D = _nearest_ship(enemy)
# #101: holds its preferred range off a building just as off a ship.
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null:
return
var to_target: Vector2 = target.position - enemy.position
var to_target: Vector2 = target.global_position - enemy.global_position
var distance: float = to_target.length()
if distance < 1.0 or distance > detection_range:
return
@ -41,7 +42,7 @@ func tick(delta: float) -> void:
desired_velocity = lateral * enemy.speed * 0.5
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
enemy.position += velocity * delta
enemy.global_position += velocity * delta
if velocity.length() > 5.0:
var target_angle: float = velocity.angle() + PI / 2.0
@ -49,14 +50,4 @@ func tick(delta: float) -> void:
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
return EnemyTargets.nearest_ship(self, enemy.global_position)

65
enemies/EnemyTargets.gd Normal file
View file

@ -0,0 +1,65 @@
class_name EnemyTargets
extends RefCounted
# #101 (#97c): who a wave enemy is coming for.
#
# Before this, every enemy component asked the same question - "which player
# SHIP is nearest?" - and a structure was scenery they flew straight past.
# #97 makes waves go after what the player has built, so the question
# becomes "which player thing is nearest", asked in one place so pursuit,
# standoff, the lancer's lunge and the ranged weapon cannot drift into
# disagreeing about it.
#
# There is one thumb on the scale. A ship inside SHIP_PRIORITY_RANGE always
# wins, even if a building is nearer: an enemy that ignored the vessels
# shooting it in order to reach a depot two hundred pixels closer would read
# as broken, and would make the fleet unable to defend anything by
# interposing itself.
const SHIP_PRIORITY_RANGE: float = 520.0
static func nearest_ship(node: Node, from: Vector2) -> Node2D:
var fleet: Node2D = node.get_tree().get_first_node_in_group("player_fleet")
if fleet == null:
return null
var nearest: Node2D = null
var best: float = INF
for ship in fleet.ships:
if not is_instance_valid(ship):
continue
var d: float = from.distance_to(ship.global_position)
if d < best:
best = d
nearest = ship
return nearest
static func nearest_building(node: Node, from: Vector2) -> Node2D:
var nearest: Node2D = null
var best: float = INF
for building in node.get_tree().get_nodes_in_group("buildings"):
if not is_instance_valid(building) or building.is_queued_for_deletion():
continue
var d: float = from.distance_to(building.global_position)
if d < best:
best = d
nearest = building
return nearest
static func nearest_target(node: Node, from: Vector2) -> Node2D:
var ship: Node2D = nearest_ship(node, from)
if ship != null and from.distance_to(ship.global_position) <= SHIP_PRIORITY_RANGE:
return ship
var building: Node2D = nearest_building(node, from)
if building == null:
return ship
if ship == null:
return building
return ship if from.distance_to(ship.global_position) \
<= from.distance_to(building.global_position) else building
static func is_building(target: Node) -> bool:
return target != null and target.is_in_group("buildings")

View file

@ -17,3 +17,4 @@ detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2)
detail_rings = PackedVector2Array(0, 8, 4, 0)
dies_on_contact = true
component_scenes = Array[PackedScene]([ExtResource("2")])
contact_damage = 22

View file

@ -19,3 +19,4 @@ 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")])
contact_damage = 34

View file

@ -17,3 +17,4 @@ 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")])
contact_damage = 4

View file

@ -16,3 +16,4 @@ 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")])
contact_damage = 5