diff --git a/buildings/Building.tscn b/buildings/Building.tscn index f6fad65..2190e89 100644 --- a/buildings/Building.tscn +++ b/buildings/Building.tscn @@ -1,8 +1,18 @@ -[gd_scene load_steps=2 format=3] +[gd_scene load_steps=4 format=3] [ext_resource type="Script" path="res://buildings/Building.gd" id="1"] +[ext_resource type="Script" path="res://buildings/BuildingHitbox.gd" id="2"] + +[sub_resource type="CircleShape2D" id="1"] +radius = 72.0 [node name="Building" type="Node2D"] script = ExtResource("1") [node name="VisualHost" type="Node2D" parent="."] + +[node name="Hitbox" type="Area2D" parent="."] +script = ExtResource("2") + +[node name="CollisionShape2D" type="CollisionShape2D" parent="Hitbox"] +shape = SubResource("1") diff --git a/buildings/BuildingHitbox.gd b/buildings/BuildingHitbox.gd new file mode 100644 index 0000000..531a143 --- /dev/null +++ b/buildings/BuildingHitbox.gd @@ -0,0 +1,54 @@ +extends Area2D + +# #101 (#97c): the part of a structure a wave can actually hit. +# +# A Building is a plain Node2D, so nothing could collide with it - enemies +# flew through the mothership without noticing. This gives it a body in the +# same shape the rest of the game already understands: an Area2D in a group, +# exactly like a Ship or an asteroid, so EnemyProjectile needed one extra +# branch rather than a new concept. +# +# Damage is applied on a TICK rather than on area_entered. Entry fires once, +# which is right for a rammer - it 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, which is what #97 asks +# for. + +const STRIKE_INTERVAL: float = 0.7 + +var _strike_timer: float = 0.0 + + +func _ready() -> void: + add_to_group("player_buildings") + + +func building_id() -> int: + var building: Node = get_parent() + return 0 if building == null else int(building.building_id) + + +# Called by EnemyProjectile, which knows nothing about buildings beyond this +# name - the same call it makes on a Ship. +func take_damage(amount: int) -> void: + GameState.damage_building(building_id(), amount) + + +func _physics_process(delta: float) -> void: + _strike_timer -= delta + if _strike_timer > 0.0: + return + _strike_timer = STRIKE_INTERVAL + var id: int = building_id() + if id == 0: + return + for area in get_overlapping_areas(): + if not is_instance_valid(area) or not area.is_in_group("enemies"): + continue + var damage: int = area.contact_damage() if area.has_method("contact_damage") else 6 + # The structure is told first, for the same reason Ship tells the + # enemy first: resolving the enemy's own death can free it, and a + # rammer must still land its hit on the way out. + GameState.damage_building(id, damage) + if area.has_method("collide_with_player_building"): + area.collide_with_player_building() diff --git a/enemies/ArtilleryEnemyDefinition.tres b/enemies/ArtilleryEnemyDefinition.tres index 1e947b1..e193e24 100644 --- a/enemies/ArtilleryEnemyDefinition.tres +++ b/enemies/ArtilleryEnemyDefinition.tres @@ -16,3 +16,4 @@ glow_color = Color(1.0, 0.5, 0.2, 1) detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6) detail_rings = PackedVector2Array(0, -20, 8, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 6 diff --git a/enemies/ChaserEnemyDefinition.tres b/enemies/ChaserEnemyDefinition.tres index 8d234db..0400774 100644 --- a/enemies/ChaserEnemyDefinition.tres +++ b/enemies/ChaserEnemyDefinition.tres @@ -15,3 +15,4 @@ hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0) glow_color = Color(1, 0.15, 0.2, 1) dies_on_contact = true component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 16 diff --git a/enemies/Enemy.gd b/enemies/Enemy.gd index 9a26869..eb99a08 100644 --- a/enemies/Enemy.gd +++ b/enemies/Enemy.gd @@ -57,10 +57,27 @@ func _physics_process(delta: float) -> void: # explosion, same `destroyed` signal, same bookkeeping for whatever is # listening. func collide_with_player_vessel() -> void: + _resolve_ram() + + +# #101: a structure resolves an impact by the same rule - whether it costs +# the enemy its life is the definition's call, not the target's type. +func collide_with_player_building() -> void: + _resolve_ram() + + +func _resolve_ram() -> void: if definition != null and bool(definition.dies_on_contact): take_damage(hp) +# What one strike against a structure takes off it. Data on the definition, +# for the same reason dies_on_contact is: a lancer arriving at 950px/s is not +# a sniper drifting into a wall. +func contact_damage() -> int: + return 6 if definition == null else int(definition.contact_damage) + + func take_damage(amount: int) -> void: hp -= amount if hp <= 0: diff --git a/enemies/EnemyDefinition.gd b/enemies/EnemyDefinition.gd index 4d16006..514c58a 100644 --- a/enemies/EnemyDefinition.gd +++ b/enemies/EnemyDefinition.gd @@ -31,5 +31,9 @@ extends Resource # check: the chaser rams because ramming is its whole behaviour, while a # sniper that happens to drift into a ship has not spent itself doing it. @export var dies_on_contact: bool = false +# #101: what one strike against a player structure takes off it. A rammer +# spends itself on the hit and so hits hardest; a stand-off enemy that drifts +# into a wall barely scratches it, its real threat being its weapon. +@export var contact_damage: int = 6 @export var core_color: Color = Color(0.06, 0.02, 0.03) @export var component_scenes: Array[PackedScene] = [] diff --git a/enemies/EnemyLungeComponent.gd b/enemies/EnemyLungeComponent.gd index 41f6b02..74cbd62 100644 --- a/enemies/EnemyLungeComponent.gd +++ b/enemies/EnemyLungeComponent.gd @@ -162,18 +162,11 @@ func _face(enemy: Node2D, direction: Vector2, delta: float) -> void: enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta) +# #101: a lancer will lock a structure exactly as it locks a vessel - the +# wind-up, the line and the charge are the same, and so is what happens when +# it arrives. 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.global_position.distance_to(nearest.global_position) - for ship in fleet.ships: - var d: float = enemy.global_position.distance_to(ship.global_position) - if d < best_dist: - best_dist = d - nearest = ship - return nearest + return EnemyTargets.nearest_target(self, enemy.global_position) func _draw() -> void: diff --git a/enemies/EnemyProjectile.gd b/enemies/EnemyProjectile.gd index 88d012d..b9a309b 100644 --- a/enemies/EnemyProjectile.gd +++ b/enemies/EnemyProjectile.gd @@ -37,6 +37,12 @@ func _on_area_entered(area: Area2D) -> void: if area.is_in_group("player_ships"): area.take_damage(damage) queue_free() + return + # #101: structures are targets too. The hitbox is a child Area2D of the + # Building, which is a plain Node2D and could not be hit otherwise. + if area.is_in_group("player_buildings"): + area.take_damage(damage) + queue_free() func _draw() -> void: diff --git a/enemies/EnemyPursuitComponent.gd b/enemies/EnemyPursuitComponent.gd index 6f7ae45..6558c5f 100644 --- a/enemies/EnemyPursuitComponent.gd +++ b/enemies/EnemyPursuitComponent.gd @@ -17,15 +17,18 @@ var velocity: Vector2 = Vector2.ZERO func tick(delta: float) -> void: var enemy: Node2D = get_parent() - var target: Node2D = _nearest_ship(enemy) - if target == null or enemy.position.distance_to(target.position) > detection_range: + # #101: a structure is a target now, not scenery. EnemyTargets keeps a + # ship in the enemy's face ahead of a building further off. + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null \ + or enemy.global_position.distance_to(target.global_position) > detection_range: return - var to_target: Vector2 = target.position - enemy.position + var to_target: Vector2 = target.global_position - enemy.global_position if to_target.length() > 1.0: var desired_velocity: Vector2 = to_target.normalized() * enemy.speed velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta) - enemy.position += velocity * delta + enemy.global_position += velocity * delta if velocity.length() > 5.0: var target_angle: float = velocity.angle() + PI / 2.0 @@ -33,15 +36,4 @@ func tick(delta: float) -> void: 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 + return EnemyTargets.nearest_ship(self, enemy.global_position) diff --git a/enemies/EnemyRangedWeaponComponent.gd b/enemies/EnemyRangedWeaponComponent.gd index 736800b..7820564 100644 --- a/enemies/EnemyRangedWeaponComponent.gd +++ b/enemies/EnemyRangedWeaponComponent.gd @@ -38,17 +38,12 @@ func tick(delta: float) -> void: fire_timer = fire_cooldown +# #101: shoots at whatever EnemyTargets says it is coming for, so a sniper +# shells a depot rather than sitting in range of one doing nothing. func _nearest_ship_in_range(enemy: Node2D) -> Node2D: - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null: + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == 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 + if enemy.global_position.distance_to(target.global_position) > fire_range: + return null + return target diff --git a/enemies/EnemyStandoffComponent.gd b/enemies/EnemyStandoffComponent.gd index 02e96a6..d1fd32e 100644 --- a/enemies/EnemyStandoffComponent.gd +++ b/enemies/EnemyStandoffComponent.gd @@ -21,10 +21,11 @@ var velocity: Vector2 = Vector2.ZERO func tick(delta: float) -> void: var enemy: Node2D = get_parent() - var target: Node2D = _nearest_ship(enemy) + # #101: holds its preferred range off a building just as off a ship. + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) if target == null: return - var to_target: Vector2 = target.position - enemy.position + var to_target: Vector2 = target.global_position - enemy.global_position var distance: float = to_target.length() if distance < 1.0 or distance > detection_range: return @@ -41,7 +42,7 @@ func tick(delta: float) -> void: desired_velocity = lateral * enemy.speed * 0.5 velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta) - enemy.position += velocity * delta + enemy.global_position += velocity * delta if velocity.length() > 5.0: var target_angle: float = velocity.angle() + PI / 2.0 @@ -49,14 +50,4 @@ func tick(delta: float) -> void: 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 + return EnemyTargets.nearest_ship(self, enemy.global_position) diff --git a/enemies/EnemyTargets.gd b/enemies/EnemyTargets.gd new file mode 100644 index 0000000..0e4fcad --- /dev/null +++ b/enemies/EnemyTargets.gd @@ -0,0 +1,65 @@ +class_name EnemyTargets +extends RefCounted + +# #101 (#97c): who a wave enemy is coming for. +# +# Before this, every enemy component asked the same question - "which player +# SHIP is nearest?" - and a structure was scenery they flew straight past. +# #97 makes waves go after what the player has built, so the question +# becomes "which player thing is nearest", asked in one place so pursuit, +# standoff, the lancer's lunge and the ranged weapon cannot drift into +# disagreeing about it. +# +# There is one thumb on the scale. A ship inside SHIP_PRIORITY_RANGE always +# wins, even if a building is nearer: an enemy that ignored the vessels +# shooting it in order to reach a depot two hundred pixels closer would read +# as broken, and would make the fleet unable to defend anything by +# interposing itself. + +const SHIP_PRIORITY_RANGE: float = 520.0 + + +static func nearest_ship(node: Node, from: Vector2) -> Node2D: + var fleet: Node2D = node.get_tree().get_first_node_in_group("player_fleet") + if fleet == null: + return null + var nearest: Node2D = null + var best: float = INF + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var d: float = from.distance_to(ship.global_position) + if d < best: + best = d + nearest = ship + return nearest + + +static func nearest_building(node: Node, from: Vector2) -> Node2D: + var nearest: Node2D = null + var best: float = INF + for building in node.get_tree().get_nodes_in_group("buildings"): + if not is_instance_valid(building) or building.is_queued_for_deletion(): + continue + var d: float = from.distance_to(building.global_position) + if d < best: + best = d + nearest = building + return nearest + + +static func nearest_target(node: Node, from: Vector2) -> Node2D: + var ship: Node2D = nearest_ship(node, from) + if ship != null and from.distance_to(ship.global_position) <= SHIP_PRIORITY_RANGE: + return ship + var building: Node2D = nearest_building(node, from) + if building == null: + return ship + if ship == null: + return building + return ship if from.distance_to(ship.global_position) \ + <= from.distance_to(building.global_position) else building + + +static func is_building(target: Node) -> bool: + return target != null and target.is_in_group("buildings") diff --git a/enemies/LancerEnemyDefinition.tres b/enemies/LancerEnemyDefinition.tres index 40dc2b4..5866263 100644 --- a/enemies/LancerEnemyDefinition.tres +++ b/enemies/LancerEnemyDefinition.tres @@ -17,3 +17,4 @@ detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2) detail_rings = PackedVector2Array(0, 8, 4, 0) dies_on_contact = true component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 22 diff --git a/enemies/MothershipEnemyDefinition.tres b/enemies/MothershipEnemyDefinition.tres index c6bf1d5..c47e042 100644 --- a/enemies/MothershipEnemyDefinition.tres +++ b/enemies/MothershipEnemyDefinition.tres @@ -19,3 +19,4 @@ glow_color = Color(0.55, 0.05, 0.85, 1) detail_lines = PackedVector2Array(0, -44, 0, 40, -28, -18, 28, -18, -34, 4, 34, 4, -20, 28, 20, 28, -12, -40, 12, -40) detail_rings = PackedVector2Array(0, -28, 9, 0, 0, 18, 6, 0) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")]) +contact_damage = 34 diff --git a/enemies/SniperEnemyDefinition.tres b/enemies/SniperEnemyDefinition.tres index 7279029..60a3cb9 100644 --- a/enemies/SniperEnemyDefinition.tres +++ b/enemies/SniperEnemyDefinition.tres @@ -17,3 +17,4 @@ glow_color = Color(0.42, 0.72, 1.0, 1) detail_lines = PackedVector2Array(0, -30, 0, 10, -6, 12, 6, 12) detail_rings = PackedVector2Array(0, -2, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")]) +contact_damage = 4 diff --git a/enemies/TurretSpawnerEnemyDefinition.tres b/enemies/TurretSpawnerEnemyDefinition.tres index 8f554ce..95a8136 100644 --- a/enemies/TurretSpawnerEnemyDefinition.tres +++ b/enemies/TurretSpawnerEnemyDefinition.tres @@ -16,3 +16,4 @@ glow_color = Color(0.68, 0.92, 0.18, 1) detail_lines = PackedVector2Array(-16, -16, 16, 16, 16, -16, -16, 16) detail_rings = PackedVector2Array(0, 0, 13, 0, 0, -22, 5, 0, 0, 22, 5, 0, -22, 0, 5, 0, 22, 0, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) +contact_damage = 5 diff --git a/tests/test_enemy_projectile_damage.gd b/tests/test_enemy_projectile_damage.gd index 2492b68..a6ac454 100644 --- a/tests/test_enemy_projectile_damage.gd +++ b/tests/test_enemy_projectile_damage.gd @@ -42,6 +42,18 @@ func _process(delta: float) -> bool: 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) diff --git a/tests/test_waves_attack_buildings.gd b/tests/test_waves_attack_buildings.gd new file mode 100644 index 0000000..42a7aa4 --- /dev/null +++ b/tests/test_waves_attack_buildings.gd @@ -0,0 +1,204 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_waves_attack_buildings.gd +# Forgejo #101 (#97c): "Waves tries to attack built buildings." +# +# Before this every enemy component asked one 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. +# +# Covers: +# A. an enemy with no ship anywhere near it goes for a building +# B. a ship in its face still wins: the fleet must be able to defend a +# structure by interposing itself, and an enemy that ignored the +# vessels shooting it to reach a depot slightly closer reads as broken +# C. a chaser that reaches a building damages it, and spends itself doing +# so - the same bargain it makes ramming a vessel +# D. an enemy parked on a structure keeps hurting it, rather than landing +# one hit on entry and then sitting there harmlessly +# E. an enemy projectile damages a structure +# F. enough of it levels the building, and the node goes with the record + +var game_state: Node +var main: Node +var fleet: Node +var field: Node +var chaser: Node2D +var sniper: Node2D +var target_building: Node2D +# Held separately: the node is freed when the building comes down, and the +# last stage still has to ask GameState about the record it left behind. +var target_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var hp_watermark: int = 0 +var destroyed_fired: bool = false + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.rocks = 5000 + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + field = main.get_node("BuildingField") + + +func _fail(message: String) -> bool: + print("TEST_WAVES_ATTACK_BUILDINGS: FAIL (%s)" % message) + quit(1) + return true + + +func _spawn_enemy(enemy_id: String, at: Vector2) -> Node2D: + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.enemy_id = enemy_id + enemy.position = at + main.add_child(enemy) + return enemy + + +func _building_of(type: String) -> Node2D: + for b in field.spawned: + if is_instance_valid(b) and not b.is_queued_for_deletion() and b.building_type == type: + return b + return null + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The fleet is parked far away for A, C and D. It comes back for B. + fleet.spawn_ships(1, Vector2(-9000.0, 0.0), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + fleet.ships[0].move_target = fleet.ships[0].position + field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(0.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + target_building = _building_of(GameState.BUILDING_SHIP_CRAFTER) + if target_building == null: + return _fail("no crafter was placed") + target_id = int(target_building.building_id) + + # --- A. with no ship near, a building is the target ---------- + var targets: Script = load("res://enemies/EnemyTargets.gd") + var from: Vector2 = Vector2(600.0, 0.0) + var picked: Node2D = targets.nearest_target(main, from) + if picked != target_building: + return _fail("an enemy 600px from a crafter and 9600px from the fleet picked %s" \ + % ("nothing" if picked == null else str(picked.name))) + print("with the fleet 9600px away, the crafter is the target") + + # --- B. a ship in its face still wins ------------------------ + # Deliberately FURTHER from the enemy than the building is: the + # rule is not "nearest thing", it is "the fleet you are fighting". + var guard: Node2D = fleet.ships[0] + guard.position = from + Vector2(0.0, 500.0) + guard.move_target = guard.position + var defended: Node2D = targets.nearest_target(main, from) + if defended != guard: + return _fail("a vessel 500px away lost priority to a building 600px away - the fleet cannot screen anything") + guard.position = Vector2(-9000.0, 0.0) + guard.move_target = guard.position + print("a vessel inside %.0fpx outranks a nearer building" % targets.SHIP_PRIORITY_RANGE) + + # --- C. a chaser spends itself on the structure -------------- + hp_watermark = game_state.building_hp(target_id) + chaser = _spawn_enemy("chaser", target_building.global_position) + chaser.destroyed.connect(func(): destroyed_fired = true) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 1.2: + return false + var now: int = game_state.building_hp(target_id) + if now >= hp_watermark: + return _fail("a chaser sat on the crafter and it took no damage") + if is_instance_valid(chaser) and not chaser.is_queued_for_deletion(): + return _fail("the chaser survived ramming a structure") + if not destroyed_fired: + return _fail("the chaser vanished without firing `destroyed`") + print("a chaser took the crafter from %d to %d hp and died doing it" % [hp_watermark, now]) + + # --- D. a besieger keeps working ---------------------------- + sniper = _spawn_enemy("sniper", target_building.global_position) + sniper.speed = 0.0 + hp_watermark = game_state.building_hp(target_id) + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 2.5: + return false + var now: int = game_state.building_hp(target_id) + var strikes: int = hp_watermark - now + if not is_instance_valid(sniper): + return _fail("the sniper died on contact - it is not marked dies_on_contact") + if strikes <= sniper.definition.contact_damage: + return _fail("%.1fs of siege did %d damage - that is one hit on entry, not a siege" \ + % [elapsed, strikes]) + print("%.1fs parked on the crafter cost it %d hp" % [elapsed, strikes]) + sniper.queue_free() + + # --- E. a projectile hits a structure ------------------------ + hp_watermark = game_state.building_hp(target_id) + var projectile: Area2D = load("res://enemies/EnemyProjectile.tscn").instantiate() + projectile.position = target_building.global_position + Vector2(0.0, -140.0) + projectile.damage = 3 + root.add_child(projectile) + projectile.launch(Vector2(0.0, 1.0)) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 1.2: + return false + var now: int = game_state.building_hp(target_id) + if now >= hp_watermark: + return _fail("an enemy bolt passed straight through the crafter") + print("an enemy bolt took %d hp off the crafter" % (hp_watermark - now)) + + # --- F. enough of it levels the place ------------------------ + game_state.damage_building(target_id, + game_state.building_hp(target_id) - 1) + var last: Node2D = _spawn_enemy("chaser", target_building.global_position) + last.speed = 0.0 + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 1.2: + return false + if not game_state.find_building(target_id).is_empty(): + return _fail("the crafter survived on its last hit point") + if _building_of(GameState.BUILDING_SHIP_CRAFTER) != null: + return _fail("the crafter node outlived the record") + print("the crafter came down, and its node went with it") + + print("TEST_WAVES_ATTACK_BUILDINGS: PASS") + quit(0) + return true + return false