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>
103 lines
3.3 KiB
GDScript
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
|