226 lines
8.3 KiB
GDScript3
226 lines
8.3 KiB
GDScript3
|
|
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
|