diff --git a/EnemyBigLaserBeam.gd b/EnemyBigLaserBeam.gd new file mode 100644 index 0000000..3478536 --- /dev/null +++ b/EnemyBigLaserBeam.gd @@ -0,0 +1,106 @@ +class_name EnemyBigLaserBeam +extends Node2D + +# Self-contained charge -> fire -> flash sequence for the mothership's big +# laser (see EnemyBigLaserComponent). Lives independently of the enemy that +# fired it once launched - added to the tree root, exactly like +# ArtilleryShell/EnemyProjectile - so the mothership dying mid-charge +# doesn't cancel a shot already telegraphed to the player. +# +# The aim direction is locked once, in launch(), and never touched again: +# that is what makes the telegraph dodgeable. Nothing in _process() re-reads +# the fleet's position while charging. +# +# Damage: every ship in group "player_ships" whose perpendicular distance +# to the beam segment is within beam_half_width, AND whose projection along +# the beam falls inside [0, beam_range], takes Ship.take_damage(damage) - +# same all-or-nothing shield semantics as every other damage source in this +# game (Ship.take_damage already handles the absorb_hit check itself). + +signal finished + +@export var charge_time: float = 2.5 +@export var beam_range: float = 1200.0 +@export var beam_half_width: float = 35.0 +@export var damage: int = 3 +@export var color: Color = Color(0.55, 0.05, 0.85) + +enum Phase { CHARGING, FLASH } +const FLASH_DURATION: float = 0.25 + +var phase: int = Phase.CHARGING +var origin: Vector2 = Vector2.ZERO +var aim_direction: Vector2 = Vector2.RIGHT +var charge_left: float = 0.0 +var flash_time: float = 0.0 + + +func launch(from: Vector2, toward: Vector2) -> void: + origin = from + aim_direction = toward.normalized() if toward.length() > 0.001 else Vector2.RIGHT + position = origin + rotation = aim_direction.angle() + charge_left = charge_time + phase = Phase.CHARGING + queue_redraw() + + +func _process(delta: float) -> void: + match phase: + Phase.CHARGING: + charge_left -= delta + if charge_left <= 0.0: + _fire() + queue_redraw() + Phase.FLASH: + flash_time -= delta + queue_redraw() + if flash_time <= 0.0: + finished.emit() + queue_free() + + +func _fire() -> void: + phase = Phase.FLASH + flash_time = FLASH_DURATION + for ship in get_tree().get_nodes_in_group("player_ships"): + if not is_instance_valid(ship): + continue + var to_ship: Vector2 = ship.global_position - origin + var along: float = to_ship.dot(aim_direction) + if along < 0.0 or along > beam_range: + continue + var perpendicular: float = (to_ship - aim_direction * along).length() + if perpendicular <= beam_half_width: + ship.take_damage(damage) + queue_redraw() + + +func _draw() -> void: + # Drawn in local space, rotated in launch() so local +X already points + # along the locked aim_direction - the same reason the beam segment's + # own math is done in world space up in _fire() (global_position/origin) + # while _draw() only ever needs the length along that local +X axis. + match phase: + Phase.CHARGING: + var progress: float = clampf(1.0 - charge_left / maxf(charge_time, 0.001), 0.0, 1.0) + var width: float = lerpf(4.0, beam_half_width * 2.0, progress) + _draw_beam_line(width, 0.65) + # Countdown arc at the origin, the same idiom as + # ArtilleryShell's growing-ring-plus-arc telegraph. + draw_arc(Vector2.ZERO, 40.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 32, + Color(color, 0.85), 8.0, true) + Phase.FLASH: + var fprogress: float = 1.0 - flash_time / FLASH_DURATION + var width: float = lerpf(beam_half_width * 2.2, 6.0, fprogress) + _draw_beam_line(width, 1.0 - fprogress * 0.3) + + +func _draw_beam_line(width: float, alpha: float) -> void: + # Three-layer glow along local +X - the same soft-halo / core / bright- + # hairline structure as ArtilleryShell._draw_ring, adapted from a ring + # to a straight line. + var to: Vector2 = Vector2(beam_range, 0.0) + draw_line(Vector2.ZERO, to, Color(color, 0.16 * alpha), width * 2.6, true) + draw_line(Vector2.ZERO, to, Color(color, 0.5 * alpha), width, true) + draw_line(Vector2.ZERO, to, Color(1.0, 0.92, 0.98, 0.9 * alpha), width * 0.35, true) diff --git a/EnemyBigLaserBeam.tscn b/EnemyBigLaserBeam.tscn new file mode 100644 index 0000000..635ceb5 --- /dev/null +++ b/EnemyBigLaserBeam.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyBigLaserBeam.gd" id="1"] + +[node name="EnemyBigLaserBeam" type="Node2D"] +script = ExtResource("1") diff --git a/EnemyBigLaserComponent.gd b/EnemyBigLaserComponent.gd new file mode 100644 index 0000000..11f36b7 --- /dev/null +++ b/EnemyBigLaserComponent.gd @@ -0,0 +1,43 @@ +class_name EnemyBigLaserComponent +extends Node + +# Fires a heavily telegraphed EnemyBigLaserBeam at the fleet's current +# centroid on a long cooldown - the beam itself owns the whole charge/fire/ +# flash sequence once launched, same "survives the shooter dying" design as +# EnemyArtilleryComponent's ArtilleryShell. + +var capability_id: String = "big_laser" + +@export var charge_time: float = 2.5 +@export var fire_cooldown: float = 9.0 +@export var beam_range: float = 1200.0 +@export var beam_half_width: float = 35.0 +@export var damage: int = 3 +@export var color: Color = Color(0.55, 0.05, 0.85) + +var cooldown_left: float = 0.0 +var beam_scene: PackedScene = preload("res://EnemyBigLaserBeam.tscn") + + +func tick(delta: float) -> void: + cooldown_left -= delta + if cooldown_left > 0.0: + return + + var enemy: Node2D = get_parent() + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return + var direction: Vector2 = fleet.get_centroid() - enemy.global_position + if direction.length() < 1.0: + return + + var beam: Node2D = beam_scene.instantiate() + beam.charge_time = charge_time + beam.beam_range = beam_range + beam.beam_half_width = beam_half_width + beam.damage = damage + beam.color = color + enemy.get_tree().root.add_child(beam) + beam.launch(enemy.global_position, direction) + cooldown_left = fire_cooldown diff --git a/EnemyBigLaserComponent.tscn b/EnemyBigLaserComponent.tscn new file mode 100644 index 0000000..8129ea2 --- /dev/null +++ b/EnemyBigLaserComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyBigLaserComponent.gd" id="1"] + +[node name="EnemyBigLaserComponent" type="Node"] +script = ExtResource("1") diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index d8ec9f2..fb5fd15 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,12 +2,13 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner"] +const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), "sniper": preload("res://SniperEnemyDefinition.tres"), "artillery": preload("res://ArtilleryEnemyDefinition.tres"), "turret_spawner": preload("res://TurretSpawnerEnemyDefinition.tres"), + "enemy_mothership": preload("res://MothershipEnemyDefinition.tres"), } diff --git a/EnemySpawnerComponent.gd b/EnemySpawnerComponent.gd index 70849d1..886f52a 100644 --- a/EnemySpawnerComponent.gd +++ b/EnemySpawnerComponent.gd @@ -19,7 +19,28 @@ var capability_id: String = "spawner" var spawn_timer: float = 0.0 var spawned: Array = [] -var enemy_scene: PackedScene = preload("res://Enemy.tscn") +# Resolved lazily via load() on first use rather than preload()d as a +# compile-time constant: when a definition's component_scenes chain leads +# BACK to Enemy.tscn while it is still mid-load (e.g. two different preset +# scenes of this same script both live on one definition, as the +# mothership's chaser/artillery spawners are), the preloaded constant can +# get permanently baked in as a broken, empty PackedScene - confirmed by +# ResourceLoader.has_cached() being true (a good copy exists) while the +# preloaded reference's own resource_path was empty. load() at first real +# use instead always resolves against whatever is actually in the cache +# by then. +var enemy_scene: PackedScene = null + + +func _ready() -> void: + # Start counting down from a full interval rather than firing on the + # very first tick: besides reading more naturally (a freshly-placed + # spawner doesn't insta-spawn), this keeps two sibling spawners with + # different intervals (e.g. the mothership's chaser/artillery presets) + # from both landing their first attempt on the exact same physics + # frame - which hit a transient PackedScene.instantiate() failure on + # this engine build when it happened. + spawn_timer = spawn_interval func tick(delta: float) -> void: @@ -44,6 +65,8 @@ func tick(delta: float) -> void: var angle: float = randf() * TAU var offset: Vector2 = Vector2(cos(angle), sin(angle)) * spawn_radius + if enemy_scene == null: + enemy_scene = load("res://Enemy.tscn") 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 diff --git a/EnemySpawnerComponentArtillery.tscn b/EnemySpawnerComponentArtillery.tscn new file mode 100644 index 0000000..9b72198 --- /dev/null +++ b/EnemySpawnerComponentArtillery.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemySpawnerComponent.gd" id="1"] + +[node name="EnemySpawnerComponentArtillery" type="Node"] +script = ExtResource("1") +spawn_interval = 18.0 +spawned_enemy_id = "artillery" +max_alive = 2 +spawn_radius = 130.0 diff --git a/EnemySpawnerComponentChaser.tscn b/EnemySpawnerComponentChaser.tscn new file mode 100644 index 0000000..582f7ce --- /dev/null +++ b/EnemySpawnerComponentChaser.tscn @@ -0,0 +1,10 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemySpawnerComponent.gd" id="1"] + +[node name="EnemySpawnerComponentChaser" type="Node"] +script = ExtResource("1") +spawn_interval = 5.0 +spawned_enemy_id = "chaser" +max_alive = 6 +spawn_radius = 110.0 diff --git a/MothershipEnemyDefinition.tres b/MothershipEnemyDefinition.tres new file mode 100644 index 0000000..cd03144 --- /dev/null +++ b/MothershipEnemyDefinition.tres @@ -0,0 +1,19 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=6 format=3] + +[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"] +[ext_resource type="PackedScene" path="res://EnemySpawnerComponentChaser.tscn" id="3"] +[ext_resource type="PackedScene" path="res://EnemySpawnerComponentArtillery.tscn" id="4"] +[ext_resource type="PackedScene" path="res://EnemyBigLaserComponent.tscn" id="5"] + +[resource] +script = ExtResource("1") +enemy_id = "enemy_mothership" +display_name = "Mothership" +max_hp = 60 +speed = 35.0 +acceleration = 45.0 +rotation_speed = 1.5 +hull_points = PackedVector2Array(0, -50, 35, -25, 48, 10, 20, 48, -20, 48, -48, 10, -35, -25) +glow_color = Color(0.55, 0.05, 0.85, 1) +component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3"), ExtResource("4"), ExtResource("5")]) diff --git a/tests/_mothership_beam_preview.gd b/tests/_mothership_beam_preview.gd new file mode 100644 index 0000000..cf3637a --- /dev/null +++ b/tests/_mothership_beam_preview.gd @@ -0,0 +1,48 @@ +extends Node2D + +# Preview, not a test - #53's "telegraph visible mid-countdown" screenshot. +# Freezes an EnemyBigLaserBeam partway through its charge, aimed across the +# fleet, so the widening beam-line telegraph is clearly visible. +# +# bash tests/screenshot.sh res://tests/_mothership_beam_preview.tscn /tmp/beam.png 20 720x1280 + +var main: Node + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.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) + + var fleet: Node = main.get_node("Fleet") + for ship in fleet.ships: + if is_instance_valid(ship): + ship.set_physics_process(false) + + var mothership: Node = load("res://Enemy.tscn").instantiate() + mothership.enemy_id = "enemy_mothership" + mothership.position = Vector2(-500, -300) + main.add_child(mothership) + + var beam: Node2D = load("res://EnemyBigLaserBeam.tscn").instantiate() + beam.charge_time = 10.0 # frozen mid-countdown for the screenshot + beam.beam_range = 1400.0 + beam.beam_half_width = 35.0 + main.add_child(beam) + beam.launch(mothership.position, fleet.get_centroid() - mothership.position) + beam.charge_left = beam.charge_time * 0.55 + beam.set_process(false) + beam.queue_redraw() + + var camera: Camera2D = main.get_node("Camera2D") + camera.position = (mothership.position + fleet.get_centroid()) * 0.5 + camera.zoom = Vector2(0.7, 0.7) + + get_tree().paused = true diff --git a/tests/_mothership_beam_preview.tscn b/tests/_mothership_beam_preview.tscn new file mode 100644 index 0000000..e176d07 --- /dev/null +++ b/tests/_mothership_beam_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_mothership_beam_preview.gd" id="1"] + +[node name="MothershipBeamPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_enemy_mothership.gd b/tests/test_enemy_mothership.gd new file mode 100644 index 0000000..e55f778 --- /dev/null +++ b/tests/test_enemy_mothership.gd @@ -0,0 +1,250 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_enemy_mothership.gd +# Forgejo #53: a big, slow capital enemy that spawns chasers/artillery on +# their own intervals and fires a heavily telegraphed line beam. +# +# Split into two independent parts, each with its own fleet setup: +# A. EnemyBigLaserBeam mechanics, tested directly (not through the +# component's own tick/cooldown timing, which would make the precise +# geometry assertions below timing-fragile): +# 1. the telegraph holds for the charge - no damage, phase stays +# CHARGING, for most of charge_time +# 2. the locked aim direction never changes even if the fleet moves +# away mid-charge +# 3. three ships in a line all die from one shot; a ship that steps +# outside beam_half_width before the shot lands survives +# 4. a shielded ship in the beam survives, losing exactly one charge +# B. The composed enemy_mothership definition, tested through a real +# Enemy.tscn instance: +# 5. structural composition - pursuit, two DISTINCT spawner presets +# (chaser/artillery), big_laser, no type switch anywhere +# 6. it launches both chaser and artillery spawns on their own +# intervals +# 7. it closes on the fleet without ever exceeding its own defined +# speed - "never reaches ramming speed" +# +# Repo-wide lessons from #52, both apply again here: +# - main.get_node(...).set_process(false) has to happen in _process()'s +# first real tick, not _initialize() - a disable made before the node +# has finished entering the tree doesn't reliably stick. +# - overriding an @export interval on an already-ticking component does +# NOT reset its in-progress countdown - reset the timer variable too. + +var game_state: Node +var main: Node +var fleet: Node +var beam_script: Script +var beam: Node2D +var line_a: Node +var line_b: Node +var line_c: Node +var shield_ship: Node +var initial_aim: Vector2 +var mothership: Node +var chaser_spawner: Node +var artillery_spawner: Node +var mothership_start_position: Vector2 +var closing_elapsed: float = 0.0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + beam_script = load("res://EnemyBigLaserBeam.gd") + 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 + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + main.get_node("BuildController").set_process(false) + for ship in fleet.ships: + if is_instance_valid(ship): + ship.queue_free() + fleet.ships.clear() + + # --- A: line-up three ships along +X, one shielded further out --- + fleet.add_ship(game_state.VESSEL_INTERCEPTOR) + line_a = fleet.ships[fleet.ships.size() - 1] + _pin(line_a, Vector2(300, 0)) + fleet.add_ship(game_state.VESSEL_INTERCEPTOR) + line_b = fleet.ships[fleet.ships.size() - 1] + _pin(line_b, Vector2(400, 0)) + fleet.add_ship(game_state.VESSEL_INTERCEPTOR) + line_c = fleet.ships[fleet.ships.size() - 1] + _pin(line_c, Vector2(500, 0)) + fleet.add_ship(game_state.VESSEL_SHIELD) + shield_ship = fleet.ships[fleet.ships.size() - 1] + _pin(shield_ship, Vector2(600, 0)) + if not shield_ship.has_shield(): + return _fail("shield ship has no shield right after being added") + + var beam_scene: PackedScene = load("res://EnemyBigLaserBeam.tscn") + beam = beam_scene.instantiate() + beam.charge_time = 0.5 + beam.beam_range = 1000.0 + beam.beam_half_width = 35.0 + beam.damage = 3 + root.add_child(beam) + beam.launch(Vector2.ZERO, Vector2(1, 0)) + initial_aim = beam.aim_direction + elapsed = 0.0 + stage = 1 + return false + 1: + # --- 1. telegraph holds, no damage, for most of the charge --- + if elapsed < beam.charge_time * 0.6: + return false + if beam.phase != beam_script.Phase.CHARGING: + return _fail("beam left CHARGING before charge_time elapsed") + for ship in [line_a, line_b, line_c, shield_ship]: + if not is_instance_valid(ship): + return _fail("a ship in the beam's path died before the charge finished") + + # Move line_b out of the beam's path - this alone also shifts + # fleet.get_centroid() (line_b is part of the same fleet), which + # is exactly the "the fleet moved mid-charge" event stage 2 + # needs to prove the beam does NOT re-track. line_a/line_c/ + # shield_ship stay exactly where they were. + _pin(line_b, Vector2(400, 400)) + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < beam.charge_time * 0.6: + return false + if beam.aim_direction != initial_aim: + return _fail("beam re-tracked the fleet mid-charge - aim_direction changed from %s to %s" % [ + initial_aim, beam.aim_direction + ]) + elapsed = 0.0 + stage = 3 + return false + 3: + # --- 3 + 4. the shot has landed by now --- + if is_instance_valid(line_a): + return _fail("line_a (still in the beam) survived the shot") + if is_instance_valid(line_c): + return _fail("line_c (still in the beam) survived the shot") + if not is_instance_valid(line_b): + return _fail("line_b (moved out of the beam before it fired) was killed anyway") + if not is_instance_valid(shield_ship): + return _fail("the shielded ship died despite having a shield") + if shield_ship.shield_charges != 0: + return _fail("shield charges did not drop to exactly 1 lost (charges=%d)" % shield_ship.shield_charges) + + # --- B: reset to a clean single-ship fleet for the mothership --- + for ship in fleet.ships: + if is_instance_valid(ship): + ship.queue_free() + fleet.ships.clear() + fleet.spawn_ships(1, Vector2(3000, 0)) + + mothership = load("res://Enemy.tscn").instantiate() + mothership.enemy_id = "enemy_mothership" + mothership.position = Vector2.ZERO + main.add_child(mothership) + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.15: + return false + # --- 5. structural composition, no type switch --- + if mothership.max_hp != 60: + return _fail("enemy_mothership max_hp is %d, expected 60" % mothership.max_hp) + if mothership.speed != 35.0: + return _fail("enemy_mothership speed is %s, expected 35.0" % mothership.speed) + if not mothership.has_capability("pursuit"): + return _fail("enemy_mothership has no pursuit component - it should close on the fleet") + if not mothership.has_capability("big_laser"): + return _fail("enemy_mothership has no big_laser component") + + var spawners: Array = [] + for child in mothership.get_children(): + if child.get("capability_id") == "spawner": + spawners.append(child) + if spawners.size() != 2: + return _fail("enemy_mothership has %d spawner components, expected 2 (chaser + artillery)" % spawners.size()) + for spawner in spawners: + if spawner.spawned_enemy_id == "chaser": + chaser_spawner = spawner + elif spawner.spawned_enemy_id == "artillery": + artillery_spawner = spawner + if chaser_spawner == null or artillery_spawner == null: + return _fail("the two spawner components are not one chaser + one artillery preset") + + # Speed up both intervals for the test, resetting the in-progress + # timer too (#52's lesson: changing the interval alone does not + # touch a countdown already running from the definition default). + # Staggered slightly rather than both at 0.0: two sibling + # spawners both instantiating Enemy.tscn on the exact same + # physics frame hit a transient PackedScene.instantiate() + # failure ("node count is 0") on this engine build - spreading + # their first fire across different frames avoids it, and is + # also what naturally happens in real play anyway. + chaser_spawner.spawn_interval = 0.2 + chaser_spawner.spawn_timer = 0.0 + artillery_spawner.spawn_interval = 0.3 + artillery_spawner.spawn_timer = 0.05 + + mothership_start_position = mothership.position + elapsed = 0.0 + closing_elapsed = 0.0 + stage = 5 + return false + 5: + # --- 6. launches both chasers and artillery on their own intervals --- + closing_elapsed += delta + if elapsed < 1.2: + return false + if chaser_spawner.spawned.is_empty(): + return _fail("mothership never spawned a chaser") + if artillery_spawner.spawned.is_empty(): + return _fail("mothership never spawned an artillery") + for spawn in chaser_spawner.spawned: + if spawn.enemy_id != "chaser": + return _fail("a chaser-spawner spawn has enemy_id '%s'" % spawn.enemy_id) + for spawn in artillery_spawner.spawned: + if spawn.enemy_id != "artillery": + return _fail("an artillery-spawner spawn has enemy_id '%s'" % spawn.enemy_id) + + # --- 7. closes on the fleet, never exceeding its own speed --- + var traveled: float = mothership.position.distance_to(mothership_start_position) + var max_possible: float = mothership.speed * closing_elapsed + 5.0 # small integration slack + if traveled > max_possible: + return _fail("mothership traveled %.1f in %.2fs, more than speed(%.1f) allows (%.1f)" % [ + traveled, closing_elapsed, mothership.speed, max_possible + ]) + var distance_to_fleet: float = mothership.position.distance_to(fleet.get_centroid()) + var starting_distance: float = mothership_start_position.distance_to(Vector2(3000, 0)) + if distance_to_fleet >= starting_distance: + return _fail("mothership did not close on the fleet at all (distance %.1f >= starting %.1f)" % [ + distance_to_fleet, starting_distance + ]) + + print("TEST_ENEMY_MOTHERSHIP: PASS") + quit(0) + return true + return false + + +func _pin(ship: Node, at: Vector2) -> void: + ship.position = at + ship.move_target = at + + +func _fail(message: String) -> bool: + print("TEST_ENEMY_MOTHERSHIP: FAIL (%s)" % message) + quit(1) + return true