2026-08-15 21:23:01 +02:00
|
|
|
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
|
2026-08-17 19:47:37 +02:00
|
|
|
# #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
|
2026-08-15 21:23:01 +02:00
|
|
|
|
|
|
|
|
var velocity: Vector2 = Vector2.ZERO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func tick(delta: float) -> void:
|
|
|
|
|
var enemy: Node2D = get_parent()
|
#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
|
|
|
# #101: holds its preferred range off a building just as off a ship.
|
|
|
|
|
var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position)
|
2026-08-15 21:23:01 +02:00
|
|
|
if target == null:
|
|
|
|
|
return
|
#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
|
|
|
var to_target: Vector2 = target.global_position - enemy.global_position
|
2026-08-15 21:23:01 +02:00
|
|
|
var distance: float = to_target.length()
|
2026-08-17 19:47:37 +02:00
|
|
|
if distance < 1.0 or distance > detection_range:
|
2026-08-15 21:23:01 +02:00
|
|
|
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)
|
#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
|
|
|
enemy.global_position += velocity * delta
|
2026-08-15 21:23:01 +02:00
|
|
|
|
|
|
|
|
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:
|
#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
|
|
|
return EnemyTargets.nearest_ship(self, enemy.global_position)
|