Add artillery enemy with telegraphed AOE (#46)

EnemyArtilleryComponent is a stationary siege piece (slow drift toward
the fleet, deliberately no pursuit component) that fires at the fleet
centroid on a long cooldown via a standalone ArtilleryShell -
telegraph ring (~1.8s, growing radius plus a filling countdown arc) ->
slow visible travel -> AOE explosion via Ship.take_damage() on every
player_ships node inside, same all-or-nothing shield absorption as
every other damage source. The shell is independent of the enemy that
fired it (added to the tree root, like EnemyProjectile/Projectile) so
the shooter dying mid-flight doesn't cancel an already-telegraphed
shot, and the impact point is fixed at fire time - never re-aimed at
where the fleet moves to afterward.

Part of the #40 conquest-mechanic breakdown.
This commit is contained in:
Loïc Wisniewski 2026-08-15 21:23:43 +02:00
commit def224667d
16 changed files with 455 additions and 1 deletions

View file

@ -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()

View file

@ -0,0 +1 @@
uid://be13xddivhtr

View file

@ -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")

View file

@ -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()

View file

@ -0,0 +1 @@
uid://dlaxr7it3582h

View file

@ -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")

View file

@ -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

View file

@ -0,0 +1 @@
uid://cv3lvd01j4kp