spaceship/tests/test_enemy_projectile_damage.gd
Loïc Wisniewski 3debcd7edd #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

103 lines
3.3 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_projectile_damage.gd
# Verifies the ranged damage path #44 adds: an EnemyProjectile destroys an
# unshielded ship on contact, and is absorbed by a shielded ship, removing
# exactly one charge rather than destroying it.
var game_state: Node
var scene: Node
var fleet: Node
var interceptor: Node
var shield_ship: Node
var stage: int = 0
var 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
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
func _fire_at(ship: Node) -> void:
var projectile: Area2D = load("res://enemies/EnemyProjectile.tscn").instantiate()
projectile.global_position = ship.global_position
projectile.launch(Vector2.ZERO) # already on top of the target
scene.add_child(projectile)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# set_process(false) only stops NEW asteroids; the ones already
# placed keep drifting under their own _physics_process, and they
# are placed at random. One of them wandering into the shield
# vessel parked out at (2000, 0) spends its single charge, and
# the projectile fired at it a moment later then kills it - which
# presented as "a shielded ship was destroyed by an enemy
# projectile" on roughly one parallel suite run in thirty, and
# never in isolation. Clearing them is what the other combat
# tests already do (see test_sniper_enemy.gd).
for asteroid in get_nodes_in_group("asteroids"):
if is_instance_valid(asteroid):
asteroid.queue_free()
fleet.spawn_ships(1, Vector2.ZERO)
interceptor = fleet.ships[0]
fleet.add_ship(GameState.VESSEL_SHIELD)
shield_ship = fleet.ships[1]
shield_ship.position = Vector2(2000.0, 0.0)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
if not shield_ship.has_shield():
return _fail("a fresh shield vessel has no shield")
_fire_at(interceptor)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
if is_instance_valid(interceptor):
return _fail("an unshielded ship survived an enemy projectile")
print("unshielded interceptor destroyed by an enemy projectile")
_fire_at(shield_ship)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.4:
return false
if not is_instance_valid(shield_ship):
return _fail("a shielded ship was destroyed by an enemy projectile")
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 the hit and lost exactly one charge")
print("TEST_ENEMY_PROJECTILE_DAMAGE: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_ENEMY_PROJECTILE_DAMAGE: FAIL (%s)" % message)
quit(1)
return true