#113: a wave converges on the nearest structure from any distance

A wave is an attack on what the player has built, not a hazard sitting in
space waiting to be flown near. Wave enemies now ignore the detection radius
their components otherwise respect and close on the nearest building from
wherever they arrived.

The flag is per INSTANCE rather than per definition: the same chaser is a
wave when the director launches it and a garrison when a map author places
it, and a planet garrison that chased the player across the system would be
a very different thing from what the editor is used to place. The test pins
both halves - a wave chaser 6000px out closed on a crafter, and an
identically built one nobody sent held its station.

What it fights on the way is unchanged: EnemyTargets already prefers a
vessel inside 520px over a building further off, and already searches the
player_ships group, so the fleet and an outpost's garrison both count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 21:38:09 +02:00
commit 1a2a27b6e8
6 changed files with 68 additions and 3 deletions

View file

@ -5,6 +5,16 @@ signal destroyed
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd") const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
@export var enemy_id: String = "chaser" @export var enemy_id: String = "chaser"
# #113: this one came in with a WAVE, and a wave is an attack on what the
# player has built rather than a hazard sitting in space. It converges on the
# nearest structure from wherever it spawned, ignoring the detection range
# every component otherwise respects.
#
# Per instance rather than per definition: the same chaser is a wave when the
# director launches it and a garrison when a map author places it, and a
# planet garrison that chased the player across the system would be a very
# different thing from what the editor was used to place.
@export var relentless: bool = false
var definition: Resource var definition: Resource
var hp: int var hp: int

View file

@ -20,8 +20,13 @@ func tick(delta: float) -> void:
# #101: a structure is a target now, not scenery. EnemyTargets keeps a # #101: a structure is a target now, not scenery. EnemyTargets keeps a
# ship in the enemy's face ahead of a building further off. # ship in the enemy's face ahead of a building further off.
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
if target == null \ if target == null:
or enemy.global_position.distance_to(target.global_position) > detection_range: return
# #113: a wave enemy closes on what the player built from wherever it
# arrived. Everything else keeps the radius that stops it reading as
# omniscient (#69).
if not EnemyTargets.ignores_range(enemy) \
and enemy.global_position.distance_to(target.global_position) > detection_range:
return return
var to_target: Vector2 = target.global_position - enemy.global_position var to_target: Vector2 = target.global_position - enemy.global_position

View file

@ -57,6 +57,10 @@ func _launch_wave() -> void:
var enemy: Node2D = enemy_scene.instantiate() var enemy: Node2D = enemy_scene.instantiate()
# Before add_child: Enemy._ready() resolves enemy_id into a definition. # Before add_child: Enemy._ready() resolves enemy_id into a definition.
enemy.enemy_id = composition[i] enemy.enemy_id = composition[i]
# #113: a wave is an attack on what the player has built. It converges
# on the nearest structure from wherever it spawned rather than
# waiting to be flown near.
enemy.relentless = true
enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius
get_parent().add_child(enemy) get_parent().add_child(enemy)
wave_enemies.append(enemy) wave_enemies.append(enemy)

View file

@ -27,7 +27,10 @@ func tick(delta: float) -> void:
return return
var to_target: Vector2 = target.global_position - enemy.global_position var to_target: Vector2 = target.global_position - enemy.global_position
var distance: float = to_target.length() var distance: float = to_target.length()
if distance < 1.0 or distance > detection_range: if distance < 1.0:
return
# #113: see EnemyPursuitComponent - a wave arrives with somewhere to be.
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
return return
var direction: Vector2 = to_target / distance var direction: Vector2 = to_target / distance
var retreat_range: float = preferred_range * retreat_range_factor var retreat_range: float = preferred_range * retreat_range_factor

View file

@ -64,3 +64,10 @@ static func nearest_target(node: Node, from: Vector2) -> Node2D:
static func is_building(target: Node) -> bool: static func is_building(target: Node) -> bool:
return target != null and target.is_in_group("buildings") return target != null and target.is_in_group("buildings")
# #113: whether this enemy may act on a target however far away it is.
# Asked here rather than read directly, so the three movement components
# cannot drift into disagreeing about what "relentless" means.
static func ignores_range(enemy: Node) -> bool:
return enemy != null and bool(enemy.get("relentless"))

View file

@ -20,6 +20,9 @@ extends SceneTree
# one hit on entry and then sitting there harmlessly # one hit on entry and then sitting there harmlessly
# E. an enemy projectile damages a structure # E. an enemy projectile damages a structure
# F. enough of it levels the building, and the node goes with the record # F. enough of it levels the building, and the node goes with the record
# G. #113: a WAVE enemy converges on the nearest structure from any
# distance, where one placed by a map author keeps the detection radius
# that stops it reading as omniscient
var game_state: Node var game_state: Node
var main: Node var main: Node
@ -35,6 +38,11 @@ var stage: int = 0
var elapsed: float = 0.0 var elapsed: float = 0.0
var hp_watermark: int = 0 var hp_watermark: int = 0
var destroyed_fired: bool = false var destroyed_fired: bool = false
var roamer: Node2D
var raider: Node2D
var far_id: int = 0
var roamer_from: float = 0.0
var raider_from: float = 0.0
func _initialize() -> void: func _initialize() -> void:
@ -198,6 +206,34 @@ func _process(delta: float) -> bool:
return _fail("the crafter node outlived the record") return _fail("the crafter node outlived the record")
print("the crafter came down, and its node went with it") print("the crafter came down, and its node went with it")
# --- G. a wave arrives with somewhere to be -----------------
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(0.0, 0.0))
far_id = int(game_state.buildings[-1].id)
# Well past every component's detection_range.
var far_away: Vector2 = Vector2(6000.0, 0.0)
roamer = _spawn_enemy("chaser", far_away)
raider = _spawn_enemy("chaser", far_away + Vector2(0.0, 400.0))
raider.relentless = true
roamer_from = roamer.global_position.distance_to(Vector2.ZERO)
raider_from = raider.global_position.distance_to(Vector2.ZERO)
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 1.5:
return false
if not is_instance_valid(raider) or not is_instance_valid(roamer):
return _fail("one of the two test chasers is gone")
var raider_now: float = raider.global_position.distance_to(Vector2.ZERO)
var roamer_now: float = roamer.global_position.distance_to(Vector2.ZERO)
if raider_now >= raider_from - 100.0:
return _fail("a wave chaser %.0fpx from a crafter did not close on it" % raider_from)
if roamer_now < roamer_from - 20.0:
return _fail("a chaser nobody sent moved %.0fpx toward a crafter far outside its detection range" \
% (roamer_from - roamer_now))
print("wave chaser closed %.0fpx from %.0f away; a placed one held station" \
% [raider_from - raider_now, raider_from])
print("TEST_WAVES_ATTACK_BUILDINGS: PASS") print("TEST_WAVES_ATTACK_BUILDINGS: PASS")
quit(0) quit(0)
return true return true