#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>
2026-08-23 13:53:17 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
#105 (#97g): garrison vessels patrol and defend their outpost
A garrisoned vessel circles its post, engages what comes into range, and
goes back to circling. It steers by writing move_target and lets Ship's own
steering fly it, so it accelerates, banks and brakes exactly like a vessel
in the formation.
It is not in Fleet.ships. It does not follow the formation, does not count
against max_fleet_size(), and does not move when the player flies away -
which is the whole point of stationing it. A garrison that ate a fleet slot
would mean stationing a vessel cost the player a place in their own wedge.
The leash is the load-bearing part. Enemies are judged from the POST rather
than from the vessel, so a garrison that has drifted out to its leash cannot
see further still and be walked away from its outpost one enemy at a time,
and an engagement is clamped to a circle around the post rather than to a
straight line, so it still manoeuvres while pinned.
The orbit starts from where the vessel actually is rather than a random
bearing. A random one makes the first move_target a point on the far side of
the circle, and the vessel flies straight across the middle - through the
outpost it is guarding.
EnemyTargets.nearest_ship now searches the player_ships GROUP instead of
Fleet.ships: an enemy that ignored the guns shooting at it, because they
were not in the player's formation, would let a garrison hold a position
without ever being fought back.
The laser vessel is deliberately not garrisonable. Firing the laser lives in
LaserController, a fleet ability driven by the laser button, rather than in
VesselLaserComponent - so a garrisoned laser would hold its position and
never shoot, and offering a vessel that cannot fight is worse than a short
list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:47 +02:00
|
|
|
# The GROUP, not Fleet.ships. #105 puts vessels at outposts that are not in
|
|
|
|
|
# the fleet at all, and an enemy that ignored the guns actually shooting at
|
|
|
|
|
# it - because they were not in the player's formation - would let a
|
|
|
|
|
# garrison hold a position without ever being fought back.
|
#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>
2026-08-23 13:53:17 +02:00
|
|
|
static func nearest_ship(node: Node, from: Vector2) -> Node2D:
|
|
|
|
|
var nearest: Node2D = null
|
|
|
|
|
var best: float = INF
|
#105 (#97g): garrison vessels patrol and defend their outpost
A garrisoned vessel circles its post, engages what comes into range, and
goes back to circling. It steers by writing move_target and lets Ship's own
steering fly it, so it accelerates, banks and brakes exactly like a vessel
in the formation.
It is not in Fleet.ships. It does not follow the formation, does not count
against max_fleet_size(), and does not move when the player flies away -
which is the whole point of stationing it. A garrison that ate a fleet slot
would mean stationing a vessel cost the player a place in their own wedge.
The leash is the load-bearing part. Enemies are judged from the POST rather
than from the vessel, so a garrison that has drifted out to its leash cannot
see further still and be walked away from its outpost one enemy at a time,
and an engagement is clamped to a circle around the post rather than to a
straight line, so it still manoeuvres while pinned.
The orbit starts from where the vessel actually is rather than a random
bearing. A random one makes the first move_target a point on the far side of
the circle, and the vessel flies straight across the middle - through the
outpost it is guarding.
EnemyTargets.nearest_ship now searches the player_ships GROUP instead of
Fleet.ships: an enemy that ignored the guns shooting at it, because they
were not in the player's formation, would let a garrison hold a position
without ever being fought back.
The laser vessel is deliberately not garrisonable. Firing the laser lives in
LaserController, a fleet ability driven by the laser button, rather than in
VesselLaserComponent - so a garrisoned laser would hold its position and
never shoot, and offering a vessel that cannot fight is worse than a short
list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:47 +02:00
|
|
|
for ship in node.get_tree().get_nodes_in_group("player_ships"):
|
|
|
|
|
if not is_instance_valid(ship) or ship.is_queued_for_deletion():
|
#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>
2026-08-23 13:53:17 +02:00
|
|
|
continue
|
#105 (#97g): garrison vessels patrol and defend their outpost
A garrisoned vessel circles its post, engages what comes into range, and
goes back to circling. It steers by writing move_target and lets Ship's own
steering fly it, so it accelerates, banks and brakes exactly like a vessel
in the formation.
It is not in Fleet.ships. It does not follow the formation, does not count
against max_fleet_size(), and does not move when the player flies away -
which is the whole point of stationing it. A garrison that ate a fleet slot
would mean stationing a vessel cost the player a place in their own wedge.
The leash is the load-bearing part. Enemies are judged from the POST rather
than from the vessel, so a garrison that has drifted out to its leash cannot
see further still and be walked away from its outpost one enemy at a time,
and an engagement is clamped to a circle around the post rather than to a
straight line, so it still manoeuvres while pinned.
The orbit starts from where the vessel actually is rather than a random
bearing. A random one makes the first move_target a point on the far side of
the circle, and the vessel flies straight across the middle - through the
outpost it is guarding.
EnemyTargets.nearest_ship now searches the player_ships GROUP instead of
Fleet.ships: an enemy that ignored the guns shooting at it, because they
were not in the player's formation, would let a garrison hold a position
without ever being fought back.
The laser vessel is deliberately not garrisonable. Firing the laser lives in
LaserController, a fleet ability driven by the laser button, rather than in
VesselLaserComponent - so a garrisoned laser would hold its position and
never shoot, and offering a vessel that cannot fight is worse than a short
list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:47 +02:00
|
|
|
var d: float = from.distance_to((ship as Node2D).global_position)
|
#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>
2026-08-23 13:53:17 +02:00
|
|
|
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")
|
2026-08-23 21:38:09 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# #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"))
|