diff --git a/ArtilleryEnemyDefinition.tres b/ArtilleryEnemyDefinition.tres new file mode 100644 index 0000000..f47c597 --- /dev/null +++ b/ArtilleryEnemyDefinition.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://EnemyArtilleryComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +enemy_id = "artillery" +display_name = "Artillery" +max_hp = 8 +speed = 60.0 +acceleration = 120.0 +rotation_speed = 3.0 +hull_points = PackedVector2Array(0, -26, 18.4, -18.4, 26, 0, 18.4, 18.4, 0, 26, -18.4, 18.4, -26, 0, -18.4, -18.4) +glow_color = Color(1.0, 0.5, 0.2, 1) +component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/ArtilleryShell.gd b/ArtilleryShell.gd new file mode 100644 index 0000000..2c8b1f9 --- /dev/null +++ b/ArtilleryShell.gd @@ -0,0 +1,112 @@ +class_name ArtilleryShell +extends Node2D + +# Self-contained telegraph -> travel -> explode sequence for the artillery +# enemy's attack (see EnemyArtilleryComponent). Lives independently of the +# enemy that fired it once launched - added to the tree root, exactly like +# EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't +# cancel a shot already telegraphed to the player. +# +# Shield interaction: a shield charge absorbs this hit entirely, same +# all-or-nothing semantics as every other damage source in this game +# (VesselShieldComponent.absorb_hit) - _explode() just calls +# Ship.take_damage() like everything else and lets that logic decide. + +signal finished + +@export var telegraph_time: float = 1.8 +@export var shell_speed: float = 180.0 +@export var aoe_radius: float = 220.0 +@export var damage: int = 3 +@export var color: Color = Color(1.0, 0.5, 0.2) + +enum Phase { TELEGRAPHING, TRAVELING, EXPLODING } + +const EXPLOSION_DURATION: float = 0.8 + +var phase: int = Phase.TELEGRAPHING +var target: Vector2 = Vector2.ZERO +var shell_pos: Vector2 = Vector2.ZERO +var telegraph_left: float = 0.0 +var explosion_time: float = 0.0 +var trail: PackedVector2Array = PackedVector2Array() + + +func _ready() -> void: + add_to_group("artillery_shells") + + +func launch(from: Vector2, at: Vector2) -> void: + target = at + shell_pos = from + telegraph_left = telegraph_time + phase = Phase.TELEGRAPHING + position = target + queue_redraw() + + +func _process(delta: float) -> void: + match phase: + Phase.TELEGRAPHING: + telegraph_left -= delta + if telegraph_left <= 0.0: + phase = Phase.TRAVELING + trail = PackedVector2Array([shell_pos]) + queue_redraw() + Phase.TRAVELING: + var to_target: Vector2 = target - shell_pos + var step: float = shell_speed * delta + if to_target.length() <= step: + shell_pos = target + _explode() + else: + shell_pos += to_target.normalized() * step + trail.append(shell_pos) + while trail.size() > 14: + trail.remove_at(0) + position = shell_pos + queue_redraw() + Phase.EXPLODING: + explosion_time -= delta + queue_redraw() + if explosion_time <= 0.0: + finished.emit() + queue_free() + + +func _explode() -> void: + phase = Phase.EXPLODING + explosion_time = EXPLOSION_DURATION + position = target + for ship in get_tree().get_nodes_in_group("player_ships"): + if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius: + ship.take_damage(damage) + + +func _draw() -> void: + match phase: + Phase.TELEGRAPHING: + var progress: float = clampf(1.0 - telegraph_left / maxf(telegraph_time, 0.001), 0.0, 1.0) + # Growing ring plus a filling countdown arc: the warning reads + # clearly without needing a number on screen. + var radius: float = lerpf(24.0, aoe_radius, progress) + _draw_ring(radius, 0.7) + draw_arc(Vector2.ZERO, aoe_radius + 14.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, + Color(color, 0.85), 8.0, true) + Phase.TRAVELING: + for i in range(1, trail.size()): + var alpha: float = float(i) / trail.size() + draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true) + draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35)) + NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0) + Phase.EXPLODING: + var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION + var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0)) + draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18)) + _draw_ring(eradius, 1.0 - eprogress) + + +func _draw_ring(radius: float, alpha: float) -> void: + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(1.0, 0.92, 0.86, 0.9 * alpha), 2.0, true) diff --git a/ArtilleryShell.gd.uid b/ArtilleryShell.gd.uid new file mode 100644 index 0000000..cfd76b3 --- /dev/null +++ b/ArtilleryShell.gd.uid @@ -0,0 +1 @@ +uid://cvxao0mjslo5x diff --git a/ArtilleryShell.tscn b/ArtilleryShell.tscn new file mode 100644 index 0000000..17ff161 --- /dev/null +++ b/ArtilleryShell.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ArtilleryShell.gd" id="1"] + +[node name="ArtilleryShell" type="Node2D"] +script = ExtResource("1") diff --git a/EnemyArtilleryComponent.gd b/EnemyArtilleryComponent.gd new file mode 100644 index 0000000..43250c3 --- /dev/null +++ b/EnemyArtilleryComponent.gd @@ -0,0 +1,61 @@ +class_name EnemyArtilleryComponent +extends Node + +# Stationary siege piece: drifts slowly toward the fleet (deliberately no +# pursuit component - closing distance is incidental, not its purpose) and +# fires a slow, heavily telegraphed AOE shell on a long cooldown via a +# standalone ArtilleryShell, so an already-launched shot survives this +# enemy dying mid-flight, same as EnemyProjectile/Projectile already do. + +var capability_id: String = "artillery" + +@export var fire_cooldown: float = 6.0 +@export var fire_range: float = 900.0 +@export var telegraph_time: float = 1.8 +@export var shell_speed: float = 180.0 +@export var aoe_radius: float = 220.0 +@export var damage: int = 3 +@export var drift_speed: float = 20.0 +@export var color: Color = Color(1.0, 0.5, 0.2) + +var cooldown_left: float = 0.0 +var shell_scene: PackedScene = preload("res://ArtilleryShell.tscn") + + +func tick(delta: float) -> void: + var enemy: Node2D = get_parent() + _drift(enemy, delta) + + cooldown_left -= delta + if cooldown_left > 0.0: + return + + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return + var centroid: Vector2 = fleet.get_centroid() + if enemy.global_position.distance_to(centroid) > fire_range: + return + + var shell: Node2D = shell_scene.instantiate() + shell.telegraph_time = telegraph_time + shell.shell_speed = shell_speed + shell.aoe_radius = aoe_radius + shell.damage = damage + shell.color = color + enemy.get_tree().root.add_child(shell) + shell.launch(enemy.global_position, centroid) + cooldown_left = fire_cooldown + + +func _drift(enemy: Node2D, delta: float) -> void: + # Local position throughout, same convention as EnemyPursuitComponent/ + # EnemyStandoffComponent: valid as long as the enemy's container and + # Fleet share a common origin, which every scene in this project does. + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return + var to_fleet: Vector2 = fleet.get_centroid() - enemy.position + if to_fleet.length() < 1.0: + return + enemy.position += to_fleet.normalized() * drift_speed * delta diff --git a/EnemyArtilleryComponent.gd.uid b/EnemyArtilleryComponent.gd.uid new file mode 100644 index 0000000..5ea4f97 --- /dev/null +++ b/EnemyArtilleryComponent.gd.uid @@ -0,0 +1 @@ +uid://b0woyjce8kl6x diff --git a/EnemyArtilleryComponent.tscn b/EnemyArtilleryComponent.tscn new file mode 100644 index 0000000..dcc574b --- /dev/null +++ b/EnemyArtilleryComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyArtilleryComponent.gd" id="1"] + +[node name="EnemyArtilleryComponent" type="Node"] +script = ExtResource("1") diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index b641b60..ca44537 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,10 +2,11 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser", "sniper"] +const ORDER: PackedStringArray = ["chaser", "sniper", "artillery"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), "sniper": preload("res://SniperEnemyDefinition.tres"), + "artillery": preload("res://ArtilleryEnemyDefinition.tres"), } diff --git a/tests/_artillery_telegraph_gas_preview.gd b/tests/_artillery_telegraph_gas_preview.gd new file mode 100644 index 0000000..c255b9a --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.gd @@ -0,0 +1,20 @@ +extends Node2D + + +func _ready() -> void: + GameState.planet_name = "Violet Tempest" + GameState.planet_id = "artillery_preview_gas" + GameState.planet_definition_id = "gas_giant" + GameState.surviving_ship_count = 3 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) + + var shell: Node = load("res://ArtilleryShell.tscn").instantiate() + shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot + shell.aoe_radius = 220.0 + surface.add_child(shell) + shell.launch(Vector2(-600, -600), Vector2.ZERO) + shell.telegraph_left = shell.telegraph_time * 0.45 + shell.set_process(false) + shell.queue_redraw() diff --git a/tests/_artillery_telegraph_gas_preview.gd.uid b/tests/_artillery_telegraph_gas_preview.gd.uid new file mode 100644 index 0000000..e5f7dff --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.gd.uid @@ -0,0 +1 @@ +uid://be13xddivhtr diff --git a/tests/_artillery_telegraph_gas_preview.tscn b/tests/_artillery_telegraph_gas_preview.tscn new file mode 100644 index 0000000..cbcb749 --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_artillery_telegraph_gas_preview.gd" id="1"] + +[node name="ArtilleryTelegraphGasPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_artillery_telegraph_rocky_preview.gd b/tests/_artillery_telegraph_rocky_preview.gd new file mode 100644 index 0000000..eaa8372 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.gd @@ -0,0 +1,20 @@ +extends Node2D + + +func _ready() -> void: + GameState.planet_name = "Kepler's Rest" + GameState.planet_id = "artillery_preview_rocky" + GameState.planet_definition_id = "rocky" + GameState.surviving_ship_count = 3 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) + + var shell: Node = load("res://ArtilleryShell.tscn").instantiate() + shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot + shell.aoe_radius = 220.0 + surface.add_child(shell) + shell.launch(Vector2(-600, -600), Vector2.ZERO) + shell.telegraph_left = shell.telegraph_time * 0.45 + shell.set_process(false) + shell.queue_redraw() diff --git a/tests/_artillery_telegraph_rocky_preview.gd.uid b/tests/_artillery_telegraph_rocky_preview.gd.uid new file mode 100644 index 0000000..6571802 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.gd.uid @@ -0,0 +1 @@ +uid://dlaxr7it3582h diff --git a/tests/_artillery_telegraph_rocky_preview.tscn b/tests/_artillery_telegraph_rocky_preview.tscn new file mode 100644 index 0000000..c38c8e8 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_artillery_telegraph_rocky_preview.gd" id="1"] + +[node name="ArtilleryTelegraphRockyPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd new file mode 100644 index 0000000..3fe97c0 --- /dev/null +++ b/tests/test_artillery_enemy.gd @@ -0,0 +1,195 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_artillery_enemy.gd +# Verifies: +# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole +# window before any damage lands +# 2. a ship that leaves the blast radius before impact takes no damage; +# one that stays takes the full hit +# 3. a shielded ship inside the blast survives, losing exactly one charge +# 4. the impact point is fixed at fire time - moving the fleet after the +# shot is launched does not redirect it (tested through the real +# EnemyArtilleryComponent, since that's what could tempt "aim at the +# fleet's live position" instead of its position at fire time) + +# Resolved via load() inside _initialize(), NOT a top-level `const ... +# preload(...)` - a top-level preload in a script-mode ENTRY point (-s +# this_file.gd) evaluates during this file's own early compilation, which +# can happen before autoloads are registered. See test_capture_points.gd +# for the full explanation. +var ARTILLERY_SHELL_SCRIPT: Script + +var game_state: Node +var main: Node +var fleet: Node +var shell: Node +var ship_inside: Node +var ship_leaves: Node +var shield_ship: Node +var far_ship: Node +var artillery: Node +var art_component: Node +var live_shell: Node +var recorded_target: Vector2 = Vector2.ZERO +var impact_point: Vector2 = Vector2.ZERO +var travel_time: float = 0.0 +var stage2_wait: float = 0.0 +var stage5_wait: float = 0.0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + ARTILLERY_SHELL_SCRIPT = load("res://ArtilleryShell.gd") + 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 + 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) + for a in get_nodes_in_group("asteroids"): + a.queue_free() + + impact_point = Vector2(5000, 0) + fleet.spawn_ships(1, impact_point) + ship_inside = fleet.ships[0] + ship_inside.position = impact_point + + fleet.add_ship(GameState.VESSEL_INTERCEPTOR) + ship_leaves = fleet.ships[fleet.ships.size() - 1] + ship_leaves.position = impact_point + Vector2(100, 0) + + fleet.add_ship(GameState.VESSEL_SHIELD) + shield_ship = fleet.ships[fleet.ships.size() - 1] + shield_ship.position = impact_point + Vector2(-100, 0) + + var origin: Vector2 = impact_point + Vector2(400, 400) + travel_time = origin.distance_to(impact_point) / 300.0 + shell = load("res://ArtilleryShell.tscn").instantiate() + shell.telegraph_time = 1.0 + shell.shell_speed = 300.0 + shell.aoe_radius = 220.0 + shell.damage = 3 + main.add_child(shell) + shell.launch(origin, impact_point) + + elapsed = 0.0 + stage = 1 + return false + 1: + # Mid-telegraph: nobody hurt yet, still visibly telegraphing. + if elapsed < shell.telegraph_time * 0.8: + return false + if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING: + return _fail("shell left the telegraph phase too early") + if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \ + or not is_instance_valid(shield_ship): + return _fail("damage landed before the telegraph window finished") + + # Ship B clears the blast radius now, well before impact. + ship_leaves.position = impact_point + Vector2(2000, 0) + # Captured once, as a plain float: the shell frees itself once + # it resolves, so re-reading shell.telegraph_time from inside + # the wait condition below (evaluated every frame) would touch + # a freed object on any frame after that. + stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5 + elapsed = 0.0 + stage = 2 + return false + 2: + # Enough time for the remaining telegraph, travel, and the full + # explosion visual to resolve. + if elapsed < stage2_wait: + return false + if is_instance_valid(ship_inside): + return _fail("a ship that stayed inside the blast radius survived") + if not is_instance_valid(ship_leaves): + return _fail("a ship that left the blast radius before impact was still hit") + if not is_instance_valid(shield_ship): + return _fail("a shielded ship inside the blast was destroyed instead of absorbing the hit") + if shield_ship.shield_charges != 0: + return _fail("shield charges did not drop to 0 after absorbing the artillery hit (charges=%d)" % + shield_ship.shield_charges) + print("stayed=destroyed left=survived shielded=absorbed-one-charge") + + # --- Part B: the impact point does not home onto a fleet that + # moves after the shot is fired --------------------------------- + fleet.spawn_ships(1, Vector2(9000, 0)) + far_ship = fleet.ships[0] + artillery = load("res://Enemy.tscn").instantiate() + artillery.enemy_id = "artillery" + artillery.position = far_ship.position + Vector2(300, 0) + main.add_child(artillery) + art_component = artillery.get_component("artillery") + art_component.cooldown_left = 0.0 + art_component.telegraph_time = 0.3 + art_component.fire_cooldown = 999.0 # only the one shot for this check + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.15: + return false + # The shell is added to the tree root (a sibling of main, not + # its child - see EnemyArtilleryComponent.tick()), so it has to + # be found via its group, not main.get_children(). + var shells: Array = get_nodes_in_group("artillery_shells") + if shells.is_empty(): + return _fail("artillery enemy did not fire when in range and off cooldown") + live_shell = shells[0] + recorded_target = live_shell.target + + far_ship.position += Vector2(4000, 0) # flee well clear of the recorded target + elapsed = 0.0 + stage = 4 + return false + 4: + # Check the target field WHILE the shell is still alive - it + # frees itself right after exploding, so this has to happen + # before that, not after. + if elapsed < 0.3: + return false + if not is_instance_valid(live_shell): + return _fail("shell resolved implausibly fast - timing assumption broke") + if live_shell.target != recorded_target: + return _fail("shell target changed after the fleet moved - it homed instead of staying fixed") + # Captured once, as a plain float: far_ship's own weapon can + # destroy the artillery enemy (and with it art_component) well + # before this wait ends, so the condition below must not keep + # re-reading a property off a node that might already be freed. + stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5 + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < stage5_wait: + return false + if not is_instance_valid(far_ship): + return _fail("fleeing ship was hit by a shot aimed at its old position - homing occurred") + print("shell held its fire-time target and did not home onto the fleeing ship") + print("TEST_ARTILLERY_ENEMY: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ARTILLERY_ENEMY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_artillery_enemy.gd.uid b/tests/test_artillery_enemy.gd.uid new file mode 100644 index 0000000..33da4a1 --- /dev/null +++ b/tests/test_artillery_enemy.gd.uid @@ -0,0 +1 @@ +uid://cv3lvd01j4kp