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:
parent
c4e2ee7178
commit
fc2b5ca74a
13 changed files with 455 additions and 1 deletions
76
tests/_sniper_vs_chaser_preview.gd
Normal file
76
tests/_sniper_vs_chaser_preview.gd
Normal 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
|
||||
1
tests/_sniper_vs_chaser_preview.gd.uid
Normal file
1
tests/_sniper_vs_chaser_preview.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://d2fj1p61hp2pk
|
||||
6
tests/_sniper_vs_chaser_preview.tscn
Normal file
6
tests/_sniper_vs_chaser_preview.tscn
Normal 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
226
tests/test_sniper_enemy.gd
Normal 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
|
||||
1
tests/test_sniper_enemy.gd.uid
Normal file
1
tests/test_sniper_enemy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://eaxineak2fte
|
||||
Loading…
Reference in a new issue