Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
extends Node2D
|
|
|
|
|
|
#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
|
|
|
# The wave director (#100 / #97). Keeps its old node name on purpose: it is
|
|
|
|
|
# still "the thing that puts enemies in the world", and every test that
|
|
|
|
|
# wanted a quiet world already says
|
|
|
|
|
# `main.get_node("EnemySpawner").set_process(false)`. Renaming it would have
|
|
|
|
|
# changed 88 test files to say the same thing differently.
|
|
|
|
|
#
|
|
|
|
|
# What it no longer does is trickle. It used to spawn one enemy every 3-6
|
|
|
|
|
# seconds for ever, so there was no rhythm to the danger and nothing to
|
|
|
|
|
# prepare for. Now the CLOCK lives in GameState (which survives the trip to
|
|
|
|
|
# a planet surface) and this node only reacts to it: when a wave turns
|
|
|
|
|
# active, it puts the wave in the world; when the wave is dead, it says so
|
|
|
|
|
# and the quiet starts again.
|
|
|
|
|
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
@export var spawn_margin: float = 150.0
|
#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
|
|
|
# Waves arrive as a ring around the fleet rather than from one bearing: a
|
|
|
|
|
# wave that all came from the left would be dodged by flying right.
|
|
|
|
|
@export var wave_spread: float = 0.55
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
@onready var fleet: Node2D = get_node("../Fleet")
|
|
|
|
|
|
#84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.
buildings core controllers enemies logistics planets
resources shaders surface ui vessels vfx world
332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.
Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.
The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
|
|
|
var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
|
#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
|
|
|
# The enemies this director launched, so "the wave is dead" is a question
|
|
|
|
|
# about THIS wave and not about every enemy on the map - a planet garrison
|
|
|
|
|
# or an enemy mothership's own spawns must not hold the next wave back.
|
|
|
|
|
var wave_enemies: Array = []
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
#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
|
|
|
# Seconds between one hull of a wave landing and the next.
|
|
|
|
|
const ARRIVAL_INTERVAL: float = 0.45
|
|
|
|
|
|
|
|
|
|
# Hulls of the current wave that have not landed yet, with the position each
|
|
|
|
|
# was assigned when the wave was aimed.
|
|
|
|
|
var arriving: Array = []
|
|
|
|
|
var arrival_timer: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _process(delta: float) -> void:
|
#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
|
|
|
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
|
|
|
|
|
if GameState.wave_pending:
|
|
|
|
|
_launch_wave()
|
|
|
|
|
return
|
#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
|
|
|
_land_arrivals(delta)
|
|
|
|
|
# `arriving` as well as `wave_enemies`: between the first hull landing and
|
|
|
|
|
# the last, the field can legitimately be empty of live enemies, and
|
|
|
|
|
# clearing the wave there would end it before it had finished arriving.
|
|
|
|
|
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE \
|
|
|
|
|
and wave_enemies.is_empty() and arriving.is_empty():
|
#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
|
|
|
GameState.wave_cleared()
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
|
|
|
|
|
|
#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
|
|
|
# Forgejo #233: a wave used to appear entirely within one frame, as a closed
|
|
|
|
|
# ring around the fleet. That reads as a wall rather than as an attack, and
|
|
|
|
|
# it was the "arrive at the same time" half of the complaint - the other half
|
|
|
|
|
# being how many there were, which wave_composition now limits.
|
|
|
|
|
#
|
|
|
|
|
# The first hull still lands immediately, so a wave never feels delayed; the
|
|
|
|
|
# rest follow at ARRIVAL_INTERVAL. Same enemies, same bearing, same total -
|
|
|
|
|
# they just do not all exist at once.
|
|
|
|
|
func _land_arrivals(delta: float) -> void:
|
|
|
|
|
if arriving.is_empty():
|
|
|
|
|
return
|
|
|
|
|
arrival_timer -= delta
|
|
|
|
|
while arrival_timer <= 0.0 and not arriving.is_empty():
|
|
|
|
|
_spawn_arrival(arriving.pop_front())
|
|
|
|
|
arrival_timer += ARRIVAL_INTERVAL
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _spawn_arrival(entry: Dictionary) -> void:
|
|
|
|
|
var enemy: Node2D = enemy_scene.instantiate()
|
|
|
|
|
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
|
|
|
|
|
enemy.enemy_id = String(entry.id)
|
|
|
|
|
# #113: a wave is an attack on what the player has built. It converges
|
|
|
|
|
# on the nearest structure from wherever it spawned rather than
|
|
|
|
|
# waiting to be flown near.
|
|
|
|
|
enemy.relentless = true
|
|
|
|
|
enemy.position = entry.at
|
|
|
|
|
get_parent().add_child(enemy)
|
|
|
|
|
wave_enemies.append(enemy)
|
|
|
|
|
|
|
|
|
|
|
#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
|
|
|
# Public so a test can put a wave in the world without waiting out the peace.
|
|
|
|
|
func _launch_wave() -> void:
|
|
|
|
|
if fleet == null or fleet.ships.is_empty():
|
|
|
|
|
# Nothing to attack. Leave it pending rather than spending the wave
|
|
|
|
|
# on an empty map - the fleet-wipe safety net rebuilds within a frame
|
|
|
|
|
# and the wave arrives then.
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
return
|
#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
|
|
|
GameState.wave_pending = false
|
|
|
|
|
var composition: PackedStringArray = GameState.wave_composition(fleet.ships.size())
|
Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1
A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).
All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.
Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.
Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:41:58 +02:00
|
|
|
var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self)
|
|
|
|
|
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
|
#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 centre: Vector2 = fleet.get_centroid()
|
|
|
|
|
var bearing: float = randf() * TAU
|
#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
|
|
|
# Positions are settled now, while the fleet is where the wave was aimed
|
|
|
|
|
# at - not as each hull lands, or a moving fleet would drag the later
|
|
|
|
|
# arrivals around behind it and the ring would smear.
|
|
|
|
|
arriving.clear()
|
#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
|
|
|
for i in range(composition.size()):
|
|
|
|
|
# Spread around one bearing rather than scattered at random: a wave
|
|
|
|
|
# should read as an attack arriving, not as the map filling up.
|
|
|
|
|
var angle: float = bearing + randf_range(-wave_spread, wave_spread) \
|
|
|
|
|
+ TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread
|
#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
|
|
|
arriving.append({
|
|
|
|
|
"id": composition[i],
|
|
|
|
|
"at": centre + Vector2(cos(angle), sin(angle)) * radius,
|
|
|
|
|
})
|
|
|
|
|
# The first lands at once, so a wave is never felt as late.
|
|
|
|
|
if not arriving.is_empty():
|
|
|
|
|
_spawn_arrival(arriving.pop_front())
|
|
|
|
|
arrival_timer = ARRIVAL_INTERVAL
|