Add standoff sniper enemy (#45)

EnemyStandoffComponent holds a preferred distance from the nearest
player ship instead of ramming (advance beyond it, retreat inside
retreat_range_factor of it, drift laterally in the comfortable band
between) - never paired with a pursuit component. EnemyRangedWeaponComponent
mirrors VesselWeaponComponent for an enemy: fires an EnemyProjectile
aimed at the target's CURRENT position rather than led, so a moving
ship can dodge it (projectile_speed 260 vs the interceptor's 460
max_speed). Registered in EnemyDefinitions so it's placeable from a
planet setup by enemy_id.

test_sniper_enemy.gd went through a few iterations to get its own
timing right: physics ticks can run ahead of a SceneTree test's own
_process() clock under this device's load, so the cooldown-violation
check reads the component's own fire_timer directly (a rising value
means it refired) instead of comparing projectile counts against a
wall-clock wait, and manually repositioned ships need their
move_target re-pinned or Ship's own steering silently drives them back
to the stale target.

Part of the #40 conquest-mechanic breakdown.
This commit is contained in:
Loïc Wisniewski 2026-08-15 21:23:01 +02:00
commit fc2b5ca74a
13 changed files with 455 additions and 1 deletions

View file

@ -2,9 +2,10 @@ class_name EnemyDefinitions
extends RefCounted
const DEFAULT_ID: String = "chaser"
const ORDER: PackedStringArray = ["chaser"]
const ORDER: PackedStringArray = ["chaser", "sniper"]
const DEFINITIONS: Dictionary = {
"chaser": preload("res://ChaserEnemyDefinition.tres"),
"sniper": preload("res://SniperEnemyDefinition.tres"),
}

View file

@ -0,0 +1,54 @@
class_name EnemyRangedWeaponComponent
extends Node
# Same shape as VesselWeaponComponent, mirrored for an enemy: fires an
# EnemyProjectile at the nearest player ship's CURRENT position, not led,
# so a moving ship can dodge it.
var capability_id: String = "ranged_weapon"
@export var fire_range: float = 700.0
@export var fire_cooldown: float = 2.2
@export var projectile_speed: float = 260.0
@export var damage: int = 1
var fire_timer: float = 0.0
var projectile_scene: PackedScene = preload("res://EnemyProjectile.tscn")
func tick(delta: float) -> void:
fire_timer -= delta
if fire_timer > 0.0:
return
var enemy: Node2D = get_parent()
var target: Node2D = _nearest_ship_in_range(enemy)
if target == null:
fire_timer = 0.25
return
var projectile: Area2D = projectile_scene.instantiate()
# global_position: the projectile is reparented to root, a different
# node than the enemy's own (possibly nested) parent, so local
# position would be wrong the moment an enemy isn't a direct root
# child - exactly the case #44 introduced (planet-surface hosting).
projectile.position = enemy.global_position
projectile.damage = damage
projectile.speed = projectile_speed
enemy.get_tree().root.add_child(projectile)
projectile.launch(target.global_position - enemy.global_position)
fire_timer = fire_cooldown
func _nearest_ship_in_range(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == 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

View file

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

View file

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

58
EnemyStandoffComponent.gd Normal file
View file

@ -0,0 +1,58 @@
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
var velocity: Vector2 = Vector2.ZERO
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target: Node2D = _nearest_ship(enemy)
if target == null:
return
var to_target: Vector2 = target.position - enemy.position
var distance: float = to_target.length()
if distance < 1.0:
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)
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(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

View file

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

View file

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

View file

@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=4 format=3]
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://EnemyStandoffComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://EnemyRangedWeaponComponent.tscn" id="3"]
[resource]
script = ExtResource("1")
enemy_id = "sniper"
display_name = "Sniper"
max_hp = 4
speed = 90.0
acceleration = 180.0
rotation_speed = 5.0
hull_points = PackedVector2Array(0, -28, 7, -6, 7, 22, -7, 22, -7, -6)
glow_color = Color(0.42, 0.72, 1.0, 1)
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])

View file

@ -0,0 +1,76 @@
extends Node2D
# Setup is deferred to the first _process() tick rather than done in
# _ready(): PlanetAtmosphereController auto-spawns a 5-ship default fleet
# in ITS _ready() on any fresh (non-returning) Main.tscn load, and since
# this scene is itself added to the tree from inside _screenshot_tool.gd's
# own _initialize() (a nested, not-yet-settled context), there's no
# guaranteed ordering between the two _ready() calls - clearing the fleet
# here could run BEFORE that auto-spawn and then get silently overwritten
# by it. Waiting one frame guarantees it has already run first.
#
# Even after clearing, physics ticks can run several times before this
# script's own next _process() tick (softpipe rendering is slow, and
# physics is decoupled from it), so an auto-spawned ship can still get one
# shot off in flight toward these enemies before the clearing code above
# ever runs. Pausing the whole tree once everything is placed (last thing
# this script does) freezes any such shot in transit for good, rather than
# letting it arrive a few physics ticks later while the screenshot is
# still waiting out its frame count.
var main: Node
var setup_done: bool = false
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
main = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BackgroundLayer").visible = false
main.get_node("AsteroidField").visible = false
main.get_node("PlanetField").visible = false
main.get_node("BuildingField").visible = false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
func _process(_delta: float) -> void:
if setup_done:
return
setup_done = true
var fleet: Node = main.get_node("Fleet")
for ship in fleet.ships:
if is_instance_valid(ship):
ship.set_physics_process(false)
fleet.spawn_ships(0, Vector2.ZERO)
# spawn_ships(0, ...) still isn't a truly empty fleet: it falls back to
# one default interceptor whenever the requested composition would
# otherwise be empty (Fleet.gd's own "the fleet is never really empty"
# rule). Clear that forced ship too, and halt it the same way.
for ship in fleet.ships:
if is_instance_valid(ship):
ship.set_physics_process(false)
ship.queue_free()
fleet.ships.clear()
for projectile in get_tree().get_nodes_in_group("player_projectiles"):
projectile.queue_free()
var chaser: Node = load("res://Enemy.tscn").instantiate()
chaser.enemy_id = "chaser"
chaser.position = Vector2(-160, -80)
main.add_child(chaser)
chaser.speed = 0.0
var sniper: Node = load("res://Enemy.tscn").instantiate()
sniper.enemy_id = "sniper"
sniper.position = Vector2(160, -80)
main.add_child(sniper)
sniper.speed = 0.0
var camera: Camera2D = main.get_node("Camera2D")
camera.zoom = Vector2(1.4, 1.4)
get_tree().paused = true

View file

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

View file

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

226
tests/test_sniper_enemy.gd Normal file
View file

@ -0,0 +1,226 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_sniper_enemy.gd
# Verifies:
# 1. against a stationary fleet, a sniper closes to roughly its standoff
# component's preferred_range and never reaches contact range
# 2. its ranged weapon only fires within fire_range, and respects cooldown
# 3. the projectile is slow enough that a ship moving away right after the
# shot clears the impact point unharmed
# 4. a hit (ship held in place) destroys an unshielded ship and is
# absorbed by a shielded one, losing exactly one charge
#
# Movement (1) and combat (2-4) use SEPARATE sniper instances: a stationary
# ship left in weapon range for many seconds would otherwise take stray
# hits during the movement check and die before the later stages run, which
# is a test-harness artifact, not anything about the sniper's own design.
var game_state: Node
var main: Node
var fleet: Node
var mover: Node # movement-only: its ranged_weapon is removed
var sniper: Node # combat-only: pinned in place via speed = 0
var target_ship: Node
var interceptor: Node
var shield_ship: Node
var min_distance: float = INF
var cooldown_watermark: float = 0.0
var projectiles_before: int = 0
var stage: int = 0
var elapsed: float = 0.0
var settle_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
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
func _projectile_count() -> int:
return get_nodes_in_group("enemy_projectiles").size()
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.1:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
for a in get_nodes_in_group("asteroids"):
a.queue_free()
fleet.spawn_ships(1, Vector2.ZERO)
target_ship = fleet.ships[0]
mover = load("res://Enemy.tscn").instantiate()
mover.enemy_id = "sniper"
mover.position = target_ship.position + Vector2(1000, 0)
main.add_child(mover)
mover.get_component("ranged_weapon").queue_free()
elapsed = 0.0
settle_elapsed = 0.0
stage = 1
return false
1:
# --- standoff: close in, never reach contact, settle near
# preferred_range -----------------------------------------
settle_elapsed += delta
min_distance = minf(min_distance, mover.position.distance_to(target_ship.position))
if min_distance < 100.0:
return _fail("sniper reached near-contact range (%.1f) against a stationary fleet" % min_distance)
if settle_elapsed < 8.0:
return false
var standoff: Node = mover.get_component("standoff")
var final_distance: float = mover.position.distance_to(target_ship.position)
var preferred: float = standoff.preferred_range
print("min_distance=%.1f final_distance=%.1f preferred=%.1f" % [min_distance, final_distance, preferred])
if final_distance < preferred * 0.6 or final_distance > preferred * 1.4:
return _fail("sniper did not settle near its preferred_range (final=%.1f preferred=%.1f)" % [
final_distance, preferred
])
mover.queue_free()
# --- fire range gating: a fresh, pinned sniper out of range ---
fleet.spawn_ships(1, target_ship.position + Vector2(-2000, 0))
interceptor = fleet.ships[0]
sniper = load("res://Enemy.tscn").instantiate()
sniper.enemy_id = "sniper"
main.add_child(sniper)
sniper.speed = 0.0
sniper.position = interceptor.position + Vector2(2000, 0) # beyond fire_range
projectiles_before = _projectile_count()
elapsed = 0.0
stage = 2
return false
2:
var ranged: Node = sniper.get_component("ranged_weapon")
if elapsed < ranged.fire_cooldown + 0.5:
return false
if _projectile_count() != projectiles_before:
return _fail("sniper fired while beyond fire_range")
# --- move into range: it should fire ---
sniper.position = interceptor.position + Vector2(400, 0)
projectiles_before = _projectile_count()
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.4:
return false
if _projectile_count() <= projectiles_before:
return _fail("sniper did not fire once in range")
# The already-fired shot is still travelling toward interceptor's
# stationary position and would otherwise connect and vanish -
# sidestep it (staying within fire_range, so the next stage's
# wait for a fresh shot still has a target). Also re-pin
# move_target to match - otherwise Ship's own steering
# immediately drives it back toward the stale target.
interceptor.position += Vector2(0, 150)
interceptor.move_target = interceptor.position
# --- cooldown: fire_timer must not jump back up before it's
# counted all the way down, which is what a second shot would
# do. Read from the component's own state rather than inferring
# it from projectile counts against a wall-clock wait: physics
# ticks can run in catch-up bursts relative to this SceneTree's
# own _process() under device load, so an EnemyProjectile's age
# and this script's `elapsed` are not guaranteed to stay in
# lockstep even with --fixed-fps.
cooldown_watermark = sniper.get_component("ranged_weapon").fire_timer
elapsed = 0.0
stage = 4
return false
4:
# fire_timer can never be caught externally at exactly <= 0: the
# component decrements-checks-refires atomically in one tick, so
# by the time this poll runs it has either not reached zero yet
# or has already fired and jumped back up to fire_cooldown. A
# RISE from the last observed value is therefore the reliable
# externally-visible signal for "it just fired again" - within
# the first 0.6s that is a bug (still cooling down from the
# first shot), afterward it is the legitimate next shot to dodge.
var current: float = sniper.get_component("ranged_weapon").fire_timer
if current > cooldown_watermark:
if elapsed < 0.6:
return _fail("sniper's cooldown timer jumped back up (a second shot fired) before it finished counting down")
interceptor.position += Vector2(0, 900) # perpendicular, well clear
interceptor.move_target = interceptor.position
elapsed = 0.0
stage = 6
return false
cooldown_watermark = current
if elapsed > 6.0:
return _fail("sniper never fired its next shot")
return false
6:
# Give the projectile its full lifetime to have resolved either way.
if elapsed < 3.5:
return false
if not is_instance_valid(interceptor):
return _fail("a slow sniper shot still hit a ship that moved away immediately")
print("dodge held: interceptor survived after moving off the impact point")
# --- direct hit: ship held in place, unshielded then shielded ---
interceptor.position = sniper.position + Vector2(300, 0)
interceptor.move_target = interceptor.position
fleet.add_ship(GameState.VESSEL_SHIELD)
shield_ship = fleet.ships[fleet.ships.size() - 1]
shield_ship.position = Vector2(20000, 0) # far from the line of fire
shield_ship.move_target = shield_ship.position
elapsed = 0.0
stage = 7
return false
7:
if elapsed < 0.3:
return false
if not shield_ship.has_shield():
return _fail("a fresh shield vessel has no shield")
elapsed = 0.0
stage = 8
return false
8:
if is_instance_valid(interceptor):
if elapsed > 4.0:
return _fail("held interceptor was never hit by the sniper")
return false
print("stationary unshielded interceptor destroyed by sniper fire")
shield_ship.position = sniper.position + Vector2(300, 0)
shield_ship.move_target = shield_ship.position
elapsed = 0.0
stage = 9
return false
9:
if elapsed < 4.0:
return false
if not is_instance_valid(shield_ship):
return _fail("a shielded ship was destroyed by sniper fire")
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 a sniper hit and lost exactly one charge")
print("TEST_SNIPER_ENEMY: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_SNIPER_ENEMY: FAIL (%s)" % message)
quit(1)
return true

View file

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