#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
extends SceneTree
|
|
|
|
|
|
|
|
|
|
# Headless gameplay test: run with
|
|
|
|
|
# godot --headless -s res://tests/test_lancer_enemy.gd
|
|
|
|
|
# Forgejo #96, the lancer: a ramming enemy that announces itself.
|
|
|
|
|
#
|
|
|
|
|
# "A new enemy vessel tries to suicide itself like the default vessel.
|
|
|
|
|
# Though this new enemy starts a bit far from the target, lock it and
|
|
|
|
|
# wait a bit. A small back and forth announce its departure to its
|
|
|
|
|
# target, in a straight line, quickly. 300ms before it actually starts
|
|
|
|
|
# going, a small line goes from it to its target to announce its
|
|
|
|
|
# trajectory."
|
|
|
|
|
#
|
|
|
|
|
# Covers:
|
|
|
|
|
# A. it is in the catalog, composes a lunge component and nothing that
|
|
|
|
|
# would fight it over position, and rams like the chaser does
|
|
|
|
|
# B. from beyond lock_range it does NOT charge - it closes slowly, so the
|
|
|
|
|
# player sees it coming
|
|
|
|
|
# C. once locked it holds station for the wind-up and rocks back and forth
|
2026-08-23 21:38:08 +02:00
|
|
|
# along the charge axis instead of drifting toward the target - ONCE
|
|
|
|
|
# (#115), because repeating the tell turned a wind-up into a fidget
|
#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
# D. the trajectory line comes up during the wind-up and lasts about
|
|
|
|
|
# 300ms - not the whole wind-up, and not nothing
|
|
|
|
|
# E. the charge is straight, fast, and aimed at where the target WAS: a
|
|
|
|
|
# ship that moves after the lock is missed, which is what makes the
|
|
|
|
|
# telegraph worth reading
|
|
|
|
|
# F. it dies on contact, taking the vessel with it
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
# G. a wave can actually field one, and still fields chasers - a new
|
|
|
|
|
# enemy nobody meets is not a new enemy
|
#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
|
|
|
|
|
var game_state: Node
|
|
|
|
|
var main: Node
|
|
|
|
|
var fleet: Node
|
|
|
|
|
var lancer: Node2D
|
|
|
|
|
var target_ship: Node2D
|
|
|
|
|
var victim: Node2D
|
|
|
|
|
var stage: int = 0
|
|
|
|
|
var elapsed: float = 0.0
|
|
|
|
|
var lunge: Node
|
|
|
|
|
|
|
|
|
|
var approach_start: float = 0.0
|
|
|
|
|
var windup_seen: bool = false
|
|
|
|
|
var windup_anchor: Vector2 = Vector2.ZERO
|
|
|
|
|
var max_sway: float = 0.0
|
|
|
|
|
var max_lateral: float = 0.0
|
2026-08-23 21:38:08 +02:00
|
|
|
var sway_crossings: int = 0
|
|
|
|
|
var last_side: int = 0
|
#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
var telegraph_time_seen: float = 0.0
|
|
|
|
|
var charge_start: Vector2 = Vector2.ZERO
|
|
|
|
|
var dodged_to: Vector2 = Vector2.ZERO
|
|
|
|
|
var charge_locked: Vector2 = Vector2.ZERO
|
|
|
|
|
var max_off_axis: float = 0.0
|
|
|
|
|
var charge_seen: bool = false
|
|
|
|
|
var destroyed_fired: bool = false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _initialize() -> void:
|
|
|
|
|
# GameState is an autoload: a bare GameState.<instance member> 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.reset_progression()
|
|
|
|
|
main = load("res://world/Main.tscn").instantiate()
|
|
|
|
|
root.add_child(main)
|
|
|
|
|
current_scene = main
|
|
|
|
|
fleet = main.get_node("Fleet")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _spawn_lancer(at: Vector2) -> Node2D:
|
|
|
|
|
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
|
|
|
|
|
enemy.enemy_id = "lancer"
|
|
|
|
|
enemy.position = at
|
|
|
|
|
main.add_child(enemy)
|
|
|
|
|
return enemy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
# --- A. catalogued, composed, and a rammer -------------------
|
|
|
|
|
var definitions: Script = load("res://enemies/EnemyDefinitions.gd")
|
|
|
|
|
var definition: Resource = definitions.get_definition("lancer")
|
|
|
|
|
if definition.enemy_id != "lancer":
|
|
|
|
|
return _fail("EnemyDefinitions has no \"lancer\" - get_definition fell back")
|
|
|
|
|
if not bool(definition.dies_on_contact):
|
|
|
|
|
return _fail("the lancer is not marked dies_on_contact, so it is not a rammer")
|
|
|
|
|
|
|
|
|
|
fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
|
|
|
|
target_ship = fleet.ships[0]
|
|
|
|
|
# Ship steers toward move_target every frame; pinning it there is
|
|
|
|
|
# how a vessel is held still (see test_sniper_enemy.gd).
|
|
|
|
|
target_ship.move_target = target_ship.position
|
|
|
|
|
|
|
|
|
|
lancer = _spawn_lancer(target_ship.global_position + Vector2(1300.0, 0.0))
|
|
|
|
|
lunge = lancer.get_component("lunge")
|
|
|
|
|
if lunge == null:
|
|
|
|
|
return _fail("the lancer composed no lunge component")
|
|
|
|
|
if lancer.has_capability("pursuit") or lancer.has_capability("standoff"):
|
|
|
|
|
return _fail("the lancer also carries a mover that would fight the lunge for position")
|
|
|
|
|
if 1300.0 <= lunge.lock_range:
|
|
|
|
|
return _fail("the test spawns inside lock_range, so case B proves nothing")
|
|
|
|
|
print("lancer: hp %d, lock_range %.0f, windup %.2fs, telegraph %.2fs" % [
|
|
|
|
|
lancer.max_hp, lunge.lock_range, lunge.windup_time, lunge.telegraph_time])
|
|
|
|
|
approach_start = lancer.global_position.distance_to(target_ship.global_position)
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 1
|
|
|
|
|
return false
|
|
|
|
|
1:
|
|
|
|
|
# --- B. it approaches rather than charging from out there ----
|
|
|
|
|
if elapsed < 0.6:
|
|
|
|
|
if lunge.phase == lunge.Phase.CHARGE:
|
|
|
|
|
return _fail("the lancer charged from beyond its lock_range")
|
|
|
|
|
return false
|
|
|
|
|
var closed: float = approach_start - lancer.global_position.distance_to(
|
|
|
|
|
target_ship.global_position)
|
|
|
|
|
if closed <= 1.0:
|
|
|
|
|
return _fail("the lancer never closed on the fleet from %.0f away" % approach_start)
|
|
|
|
|
# The stalk must be slower than the charge, or the "starts a bit
|
|
|
|
|
# far, waits" reading is lost.
|
|
|
|
|
var approach_speed: float = closed / elapsed
|
|
|
|
|
if approach_speed >= lunge.charge_speed * 0.5:
|
|
|
|
|
return _fail("it approached at %.0fpx/s, barely short of its %.0fpx/s charge" % [
|
|
|
|
|
approach_speed, lunge.charge_speed])
|
|
|
|
|
print("closed %.0fpx in %.1fs (%.0fpx/s) without charging" % [
|
|
|
|
|
closed, elapsed, approach_speed])
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 2
|
|
|
|
|
return false
|
|
|
|
|
2:
|
|
|
|
|
# --- C/D. wind-up: rocks in place, and puts the line up late --
|
|
|
|
|
if lunge.phase == lunge.Phase.WINDUP:
|
|
|
|
|
if not windup_seen:
|
|
|
|
|
windup_seen = true
|
|
|
|
|
windup_anchor = lunge.charge_anchor
|
|
|
|
|
charge_locked = lunge.locked_point
|
|
|
|
|
var offset: Vector2 = lancer.global_position - windup_anchor
|
2026-08-23 21:38:08 +02:00
|
|
|
var along: float = offset.dot(lunge.charge_direction)
|
|
|
|
|
max_sway = maxf(max_sway, absf(along))
|
|
|
|
|
# One back-and-forth crosses the anchor exactly once: out one
|
|
|
|
|
# way, back through it, out the other. Two would cross three
|
|
|
|
|
# times.
|
|
|
|
|
if absf(along) > 2.0:
|
|
|
|
|
var side: int = 1 if along > 0.0 else -1
|
|
|
|
|
if last_side != 0 and side != last_side:
|
|
|
|
|
sway_crossings += 1
|
|
|
|
|
last_side = side
|
#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x)
|
|
|
|
|
max_lateral = maxf(max_lateral, absf(offset.dot(across)))
|
|
|
|
|
if lunge.is_telegraphing():
|
|
|
|
|
telegraph_time_seen += delta
|
|
|
|
|
if dodged_to == Vector2.ZERO:
|
|
|
|
|
# What the warning is FOR: the moment the line shows,
|
|
|
|
|
# the ship steps off it. The lock is already frozen,
|
|
|
|
|
# so this must not drag the charge along with it.
|
|
|
|
|
var sidestep: Vector2 = Vector2(-lunge.charge_direction.y,
|
|
|
|
|
lunge.charge_direction.x) * 400.0
|
|
|
|
|
target_ship.position += sidestep
|
|
|
|
|
target_ship.move_target = target_ship.position
|
|
|
|
|
dodged_to = target_ship.position
|
|
|
|
|
return false
|
|
|
|
|
if not windup_seen:
|
|
|
|
|
if elapsed > 6.0:
|
|
|
|
|
return _fail("the lancer never entered a wind-up within 6s of locking range")
|
|
|
|
|
return false
|
|
|
|
|
if lunge.phase != lunge.Phase.CHARGE:
|
|
|
|
|
return _fail("the wind-up ended in phase %d rather than a charge" % lunge.phase)
|
|
|
|
|
|
|
|
|
|
if max_sway < 1.0:
|
|
|
|
|
return _fail("the lancer sat perfectly still through the wind-up - no back and forth")
|
|
|
|
|
if max_sway > lunge.windup_sway + 1.0:
|
|
|
|
|
return _fail("it moved %.0fpx along the charge axis, more than the %.0fpx sway - that is departing early" % [
|
|
|
|
|
max_sway, lunge.windup_sway])
|
|
|
|
|
if max_lateral > 1.0:
|
|
|
|
|
return _fail("it wandered %.0fpx sideways during the wind-up instead of holding station" % max_lateral)
|
2026-08-23 21:38:08 +02:00
|
|
|
if sway_crossings != 1:
|
|
|
|
|
return _fail("the wind-up crossed its anchor %d times - #115 asks for exactly one back and forth" \
|
|
|
|
|
% sway_crossings)
|
|
|
|
|
print("wind-up: rocked %.0fpx back and forth once, %.0fpx sideways" % [max_sway, max_lateral])
|
#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
|
|
|
|
|
# 300ms of line, give or take the frames either side of the
|
|
|
|
|
# threshold. Both bounds matter: a line up for the whole wind-up
|
|
|
|
|
# is not a 300ms warning either.
|
|
|
|
|
if absf(telegraph_time_seen - lunge.telegraph_time) > 0.1:
|
|
|
|
|
return _fail("the trajectory line showed for %.2fs, not the %.2fs it promises" % [
|
|
|
|
|
telegraph_time_seen, lunge.telegraph_time])
|
|
|
|
|
if lunge.telegraph_time >= lunge.windup_time:
|
|
|
|
|
return _fail("the line covers the whole wind-up, so it announces nothing")
|
|
|
|
|
print("trajectory line up for %.2fs of the %.2fs wind-up" % [
|
|
|
|
|
telegraph_time_seen, lunge.windup_time])
|
|
|
|
|
|
|
|
|
|
charge_start = lancer.global_position
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 3
|
|
|
|
|
return false
|
|
|
|
|
3:
|
|
|
|
|
# --- E. straight, fast, and at the locked point --------------
|
|
|
|
|
if lunge.phase == lunge.Phase.CHARGE:
|
|
|
|
|
charge_seen = true
|
|
|
|
|
var travel: Vector2 = lancer.global_position - charge_start
|
|
|
|
|
var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x)
|
|
|
|
|
max_off_axis = maxf(max_off_axis, absf(travel.dot(across)))
|
|
|
|
|
return false
|
|
|
|
|
if not charge_seen:
|
|
|
|
|
return _fail("the charge was over before a single frame of it was observed")
|
|
|
|
|
if dodged_to == Vector2.ZERO:
|
|
|
|
|
return _fail("the trajectory line never showed, so nothing was dodged")
|
|
|
|
|
if not is_instance_valid(target_ship) or target_ship.is_queued_for_deletion():
|
|
|
|
|
return _fail("the ship stepped off the telegraphed line and was hit anyway")
|
|
|
|
|
if max_off_axis > 1.0:
|
|
|
|
|
return _fail("the charge bent %.0fpx off its locked axis - it homed instead of flying straight" % max_off_axis)
|
|
|
|
|
var covered: float = charge_start.distance_to(lancer.global_position)
|
|
|
|
|
var speed: float = covered / maxf(elapsed, 0.001)
|
|
|
|
|
if speed < lunge.charge_speed * 0.8:
|
|
|
|
|
return _fail("the charge averaged %.0fpx/s against a stated %.0fpx/s" % [
|
|
|
|
|
speed, lunge.charge_speed])
|
|
|
|
|
# It aimed at the locked point, not at wherever the ship is now.
|
|
|
|
|
var passed_by: float = (charge_locked - target_ship.global_position).length()
|
|
|
|
|
if passed_by < 200.0:
|
|
|
|
|
return _fail("the charge followed the ship as it dodged - the lock is not frozen")
|
|
|
|
|
print("charged %.0fpx at %.0fpx/s, %.0fpx off axis, locked point %.0fpx from the ship" % [
|
|
|
|
|
covered, speed, max_off_axis, passed_by])
|
|
|
|
|
lancer.queue_free()
|
|
|
|
|
|
|
|
|
|
# --- F. contact kills both ----------------------------------
|
|
|
|
|
fleet.spawn_ships(1, Vector2(4000.0, 0.0), PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
|
|
|
|
victim = fleet.ships[0]
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 4
|
|
|
|
|
return false
|
|
|
|
|
4:
|
|
|
|
|
if elapsed < 0.2:
|
|
|
|
|
return false
|
|
|
|
|
lancer = _spawn_lancer(victim.global_position)
|
|
|
|
|
lancer.destroyed.connect(func(): destroyed_fired = true)
|
|
|
|
|
elapsed = 0.0
|
|
|
|
|
stage = 5
|
|
|
|
|
return false
|
|
|
|
|
5:
|
|
|
|
|
if elapsed < 0.5:
|
|
|
|
|
return false
|
|
|
|
|
if is_instance_valid(lancer) and not lancer.is_queued_for_deletion():
|
|
|
|
|
return _fail("the lancer survived ramming a vessel")
|
|
|
|
|
if not destroyed_fired:
|
|
|
|
|
return _fail("the lancer vanished without firing `destroyed` - not routed through take_damage")
|
|
|
|
|
if is_instance_valid(victim) and not victim.is_queued_for_deletion():
|
|
|
|
|
return _fail("the rammed vessel survived, so the lancer cost nothing to stop")
|
|
|
|
|
print("lancer and vessel destroyed each other")
|
|
|
|
|
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
# --- G. waves can field one ---------------------------------
|
|
|
|
|
# This originally checked the roaming spawner's share of lancers.
|
|
|
|
|
# #100 removed that spawner entirely: the way a lancer reaches
|
|
|
|
|
# the player now is a wave, so that is what has to be able to
|
|
|
|
|
# produce one - and it must not have displaced the chaser while
|
|
|
|
|
# doing it.
|
#233: open with fewer enemies, of one kind, arriving in sequence
Measured before changing anything. The ticket's two halves needed opposite
fixes, and the first one made the second worse.
TOO COMPLEX. Variety was gated on fleet size alone, so a player who had
grown early met two kinds of trouble in the first wave they ever saw - a
fleet of four opened on two lancers and three chasers. Each roster entry now
carries a min_wave as well, so the openings teach one enemy at a time
whatever the fleet already looks like.
That alone pushed wave 1 against a fleet of six from a mixed six to SEVEN
chasers: budget that used to buy a lancer now bought two more chasers. So an
early wave also has a size ceiling, 2 + wave_number * 2. Wave 1 is four
chasers at any fleet size; the cap stops binding by wave 6 and nothing about
later balance moves.
before wave 1 fleet 4 -> 2 lancers, 3 chasers
wave 1 fleet 6 -> 1 sniper, 2 lancers, 3 chasers
after wave 1 fleet 4 -> 4 chasers
wave 1 fleet 6 -> 4 chasers
ARRIVE AT THE SAME TIME was literal: the whole wave spawned inside one frame
as a closed ring around the fleet, which reads as a wall rather than as an
attack. The first hull still lands immediately so a wave is never felt as
late, and the rest follow every 0.45s. Same enemies, same bearing, same
total - they simply do not all exist at once.
Four existing cases objected, and each was right to: they encode intents
that still hold LATER - a large wave must be varied, fleet growth must open
more kinds, a wave can field a lancer at all, and killing a wave must bring
the quiet back. The first three now ask at a wave number where variety is
permitted rather than being weakened. The fourth runs the arrivals out
before killing anything, and gained an assertion that doing so ADDS enemies
- which fails if the staggering ever collapses back into one frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-14 12:35:50 -04:00
|
|
|
# Read past the openings: #233 gates each kind behind a minimum
|
|
|
|
|
# WAVE as well as a minimum fleet, so the first waves are chasers
|
|
|
|
|
# only however large the fleet is. This case is about whether a
|
|
|
|
|
# wave can field a lancer at all, not about when it starts to.
|
|
|
|
|
var settled_wave: int = game_state.wave_number
|
|
|
|
|
game_state.wave_number = 8
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
var kinds: Dictionary = {}
|
|
|
|
|
var lancer_at: int = 0
|
|
|
|
|
for size in range(1, 12):
|
|
|
|
|
for id in game_state.wave_composition(size):
|
|
|
|
|
kinds[id] = true
|
|
|
|
|
if id == "lancer" and lancer_at == 0:
|
|
|
|
|
lancer_at = size
|
#233: open with fewer enemies, of one kind, arriving in sequence
Measured before changing anything. The ticket's two halves needed opposite
fixes, and the first one made the second worse.
TOO COMPLEX. Variety was gated on fleet size alone, so a player who had
grown early met two kinds of trouble in the first wave they ever saw - a
fleet of four opened on two lancers and three chasers. Each roster entry now
carries a min_wave as well, so the openings teach one enemy at a time
whatever the fleet already looks like.
That alone pushed wave 1 against a fleet of six from a mixed six to SEVEN
chasers: budget that used to buy a lancer now bought two more chasers. So an
early wave also has a size ceiling, 2 + wave_number * 2. Wave 1 is four
chasers at any fleet size; the cap stops binding by wave 6 and nothing about
later balance moves.
before wave 1 fleet 4 -> 2 lancers, 3 chasers
wave 1 fleet 6 -> 1 sniper, 2 lancers, 3 chasers
after wave 1 fleet 4 -> 4 chasers
wave 1 fleet 6 -> 4 chasers
ARRIVE AT THE SAME TIME was literal: the whole wave spawned inside one frame
as a closed ring around the fleet, which reads as a wall rather than as an
attack. The first hull still lands immediately so a wave is never felt as
late, and the rest follow every 0.45s. Same enemies, same bearing, same
total - they simply do not all exist at once.
Four existing cases objected, and each was right to: they encode intents
that still hold LATER - a large wave must be varied, fleet growth must open
more kinds, a wave can field a lancer at all, and killing a wave must bring
the quiet back. The first three now ask at a wave number where variety is
permitted rather than being weakened. The fourth runs the arrivals out
before killing anything, and gained an assertion that doing so ADDS enemies
- which fails if the staggering ever collapses back into one frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-14 12:35:50 -04:00
|
|
|
game_state.wave_number = settled_wave
|
#100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.
The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.
EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.
Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.
"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
|
|
|
if lancer_at == 0:
|
|
|
|
|
return _fail("no wave against a fleet of 1..11 contains a lancer")
|
|
|
|
|
if not kinds.has("chaser"):
|
|
|
|
|
return _fail("lancers displaced the chaser out of every wave")
|
|
|
|
|
print("waves field lancers from a fleet of %d up, alongside %s" \
|
|
|
|
|
% [lancer_at, str(kinds.keys())])
|
#96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.
The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.
EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.
The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
|
|
|
|
|
|
|
|
print("TEST_LANCER_ENEMY: PASS")
|
|
|
|
|
quit(0)
|
|
|
|
|
return true
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
|
|
|
print("TEST_LANCER_ENEMY: FAIL (%s)" % message)
|
|
|
|
|
quit(1)
|
|
|
|
|
return true
|