Sharpen upgrades, mark the convoy's destination, and give the world sound

#204 the Beam Collimator widens the beam instead of adding range to one that
already crosses the screen; beam width becomes a per-shot vessel stat.
#205 the Energy Propulser gains three upgrades - dash recharge, dash reach,
and boosted-cruise drain - which turns three constants into vessel stats.
#206 three further doctrines carry the fleet from ten to twenty.
#207 the prologue's World Ship gets a marker, through the scanner's own
guidance arrow rather than a second one.
#208 #210 #213 #214 four new synthesised sounds: the breach opening, cargo
reaching the hold, a rock breaking, and a gas bomb blooming.
#215 building health bars are larger, plated and framed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-05 12:16:17 +02:00
commit 78836882af
33 changed files with 716 additions and 52 deletions

View file

@ -0,0 +1,34 @@
extends Node2D
# Preview, not a test - #215's health bars, through the real game path. A bar
# is only drawn on a DAMAGED structure, so this hurts each one by a different
# amount: full, most, half, and nearly gone, left to right.
#
# bash tests/screenshot.sh res://tests/_building_health_preview.tscn shots/building_health.png 3 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.intro_active = false
GameState.reset_formation()
var placed: Array = [
[GameState.BUILDING_MOTHERSHIP, Vector2(-330.0, 0.0), 0.0],
[GameState.BUILDING_SHIP_CRAFTER, Vector2(140.0, 0.0), 0.25],
[GameState.BUILDING_TRANSPORT_HUB, Vector2(480.0, 0.0), 0.5],
[GameState.BUILDING_SHIP_LAB, Vector2(820.0, 0.0), 0.85],
]
for entry in placed:
var id: int = GameState.add_building(String(entry[0]), Vector2(entry[1]))
var hurt: float = float(entry[2])
if hurt > 0.0:
GameState.damage_building(id,
int(GameState.building_max_hp(String(entry[0])) * hurt))
var main: Node = load("res://world/Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
main.get_node("Fleet").move_to(Vector2(240.0, 260.0))
var cam: Camera2D = main.get_node("Camera2D")
cam.zoom = Vector2(0.78, 0.78)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_building_health_preview.gd" id="1"]
[node name="BuildingHealthPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,21 @@
extends Node2D
# Preview, not a test - #207's destination marker during the prologue. The
# convoy is flying north toward a World Ship 16000 units away that is never
# on screen, so this renders the RUN beat, where the player is flying and
# the marker is the only thing saying where.
#
# bash tests/screenshot.sh res://tests/_intro_marker_preview.tscn shots/intro_marker.png 3 720x1280
func _ready() -> void:
GameState.reset_new_game()
var main: Node = load("res://world/Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
# BRIEF has already run inside Main's _ready(), which is what spawns the
# convoy and names the destination; step on to the beat where the convoy
# is actually under way.
var director: Node = main.get_node("IntroDirector")
director._begin(1)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_intro_marker_preview.gd" id="1"]
[node name="IntroMarkerPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -21,6 +21,8 @@ func _ready() -> void:
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
var build: Node = main.get_node("BuildController")
# The tree lives on the menu, not on the controller - reaching through
# build.skill_tree stopped resolving at some point and this preview had
# been rendering an unopened menu ever since.
build.skill_menu.visible = true
build.skill_tree.selected_id = "shipyards"
build._refresh_skill_menu("shipyards")
build.skill_menu.refresh("shipyards")

View file

@ -19,7 +19,8 @@ extends SceneTree
# E. the tree cannot promise a slot the formation has no room for - the
# invariant that made extra doctrines silently worthless
const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup", "grand_fleet"]
const DOCTRINE_CHAIN: Array = ["wing", "squadron", "armada", "battlegroup",
"grand_fleet", "task_force", "star_legion", "void_armada"]
var game_state: Node
var scene: Node
@ -98,6 +99,11 @@ func _process(delta: float) -> bool:
if expected > GameState.DEFAULT_FORMATION.size():
return _fail("the doctrines promise %d slots but DEFAULT_FORMATION only defines %d - the last ones buy nothing" \
% [expected, GameState.DEFAULT_FORMATION.size()])
# #206 named the destination, so it is pinned rather than left to
# whatever the chain happens to add up to.
if expected != 20:
return _fail("the doctrine chain reaches %d vessels, not the 20 it is meant to" \
% expected)
print("full doctrine chain reaches %d slots, with %d formation positions defined" \
% [expected, GameState.DEFAULT_FORMATION.size()])

View file

@ -288,6 +288,41 @@ func _process(delta: float) -> bool:
return _fail_now("dragging further did not reach past the nearer enemy")
print("drag depth selects between a near and a far target in the same cone")
# --- G. #204: the Collimator widens what one shot catches -------
# The upgrade used to add range to a beam that already crossed the
# screen; it adds WIDTH now, so this checks the thing it widens is
# the hit test and not only the picture.
for placed in [near_enemy, far_enemy, out_of_cone]:
if is_instance_valid(placed):
placed.free()
var laser: Node = first_shooter.get_component("laser")
var narrow_width: float = laser.beam_half_width
game_state.vessel_levels["lsr_width"] = 3
laser.apply_stats()
if not is_equal_approx(laser.beam_half_width, narrow_width + 66.0):
return _fail_now("three Collimators gave a half-width of %.0f, expected %.0f" \
% [laser.beam_half_width, narrow_width + 66.0])
# Off the line by more than the base beam covers and less than the
# researched one does, so the same shot answers differently.
var flanker: Node = load("res://enemies/Enemy.tscn").instantiate()
flanker.position = first_shooter.global_position \
+ Vector2(600.0, narrow_width + 60.0)
main.add_child(flanker)
flanker.speed = 0.0
var beam_script: Resource = load("res://vfx/LaserBeamEffect.gd")
var narrow_shot: Node2D = beam_script.fire(root, first_shooter.global_position,
Vector2.RIGHT, 2900.0, 1, narrow_width)
if narrow_shot.hit_count != 0:
return _fail_now("the unresearched beam already reached the flanking enemy")
var wide_shot: Node2D = beam_script.fire(root, first_shooter.global_position,
Vector2.RIGHT, 2900.0, 1, laser.beam_half_width)
if wide_shot.hit_count != 1:
return _fail_now("the widened beam hit %d enemies, expected the flanker" \
% wide_shot.hit_count)
print("Beam Collimator: %.0f -> %.0f half-width, and the wider beam catches what the narrow one missed" \
% [narrow_width, laser.beam_half_width])
print("TEST_LASER_VESSEL: PASS")
quit(0)
return true

View file

@ -0,0 +1,133 @@
extends SceneTree
# Forgejo #205: the Energy Propulser had no upgrades at all - it was the one
# fleet hull whose ship lab offered nothing. It now carries three, and this
# checks each of them reaches the thing it is supposed to change rather than
# only reading well in the menu.
#
# Covers:
# A. Warp Coils lengthen the dash, and the controller aims that far
# B. Surge Capacitor shortens the recharge, floored by minimum_stats
# C. Flux Regulator makes boosted cruise cheaper to sustain
# D. an unupgraded Propulser is exactly what it was before
const BASE_DASH_DISTANCE: float = 520.0
const BASE_DASH_COOLDOWN: float = 5.0
const BASE_BOOST_DRAIN: float = 2.0
const COOLDOWN_FLOOR: float = 2.0
# Where three Surge Capacitors actually land. Above the floor, deliberately:
# the floor is a guard on the definition, not a target the branch reaches.
const UPGRADED_COOLDOWN: float = 2.3
var state: Node
var main: Node
var fleet: Node
var dash: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
state = root.get_node("GameState")
state.reset_new_game()
state.intro_active = false
state.reset_quests()
state.vessel_levels["unlock_distorder"] = 1
state.fleet_inventory.total_capacity = -1
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
dash = main.get_node("DashController")
func _stat(name: String) -> float:
return state.vessel_stat(GameState.VESSEL_DISTORDER, name)
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)
# --- D. nothing researched, nothing changed -------------------
if not is_equal_approx(_stat("dash_distance"), BASE_DASH_DISTANCE) \
or not is_equal_approx(_stat("dash_cooldown"), BASE_DASH_COOLDOWN) \
or not is_equal_approx(_stat("boost_drain"), BASE_BOOST_DRAIN):
return _fail("an unresearched Propulser does not match its own definition")
print("unupgraded Propulser: %.0f dash, %.1fs recharge, %.1f energy/s" \
% [_stat("dash_distance"), _stat("dash_cooldown"), _stat("boost_drain")])
# --- A. Warp Coils --------------------------------------------
state.vessel_levels["dst_dash_range"] = 3
if not is_equal_approx(_stat("dash_distance"), BASE_DASH_DISTANCE + 420.0):
return _fail("three Warp Coils gave %.0f dash distance, expected %.0f" \
% [_stat("dash_distance"), BASE_DASH_DISTANCE + 420.0])
# The controller must aim as far as the fleet can actually surge,
# or the guide promises reach the dash does not have.
if not is_equal_approx(dash._aim_max_range(), _stat("dash_distance")):
return _fail("the dash guide reaches %.0f while the dash carries %.0f" \
% [dash._aim_max_range(), _stat("dash_distance")])
print("Warp Coils: dash and its guide both reach %.0f" % _stat("dash_distance"))
# --- C. Flux Regulator ----------------------------------------
state.vessel_levels["dst_drain"] = 3
var expected_drain: float = BASE_BOOST_DRAIN * pow(0.75, 3)
if not is_equal_approx(_stat("boost_drain"), expected_drain):
return _fail("three Flux Regulators drain %.3f/s, expected %.3f" \
% [_stat("boost_drain"), expected_drain])
print("Flux Regulator: sustained boost costs %.2f energy/s" % _stat("boost_drain"))
# --- B. Surge Capacitor, and its floor ------------------------
state.vessel_levels["dst_dash_cooldown"] = 2
if not is_equal_approx(_stat("dash_cooldown"), BASE_DASH_COOLDOWN - 1.8):
return _fail("two Surge Capacitors left a %.2fs recharge, expected %.2f" \
% [_stat("dash_cooldown"), BASE_DASH_COOLDOWN - 1.8])
state.vessel_levels["dst_dash_cooldown"] = 3
if not is_equal_approx(_stat("dash_cooldown"), UPGRADED_COOLDOWN):
return _fail("three Surge Capacitors left a %.2fs recharge, expected %.2f" \
% [_stat("dash_cooldown"), UPGRADED_COOLDOWN])
# Past the branch's own maximum, to prove minimum_stats actually
# holds. Nothing in the game can buy this level; raising max_level
# later would walk straight into the floor, and this says where it
# is rather than leaving it to be discovered.
state.vessel_levels["dst_dash_cooldown"] = 6
if not is_equal_approx(_stat("dash_cooldown"), COOLDOWN_FLOOR):
return _fail("the recharge passed its %.1fs floor, reaching %.2f" \
% [COOLDOWN_FLOOR, _stat("dash_cooldown")])
state.vessel_levels["dst_dash_cooldown"] = 3
print("Surge Capacitor: %.1fs recharge at full research, floored at %.1f" \
% [UPGRADED_COOLDOWN, COOLDOWN_FLOOR])
# A hull built AFTER the research carries the researched recharge:
# apply_stats() is what puts a definition on a component, and a
# stat that never reaches the component upgrades nothing.
fleet.spawn_ships(1, Vector2.ZERO,
PackedStringArray([GameState.VESSEL_DISTORDER]))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
var component: Node = fleet.ships[0].get_component("distorder")
if component == null:
return _fail("the spawned Propulser has no distorder component")
if not is_equal_approx(component.cooldown_duration, UPGRADED_COOLDOWN):
return _fail("the hull recharges in %.2fs while its definition says %.2f" \
% [component.cooldown_duration, UPGRADED_COOLDOWN])
print("the researched recharge reaches the hull itself")
print("TEST_PROPULSER_UPGRADES: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_PROPULSER_UPGRADES: FAIL (%s)" % message)
quit(1)
return true

View file

@ -179,6 +179,55 @@ func _process(delta: float) -> bool:
return _fail("a structure came down in silence")
print("a structure coming down is heard")
# --- G. the things #210/#213/#214 asked to be able to hear -----
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var rock: Node2D = load("res://world/Asteroid.tscn").instantiate()
scene.add_child(rock)
rock.shatter(false)
if _sounding() == 0:
return _fail("an asteroid broke apart in silence")
print("a destroyed asteroid is heard")
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var bomb: Node2D = load("res://vfx/GasBombEffect.gd").launch(
scene, Vector2.ZERO, Vector2(300.0, 0.0), 6)
if _sounding() != 0:
return _fail("the gas bomb made its noise on the throw, not the blast")
bomb._explode()
if _sounding() == 0:
return _fail("a gas bomb bloomed in silence")
print("a gas bomb is heard when it blooms, and not before")
# The hold has to be able to take the deposit, or there is no
# arrival to hear.
game_state.fleet_inventory.total_capacity = -1
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
if game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1) != 1:
return _fail("the fleet hold refused a single rock")
var after_first: int = _sounding()
if after_first == 0:
return _fail("a rock reached the fleet hold in silence")
# Mining lands one unit at a time, so the window is the whole of
# what stops this being a machine-gun.
game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1)
game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1)
if _sounding() != after_first:
return _fail("a burst of pickups opened %d voices, not the %d one blip needs" \
% [_sounding(), after_first])
print("a rock arriving in the hold is heard, and a burst of them only once")
# The bank is a number moving in a building the player is not in.
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
game_state._pickup_sound_at = 0
game_state.deposit_bank_resource(GameState.RESOURCE_ORE, 5)
if _sounding() != 0:
return _fail("a deposit into the bank made a pickup noise")
print("banked resources stay silent")
print("TEST_SFX: PASS")
quit(0)
return true