diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index ca44537..d8ec9f2 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,11 +2,12 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser", "sniper", "artillery"] +const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), "sniper": preload("res://SniperEnemyDefinition.tres"), "artillery": preload("res://ArtilleryEnemyDefinition.tres"), + "turret_spawner": preload("res://TurretSpawnerEnemyDefinition.tres"), } diff --git a/EnemySpawnerComponent.gd b/EnemySpawnerComponent.gd new file mode 100644 index 0000000..70849d1 --- /dev/null +++ b/EnemySpawnerComponent.gd @@ -0,0 +1,55 @@ +class_name EnemySpawnerComponent +extends Node + +# Continuously spawns another enemy type beside its host, at a fixed +# interval, capped at max_alive concurrently. The spawned enemies are +# added as children of the spawner's OWN PARENT - not the spawner itself - +# so killing the spawner never frees its already-spawned children (they +# are siblings, not descendants) and so a surface-hosted spawner keeps its +# spawns under the same host a `../Fleet`-style path assumption would miss +# (see #44's note on why that hazard exists for anything parented under a +# planet surface's PlanetSetupHost rather than directly under Main.tscn). + +var capability_id: String = "spawner" + +@export var spawn_interval: float = 4.0 +@export var spawned_enemy_id: String = "chaser" +@export var max_alive: int = 6 +@export var spawn_radius: float = 90.0 + +var spawn_timer: float = 0.0 +var spawned: Array = [] +var enemy_scene: PackedScene = preload("res://Enemy.tscn") + + +func tick(delta: float) -> void: + spawned = spawned.filter(func(node): return is_instance_valid(node)) + if spawned.size() >= max_alive: + return + + spawn_timer -= delta + if spawn_timer > 0.0: + return + spawn_timer = spawn_interval + + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return + + var spawner: Node2D = get_parent() + var host: Node = spawner.get_parent() + if host == null: + return + + var angle: float = randf() * TAU + var offset: Vector2 = Vector2(cos(angle), sin(angle)) * spawn_radius + + var new_enemy: Node2D = enemy_scene.instantiate() + new_enemy.enemy_id = spawned_enemy_id + # spawner.position is already expressed in `host`'s local space, and + # new_enemy is about to be added to that same `host` - unlike + # EnemyRangedWeaponComponent's projectile, which reparents to root and + # needs global_position, there is no space conversion needed here. + new_enemy.position = spawner.position + offset + host.add_child(new_enemy) + spawned.append(new_enemy) diff --git a/EnemySpawnerComponent.tscn b/EnemySpawnerComponent.tscn new file mode 100644 index 0000000..5121e1b --- /dev/null +++ b/EnemySpawnerComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemySpawnerComponent.gd" id="1"] + +[node name="EnemySpawnerComponent" type="Node"] +script = ExtResource("1") diff --git a/TurretSpawnerEnemyDefinition.tres b/TurretSpawnerEnemyDefinition.tres new file mode 100644 index 0000000..59e000f --- /dev/null +++ b/TurretSpawnerEnemyDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://EnemySpawnerComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +enemy_id = "turret_spawner" +display_name = "Turret Spawner" +max_hp = 20 +speed = 0.0 +acceleration = 0.0 +rotation_speed = 0.0 +hull_points = PackedVector2Array(0, -32, 22.6, -22.6, 32, 0, 22.6, 22.6, 0, 32, -22.6, 22.6, -32, 0, -22.6, -22.6) +glow_color = Color(0.68, 0.92, 0.18, 1) +component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/tests/_turret_vs_chaser_preview.gd b/tests/_turret_vs_chaser_preview.gd new file mode 100644 index 0000000..42b009f --- /dev/null +++ b/tests/_turret_vs_chaser_preview.gd @@ -0,0 +1,60 @@ +extends Node2D + +# Preview, not a test - #52's "visually distinguishable from a chaser" +# acceptance criterion. Same setup shape as _sniper_vs_chaser_preview.gd - +# see that file's header note for why setup is deferred to the first +# _process() tick and the tree gets paused once everything is placed. +# +# bash tests/screenshot.sh res://tests/_turret_vs_chaser_preview.tscn /tmp/turret.png 20 720x1280 + +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) + 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 turret: Node = load("res://Enemy.tscn").instantiate() + turret.enemy_id = "turret_spawner" + turret.position = Vector2(160, -80) + main.add_child(turret) + + var camera: Camera2D = main.get_node("Camera2D") + camera.zoom = Vector2(1.4, 1.4) + + get_tree().paused = true diff --git a/tests/_turret_vs_chaser_preview.tscn b/tests/_turret_vs_chaser_preview.tscn new file mode 100644 index 0000000..a318c94 --- /dev/null +++ b/tests/_turret_vs_chaser_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_turret_vs_chaser_preview.gd" id="1"] + +[node name="TurretVsChaserPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_turret_spawner_enemy.gd b/tests/test_turret_spawner_enemy.gd new file mode 100644 index 0000000..c4ffa7b --- /dev/null +++ b/tests/test_turret_spawner_enemy.gd @@ -0,0 +1,192 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_turret_spawner_enemy.gd +# Forgejo #52: a stationary, high-HP enemy that continuously spawns chasers +# beside itself. Verifies: +# 1. registered in EnemyDefinitions - the map editor palette (#48) is +# generated from EnemyDefinitions.all(), so this alone is what makes +# it placeable, with no editor code change +# 2. spawns nothing while there is no fleet (or an empty one) +# 3. once a fleet exists, spawns one enemy per spawn_interval, stopping +# at max_alive +# 4. a spawn dying frees its slot, and the next interval fills it again +# 5. the turret itself never moves +# 6. survives 19 damage, dies on the 20th +# 7. its already-spawned children survive its death - they are siblings +# under its own parent, not descendants, so killing it can't free them +# +# spawn_interval/max_alive are overridden to small test-friendly values +# directly on the instantiated component, the same way tests/ +# _artillery_telegraph_rocky_preview.gd pins ArtilleryShell's telegraph_time - +# the definition's real defaults (4.0s / 6 alive) are only asserted once, +# structurally, before the override. + +var game_state: Node +var main: Node +var fleet: Node +var turret: Node +var spawner: Node +var stage: int = 0 +var elapsed: float = 0.0 +var turret_start_position: Vector2 +var one_spawn_ref: Node + + +func _initialize() -> void: + game_state = root.get_node("GameState") + + # --- 1. registered, with the definition's real defaults intact --- + if not EnemyDefinitions.ORDER.has("turret_spawner"): + _fail("turret_spawner is not in EnemyDefinitions.ORDER - the map editor palette would never show it") + return + var definition: Resource = EnemyDefinitions.get_definition("turret_spawner") + if definition.max_hp != 20: + _fail("turret_spawner max_hp is %d, expected 20 (a decent amount of life)" % definition.max_hp) + return + if definition.speed != 0.0: + _fail("turret_spawner speed is %s, expected 0.0 (stationary)" % definition.speed) + return + + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + # This has to happen here, not in _initialize() - a node's + # set_process(false) call made before it has fully finished + # entering the tree doesn't reliably stick, since the engine's + # own deferred tree-entry finalization can reset it back to + # enabled. By now _ready() cascades are confirmed complete. + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + # BuildController._check_fleet_wipe() runs every _process() tick + # and rebuilds a single ship the moment fleet.ships is empty - + # the exact "empty fleet" state stage 2 needs to hold for a while. + main.get_node("BuildController").set_process(false) + + # Empty the fleet (gotcha: spawn_ships(0,...) still leaves one + # default interceptor - clear explicitly). + for ship in fleet.ships: + if is_instance_valid(ship): + ship.queue_free() + fleet.ships.clear() + + turret = load("res://Enemy.tscn").instantiate() + turret.enemy_id = "turret_spawner" + turret.position = Vector2(500, 500) + turret_start_position = turret.position + main.add_child(turret) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.15: + return false + spawner = turret.get_component("spawner") + if spawner == null: + return _fail("turret_spawner has no 'spawner' capability component") + # --- 2. no fleet -> no spawns, even past several intervals --- + spawner.spawn_interval = 0.1 + spawner.max_alive = 3 + spawner.spawn_radius = 50.0 + # spawn_timer already started counting down from the definition's + # real default (4.0s) on the component's very first tick, before + # this override could reach it - changing spawn_interval alone + # only affects the NEXT reset, not the countdown already running. + # Reset it explicitly so the override takes effect immediately. + spawner.spawn_timer = 0.0 + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.6: + return false + if spawner.spawned.size() > 0: + return _fail("spawner spawned %d enemies with no fleet present" % spawner.spawned.size()) + + # --- give it a fleet to spawn against --- + fleet.spawn_ships(1, Vector2(-2000, -2000)) + elapsed = 0.0 + stage = 3 + return false + 3: + # --- 3. spawns, capped at max_alive --- + if elapsed < spawner.spawn_interval * (spawner.max_alive + 2) + 0.3: + return false + if spawner.spawned.size() != spawner.max_alive: + return _fail("spawner has %d alive after plenty of time, expected max_alive=%d" % [ + spawner.spawned.size(), spawner.max_alive + ]) + for spawn in spawner.spawned: + if spawn.enemy_id != "chaser": + return _fail("a spawn has enemy_id '%s', expected 'chaser'" % spawn.enemy_id) + if spawn.get_parent() != turret.get_parent(): + return _fail("a spawn is not parented under the turret's own parent - it would not survive the turret's death correctly") + + # --- 5. never moves --- + if turret.position != turret_start_position: + return _fail("turret_spawner moved from %s to %s" % [turret_start_position, turret.position]) + + # --- 4. a dead spawn frees its slot and gets refilled --- + one_spawn_ref = spawner.spawned[0] + one_spawn_ref.take_damage(999) + elapsed = 0.0 + stage = 4 + return false + 4: + # --- 4. a dead spawn frees its slot, and the interval refills it --- + # spawn_interval is short enough (0.1s, overridden above) that the + # refill can land within a couple of frames of the kill - there is + # no reliably observable moment where the count sits at max_alive-1 + # and stays there, so this checks the net effect once things have + # settled: the killed spawn specifically is gone, and a fresh one + # took its slot back up to max_alive, rather than checking for an + # intermediate dip that a fast interval can blow straight through. + if elapsed < spawner.spawn_interval + 0.3: + return false + if is_instance_valid(one_spawn_ref): + return _fail("killed spawn is still valid after take_damage(999)") + if spawner.spawned.has(one_spawn_ref): + return _fail("the killed spawn is still tracked in spawner.spawned") + if spawner.spawned.size() != spawner.max_alive: + return _fail("spawner did not refill the freed slot (has %d, expected %d)" % [ + spawner.spawned.size(), spawner.max_alive + ]) + + # --- 6 + 7. damage, death, and spawns surviving the death --- + var surviving_spawns: Array = spawner.spawned.duplicate() + turret.take_damage(19) + if not is_instance_valid(turret): + return _fail("turret_spawner died from 19 damage - should take 20 to kill") + turret.take_damage(1) + elapsed = 0.0 + stage = 6 + one_spawn_ref = surviving_spawns[0] + return false + 6: + if elapsed < 0.15: + return false + if is_instance_valid(turret): + return _fail("turret_spawner survived 20 total damage") + if not is_instance_valid(one_spawn_ref): + return _fail("a spawn was freed when its spawner died - they are siblings, not descendants") + if not one_spawn_ref.has_capability("pursuit"): + return _fail("a surviving spawn lost its pursuit component - it should still chase the fleet") + + print("TEST_TURRET_SPAWNER_ENEMY: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_TURRET_SPAWNER_ENEMY: FAIL (%s)" % message) + quit(1) + return true