Compare commits

...
Author SHA1 Message Date
Claude
34e252037e #231, #230: a key per ability, and an upkeep sheet that adds up
#231 - the abilities were hard to use on a keyboard.

"The currently selected weapons" names a real mechanism: MouseCombatController
(#80) has number keys that SELECT which weapon a click fires. That is two
acts for one shot, and the selection is invisible unless you look at the
buttons. Q, E and space now fire the gas bomb, the laser and the dash
outright, wherever the mouse already points.

It goes through fire_at(), the same public entry the click path uses, so
every guard comes with it - no hull ready, cannot afford the shot, a panel
over the button. The number keys stay: they are not what this ticket is
about, and removing them would take the click path's abilities with them.

Q and E were already taken - they pivot the formation - so the pivot pair
moved to Z and C, one row under the movement keys and still left-hand. That
is a change to a control the ticket did not mention, and the one thing here
worth arguing with.

test_keyboard_input spelled Q and E out and so reported a broken pivot when
the pivot was fine. It reads KEY_PIVOT_CW/CCW off the controller now, and
additionally asserts the pair is not bound to an ability key - the failure
that would actually matter.

#230 - the upkeep menu never added anything up.

Two faults, and the second is invisible from the code. There was no TOTAL,
so the bill had to be done in the player's head. And the rows were one Label
padded with %-16s, which aligns only in a monospace font - the theme's is
CarroisGothicSC, which is proportional - with autowrap on top, so a long row
folded and the table lost its shape.

A real GridContainer now: header, a row per paying berth, and a TOTAL. The
headline carries both figures, because they answer different questions - the
per-charge bill is what the player watches happen, the per-minute rate is
what they plan against.

Amounts are listed in the game's resource order rather than the dictionary's.
The screenshot showed "2 ore 1 energy" on one row above "2 gas 2 ore" on the
next, which defeats the point of a column.

The sheet is rebuilt only when the numbers change. BuildController refreshes
this menu every frame while it is open, and rebuilding a dozen Labels per
frame for figures that move every 25 seconds is what #6 and #10 are about.
The test refreshes twice and asserts the same cell objects come back.

Every figure still comes from fleet_upkeep_breakdown(), the calculation the
charging loop itself runs - the new total sums those same per-berth costs
rather than recomputing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:17 -04:00
Claude
4ebd832a70 #232: stop rebuilding the gas vents every frame
GasVentField called queue_redraw() every frame and its _draw rebuilt six
vents of seven glowing polygons each - 168 antialiased polylines
re-tessellated per frame, since every NeonDraw call expands into four.

Measured first: 40 rasterisations over 30 frames, costing 6.6ms against a
32.4ms floor. For scale, the black hole's cached disc costs 0.1ms.

Both halves are pieces now. The sources never move, never recolour and never
fade, so they are rasterised once. The rising bubbles genuinely change size
and opacity, but size is scale and opacity is modulate - neither needs the
geometry rebuilding. 40 rasterisations over 30 frames became 0.

Two things worth recording.

The static extraction alone measured NOTHING - 7.1ms against 6.6ms, inside
the noise. softpipe is a CPU rasteriser: it prices FILL, and cached geometry
fills the same pixels. It cannot see a tessellation saving at all. After the
full change the frame time read NEGATIVE with the floor drifting 28 to 39ms
between runs, so no millisecond figure is claimed here. The rasterisation
count is the reproducible result; what it is worth on a phone is unmeasured.

And scaling a NeonPiece scales its STROKE with it. The bubbles were first
built as unit circles scaled up by their radius, which multiplied NeonDraw's
widths by five to fourteen - a hundred pixels of glow around a fourteen
pixel bubble. They rendered as bright blobs, the rasterisation count said
40 -> 0, and only the screenshot disagreed. Built at the largest radius and
scaled DOWN now, so the stroke only ever thins. The trap is noted at the
call site: scale is safe for moving and shrinking a piece and quietly wrong
for growing one.

NeonPiece.add_polygon gained a width, which it needed to keep the vents'
authored stroke rather than silently fattening them.

179/179.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:16 -04:00
Claude
cb3650e3a9 #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-15 23:44:16 -04:00
Claude
885f977768 #229, #234: one voice per volley, and gas within reach of the spawn
#229 - a volley got louder for every ship in it.

Every hull owns its gun and asks for its own shot sound, so eight ships
firing on the same cooldown started eight voices playing the SAME sample in
the same frame. Those amplitudes add, so a full fleet was the loudest thing
in the game by a wide margin.

play() now takes a per-sound coalescing window. A volley is one event to the
ear, so it gets one voice. 70ms, which the test checks is inside the fastest
gun's 500ms cooldown - it collapses "six guns at once", never "six shots in
a row". PICKUP and ASTEROID_BREAK get one too: ore arrives in bursts when a
rock breaks and that is one pickup to the ear. Deaths and the boss scream
are left alone deliberately - several things dying together IS several
events.

test_sfx's pool-saturation case was driven by shots, which can no longer
saturate anything by design. It is driven by deaths now, which is what the
pool actually has to absorb, and it gained a case asserting a volley takes
one voice - measured against the real pool rather than the gate.

#234 - gas was not reliably in reach of the opening.

Gas giants are gated behind two independent noise fields, the SPACE biome
AND the planet roll, so whether any sat near the spawn was luck. Measured,
this seed lost: ZERO gas planets within four chunks, the nearest five away
past five rocky ones. Gas is an early need - the burner, ammunition - so the
opening cannot depend on the noise being kind.

SPAWN_GAS_GIANT_CHUNK at (2, -2), authored like OCTOPUS_BIOME_CENTER rather
than found by searching outward: a fixed chunk is reproducible from anywhere
without walking the map, which is what the rest of WorldGen is built on.

The override is applied AFTER every rng draw, for the same reason the dev
planting is - forcing one chunk must not shift what any other chunk rolls.
Confirmed by probe: the five other planets within four chunks are identical
before and after, and the distant gas giants at (-5,-4) and (0,-5) are still
where they were.

Both verified in reverse: without the window, "8 ships firing together
started 8 shot sounds"; without the chunk, "no gas planet within 4 chunks of
the spawn".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:15 -04:00
18 changed files with 971 additions and 106 deletions

View file

@ -73,6 +73,35 @@ const LEVELS: Dictionary = {
BLACK_HOLE: -11.0,
}
# Forgejo #229: how long a sound blocks another of its own kind.
#
# Every hull fires its own gun and asks for its own shot sound, so a fleet
# firing a volley started one voice per ship - the same sample, in the same
# frame, at the same level. Those amplitudes ADD, so the volley got louder
# for every ship added, and a full fleet was the loudest thing in the game
# by a wide margin.
#
# A volley is one event to the ear, so it gets one voice. The window is
# shorter than any weapon's cooldown, so consecutive volleys are still heard
# individually - it collapses "six guns at once", never "six shots in a row".
#
# Deaths and the boss scream are absent deliberately: several things dying
# together IS several events, and each is rare enough not to stack.
const COALESCE_MSEC: Dictionary = {
SHOT: 70,
# Ore comes in bursts when a rock breaks, and a burst is one pickup to
# the ear. Quieter than a shot but far more frequent (see LEVELS).
PICKUP: 60,
# Rocks break in twos and threes - that is in the pitch spread comment
# below, and it is the same stacking problem.
ASTEROID_BREAK: 60,
}
# When each sound last started, keyed by id. Static because play() is, and
# the pool outlives every scene that uses it.
static var _last_started_msec: Dictionary = {}
# Shots vary a little in pitch so a stream of them does not turn into one
# flat machine-gun note. The deaths do not: they are events, and a wobbling
# explosion sounds broken rather than varied.
@ -128,6 +157,13 @@ static func play(tree: SceneTree, id: String) -> void:
var stream: AudioStream = SOUNDS[id] as AudioStream
if stream == null:
return
var window: int = int(COALESCE_MSEC.get(id, 0))
if window > 0:
var now: int = Time.get_ticks_msec()
var last: int = int(_last_started_msec.get(id, -1000000))
if now - last < window:
return
_last_started_msec[id] = now
var pool: Node = _pool(tree)
var voice: AudioStreamPlayer = null
for child in pool.get_children():

View file

@ -92,8 +92,11 @@ const KEY_DIRECTIONS: Dictionary = {
KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT,
KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT,
}
const KEY_PIVOT_CW: int = KEY_E
const KEY_PIVOT_CCW: int = KEY_Q
# Z and C rather than Q and E: #231 gives Q, E and space to the abilities, so
# that each one is triggered directly instead of being selected first. Pivot
# stays under the same hand, one row down from the movement keys.
const KEY_PIVOT_CW: int = KEY_C
const KEY_PIVOT_CCW: int = KEY_Z
const WHEEL_ZOOM_STEP: float = 1.12

View file

@ -25,6 +25,18 @@ extends Node2D
# as "nonexistent function" on a node that looks fine in the scene.
const ABILITY_ORDER: PackedStringArray = ["GasBombController", "LaserController", "DashController"]
# Forgejo #231. Selecting a weapon and then clicking is two acts for one
# shot, and on a keyboard the selection is invisible until you look at the
# buttons - which is what made the abilities "hard to use". Each one now has
# a key that fires it outright, at wherever the mouse already is.
#
# Indices into ABILITY_ORDER above, so these stay in step with it.
const ABILITY_KEYS: Dictionary = {
KEY_Q: 0,
KEY_E: 1,
KEY_SPACE: 2,
}
@onready var fleet: Node2D = get_node("../Fleet")
@onready var camera: Camera2D = get_node("../Camera2D")
@onready var fire_stick_controller: Node = get_node_or_null("../FireStickController")
@ -97,6 +109,19 @@ func _unhandled_input(event: InputEvent) -> void:
aim_world = get_global_mouse_position()
return
if event is InputEventKey and event.pressed and not event.echo:
# Fired where the mouse already points, so pressing the key is the
# whole action. fire_at() carries every guard the click path uses -
# no hull ready, cannot afford it, a panel covering the button.
if ABILITY_KEYS.has(event.keycode):
var slot: int = int(ABILITY_KEYS[event.keycode])
if slot < abilities.size():
aim_world = get_global_mouse_position()
abilities[slot].fire_at(aim_world)
get_viewport().set_input_as_handled()
return
# The number keys still choose what a CLICK does. They are not what
# #231 is about, and removing them would take the click path's
# abilities with them.
var index: int = event.keycode - KEY_1
if index >= 0 and index < weapon_count() and select_weapon(index):
get_viewport().set_input_as_handled()

View file

@ -3275,14 +3275,30 @@ func wave_strength(fleet_size: int) -> int:
# roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE
# the player meets chasers and nothing else, however long they take, and each
# vessel past the starting three opens exactly one new kind of trouble.
#
# min_wave is the third gate (#233). min_fleet alone still let a player who
# had grown early meet two kinds of trouble in the FIRST wave they ever saw -
# a fleet of four opened on two lancers and three chasers. Whatever the fleet
# looks like, the opening waves teach one enemy at a time.
const WAVE_ROSTER: Array = [
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6},
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5},
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4},
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0},
{"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6, "min_wave": 4},
{"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5, "min_wave": 3},
{"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4, "min_wave": 2},
{"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0, "min_wave": 0},
]
const WAVE_MAX_ENEMIES: int = 18
# How many can arrive in one wave, early on (#233). The variety gate above
# fixed "too complex" but made the other half of that ticket WORSE: budget
# that used to buy a lancer now buys two more chasers, so a grown fleet's
# FIRST wave went from a mixed six to seven chasers at once.
#
# Strength still rises with the fleet and the wave number - it simply cannot
# all be spent at once yet. What the budget cannot buy is not carried over;
# an early wave is meant to be smaller, not delayed.
func wave_max_enemies() -> int:
return mini(WAVE_MAX_ENEMIES, 2 + wave_number * 2)
func wave_composition(fleet_size: int) -> PackedStringArray:
var budget: int = wave_strength(fleet_size)
@ -3290,13 +3306,15 @@ func wave_composition(fleet_size: int) -> PackedStringArray:
var out: PackedStringArray = PackedStringArray()
for entry in WAVE_ROSTER:
if strength < int(entry.min_strength) \
or fleet_size < int(entry.get("min_fleet", 0)):
or fleet_size < int(entry.get("min_fleet", 0)) \
or wave_number < int(entry.get("min_wave", 0)):
continue
var cost: int = int(entry.cost)
# The cheapest tier is last and takes whatever is left, so the budget
# is always spent rather than rounded away.
var share: int = budget if String(entry.id) == "chaser" else budget / 2
while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES:
var ceiling: int = wave_max_enemies()
while share >= cost and budget >= cost and out.size() < ceiling:
out.append(String(entry.id))
budget -= cost
share -= cost

View file

@ -27,15 +27,59 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn")
var wave_enemies: Array = []
func _process(_delta: float) -> void:
# 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:
wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e))
if GameState.wave_pending:
_launch_wave()
return
if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty():
_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():
GameState.wave_cleared()
# 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)
# 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():
@ -49,18 +93,20 @@ func _launch_wave() -> void:
var radius: float = visible_rect.size.length() / 2.0 + spawn_margin
var centre: Vector2 = fleet.get_centroid()
var bearing: float = randf() * TAU
# 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()
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
var enemy: Node2D = enemy_scene.instantiate()
# Before add_child: Enemy._ready() resolves enemy_id into a definition.
enemy.enemy_id = composition[i]
# #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 = centre + Vector2(cos(angle), sin(angle)) * radius
get_parent().add_child(enemy)
wave_enemies.append(enemy)
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

View file

@ -9,7 +9,23 @@ const RESOURCE_PICKUP_VFX = preload("res://vfx/ResourcePickupVFX.gd")
@export var fleet_path: NodePath = NodePath("../Fleet")
@onready var fleet: Node2D = get_node(fleet_path)
const SOURCE_COLOUR: Color = Color(0.85, 0.52, 1.0)
const CORE_COLOUR: Color = Color(0.15, 0.03, 0.22)
# Offsets from a vent's centre, and each bubble's radius.
const SOURCE_BUBBLES: Array = [
[Vector2(-20.0, 8.0), 20.0],
[Vector2(18.0, 14.0), 27.0],
[Vector2(17.0, -22.0), 15.0],
]
const BUBBLES_PER_VENT: int = 4
const BUBBLE_MIN_RADIUS: float = 5.0
const BUBBLE_MAX_RADIUS: float = 14.0
const EMISSION_COLOUR: Color = Color(0.92, 0.68, 1.0)
var vents: Array[Vector2] = []
# One row of rising-bubble pieces per vent.
var bubbles: Array = []
var progress: float = 0.0
var emission_phase: float = 0.0
func _ready() -> void:
@ -24,10 +40,73 @@ func _ready() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = hash(GameState.planet_id if GameState.planet_id != "" else GameState.planet_name)
for i in range(6): vents.append(Vector2(rng.randf_range(-800,800), rng.randf_range(-650,650)))
queue_redraw()
_build_sources()
_build_bubbles()
_place_bubbles()
# The sources never move, never change colour and never fade - they were
# simply being re-tessellated every frame alongside the bubbles rising out of
# them, because one _draw() covered both. Eighteen glowing polygons a frame,
# each of which NeonDraw expands into four antialiased polylines. Rasterised
# once into a piece each now, per the rule issues #6 and #10 set.
func _build_sources() -> void:
for vent in vents:
var piece: NeonPiece = NeonPiece.create(self, vent, SOURCE_COLOUR)
piece.core_color = CORE_COLOUR
piece.z_index = -1
for bubble in SOURCE_BUBBLES:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(Vector2(bubble[0]) + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
piece.add_polygon(ring, 0.9, 0.6)
# One piece per rising bubble, built as a UNIT circle and thereafter only
# moved, scaled and faded. The bubble genuinely changes size and opacity as
# it climbs, which is why this used to redraw - but size is scale and opacity
# is modulate, and neither needs the geometry rebuilding.
func _build_bubbles() -> void:
for vent in vents:
var row: Array = []
for i in range(BUBBLES_PER_VENT):
var piece: NeonPiece = NeonPiece.create(self, vent, EMISSION_COLOUR)
piece.core_color = CORE_COLOUR
piece.z_index = -1
# Built at the LARGEST size a bubble reaches and scaled DOWN,
# never up. Scaling a piece scales its stroke with it, and a unit
# circle blown up to radius 14 took NeonDraw's widths with it -
# a hundred pixels of glow around a fourteen pixel bubble, which
# rendered as a bright blob rather than a bubble. Downward, the
# stroke only ever thins, which is what a smaller bubble wants.
var ring := PackedVector2Array()
for point in range(10):
var angle: float = TAU * float(point) / 10.0
ring.append(Vector2(cos(angle), sin(angle)) * BUBBLE_MAX_RADIUS)
piece.add_polygon(ring, 0.75, 0.45)
row.append(piece)
bubbles.append(row)
# Where one bubble sits, how big it is and how faded, for a point in its
# climb. Unchanged arithmetic - only the thing it is applied to changed.
func _place_bubbles() -> void:
for v in range(vents.size()):
for i in range(BUBBLES_PER_VENT):
var travel: float = fmod(emission_phase + float(i) * 0.25, 1.0)
var piece: NeonPiece = bubbles[v][i]
piece.position = vents[v] + Vector2(
sin((travel + float(i)) * TAU) * 11.0,
-48.0 - travel * 92.0)
var radius: float = lerpf(BUBBLE_MIN_RADIUS, BUBBLE_MAX_RADIUS, travel)
var k: float = radius / BUBBLE_MAX_RADIUS
piece.scale = Vector2(k, k)
piece.modulate.a = 1.0 - travel * 0.72
func _process(delta: float) -> void:
emission_phase = fmod(emission_phase + delta * 0.55, 1.0)
queue_redraw()
# Node properties only. Nothing is rasterised after the first frame.
_place_bubbles()
for vent in vents:
if fleet.get_centroid().distance_to(vent) < 90.0:
progress += delta
@ -42,43 +121,3 @@ func _process(delta: float) -> void:
to_global(vent), GameState.RESOURCE_GAS, taken)
return
progress = 0.0
func _draw() -> void:
for vent in vents:
_draw_gas_source(vent)
_draw_emission(vent)
func _draw_gas_source(centre: Vector2) -> void:
var colour := Color(0.85, 0.52, 1.0)
var core := Color(0.15, 0.03, 0.22)
var bubbles: Array = [
[centre + Vector2(-20.0, 8.0), 20.0],
[centre + Vector2(18.0, 14.0), 27.0],
[centre + Vector2(17.0, -22.0), 15.0],
]
for bubble in bubbles:
var ring := PackedVector2Array()
for i in range(12):
var angle: float = TAU * float(i) / 12.0
ring.append(bubble[0] + Vector2(cos(angle), sin(angle)) * float(bubble[1]))
NeonDraw.draw_glowing_polygon(self, ring, colour, 0.9, core, 0.6)
func _draw_emission(centre: Vector2) -> void:
# Small bubbles continuously rise out of every source. This remains
# animated even when the fleet is not collecting, so the vent reads as
# an active gas generator rather than a static decoration.
var core := Color(0.15, 0.03, 0.22)
for i in range(4):
var travel: float = fmod(emission_phase + float(i) * 0.25, 1.0)
var bubble_centre := centre + Vector2(
sin((travel + float(i)) * TAU) * 11.0,
-48.0 - travel * 92.0
)
var radius: float = lerpf(5.0, 14.0, travel)
var colour := Color(0.92, 0.68, 1.0, 1.0 - travel * 0.72)
var ring := PackedVector2Array()
for point in range(10):
var angle: float = TAU * float(point) / 10.0
ring.append(bubble_centre + Vector2(cos(angle), sin(angle)) * radius)
NeonDraw.draw_glowing_polygon(self, ring, colour, 0.75, core, 0.45)

121
tests/test_ability_keys.gd Normal file
View file

@ -0,0 +1,121 @@
extends SceneTree
# Forgejo #231: on a computer each ability has a key that fires it outright.
#
# Selecting a weapon with a number key and then clicking is two acts for one
# shot, and the selection is invisible until you look at the buttons - which
# is what made the abilities hard to use with a keyboard. Q, E and space now
# fire the gas bomb, the laser and the dash where the mouse already points.
#
# Covers:
# A. the three keys map to the three abilities, in the documented order
# B. pressing one fires THAT ability and no other
# C. it fires at the mouse, so pointing is the aim
# D. Q and E no longer pivot, which is what they used to do
# E. the number keys still choose what a CLICK does - #231 replaces the
# selection as a way to FIRE, not the click path itself
# F. touch is untouched: on a phone these keys do nothing
var scene: Node
var mouse: Node
var controller: Node
var game_state: Node
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.reset_new_game()
game_state.intro_active = false
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
mouse = scene.get_node("MouseCombatController")
# GameController is the ROOT of Main.tscn, not a child of it - the same
# script roots the planet surface too.
controller = scene
func _key(code: int) -> InputEventKey:
var event := InputEventKey.new()
event.keycode = code
event.pressed = true
return event
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.4:
return false
# --- A. the mapping is the documented one ----------------------------
if mouse.ABILITY_ORDER.size() != 3:
return _fail("expected 3 abilities, found %d" % mouse.ABILITY_ORDER.size())
var expected: Dictionary = {KEY_Q: "GasBombController", KEY_E: "LaserController",
KEY_SPACE: "DashController"}
for code in expected:
if not mouse.ABILITY_KEYS.has(code):
return _fail("key %d is not bound to an ability" % code)
var slot: int = int(mouse.ABILITY_KEYS[code])
if String(mouse.ABILITY_ORDER[slot]) != String(expected[code]):
return _fail("key %d fires %s, expected %s" \
% [code, mouse.ABILITY_ORDER[slot], expected[code]])
print("Q, E and space map to the bomb, the laser and the dash")
# --- D. and they no longer pivot --------------------------------------
if controller.KEY_PIVOT_CW in [KEY_Q, KEY_E, KEY_SPACE] \
or controller.KEY_PIVOT_CCW in [KEY_Q, KEY_E, KEY_SPACE]:
return _fail("pivot still uses an ability key (cw=%d ccw=%d)" \
% [controller.KEY_PIVOT_CW, controller.KEY_PIVOT_CCW])
print("pivot moved to %d/%d, clear of the ability keys" \
% [controller.KEY_PIVOT_CCW, controller.KEY_PIVOT_CW])
# --- B/C. the KEY itself reaches that ability, aimed at the mouse -----
# Driven through _unhandled_input rather than by calling fire_at(), or
# this would prove the abilities work and nothing about the binding.
#
# Observed through aim_world: the ability branch overwrites it with the
# live cursor before firing, so a value that survives means the branch
# never ran. The abilities themselves are locked in a fresh game, so
# fire_at() correctly refuses - the wiring is what is under test here.
mouse.pointer_mode = 1 # PointerMode.ON - a headless display reports no mouse
var sentinel := Vector2(98765.0, -4321.0)
for code in [KEY_Q, KEY_E, KEY_SPACE]:
mouse.aim_world = sentinel
mouse._unhandled_input(_key(code))
if mouse.aim_world == sentinel:
var slot: int = int(mouse.ABILITY_KEYS[code])
return _fail("key %d never reached %s" % [code, mouse.ABILITY_ORDER[slot]])
print("Q, E and space each reach their ability through the input path")
# A key bound to nothing must fall through to the number-key branch
# rather than being swallowed by the ability one.
mouse.aim_world = sentinel
mouse._unhandled_input(_key(KEY_K))
if mouse.aim_world != sentinel:
return _fail("an unbound key was treated as an ability trigger")
print("an unbound key is left alone")
# --- E. the number keys still select for the click path ---------------
if not mouse.select_weapon(1):
return _fail("the number keys no longer select a weapon for a click")
if mouse.selected_weapon != 1:
return _fail("selecting weapon 1 left selected_weapon at %d" % mouse.selected_weapon)
print("the number keys still choose what a click fires")
# --- F. and none of this reaches a phone ------------------------------
mouse.pointer_mode = 2 # PointerMode.OFF
if mouse.is_enabled():
return _fail("mouse combat is live with the pointer mode off")
print("with a touch pointer the keys are inert")
print("TEST_ABILITY_KEYS: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_ABILITY_KEYS: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1,150 @@
extends SceneTree
# Forgejo #230: the upkeep menu shows what the fleet costs IN TOTAL, laid out
# as a table.
#
# It listed each berth and never added them up, so the bill had to be done in
# the player's head. The rows were also one Label padded with %-16s, which
# lines up only in a monospace font - the theme's is CarroisGothicSC, which
# is proportional, so the "columns" never actually aligned. Worse, that Label
# had autowrap on, so a long row folded and the table lost its shape.
#
# Covers:
# A. a row per paying berth, in a real grid
# B. a TOTAL row, and it equals the sum of the rows
# C. the headline states both the per-charge bill and the per-minute rate
# D. an unpaid berth is marked
# E. with nothing to charge, the table gives way to an explanation
# F. the sheet is not rebuilt on every frame - the menu refreshes at frame
# rate and these numbers move every 25 seconds
var game_state: Node
var scene: Node
var menu: Control
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_new_game()
game_state.reset_quests()
game_state.intro_active = false
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
menu = scene.get_node("UI/FleetUpkeepMenu")
func _rows() -> Array:
# Grid cells come out in reading order; COLUMNS wide makes one row.
var width: int = menu.sheet.columns
var cells: Array = []
for child in menu.sheet.get_children():
cells.append(String((child as Label).text))
var rows: Array = []
for i in range(0, cells.size(), width):
rows.append(cells.slice(i, i + width))
return rows
func _process(delta: float) -> bool:
elapsed += delta
if elapsed < 0.4:
return false
# --- E. nothing to charge -------------------------------------------
menu.open_menu()
menu.refresh()
if not menu.empty_label.visible or menu.sheet.visible:
return _fail("with no berths the table is shown instead of an explanation")
if not menu.total_label.text.contains("NOTHING"):
return _fail("with no berths the headline reads '%s'" % menu.total_label.text)
print("with nothing to charge: '%s'" % menu.total_label.text)
# Two offices, two crewed berths - the second costs more than the first.
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2.ZERO)
game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(600.0, 0.0))
game_state.fleet_vessel_types = PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR,
GameState.VESSEL_INTERCEPTOR])
menu.refresh()
var breakdown: Dictionary = game_state.fleet_upkeep_breakdown()
if breakdown.berths.size() < 2:
return _fail("only %d berth(s) are being charged - that proves nothing about a total" \
% breakdown.berths.size())
# --- A. a row per berth, plus header and total ------------------------
var rows: Array = _rows()
if not menu.sheet.visible or menu.empty_label.visible:
return _fail("the table is hidden while berths are being charged")
if rows.size() != breakdown.berths.size() + 2:
return _fail("%d rows for %d berths - expected a header and a total too" \
% [rows.size(), breakdown.berths.size()])
if String(rows[0][0]) != "BERTH":
return _fail("the first row is not a header: %s" % str(rows[0]))
print("%d berths -> %d rows (header, %d berths, total)" \
% [breakdown.berths.size(), rows.size(), breakdown.berths.size()])
# --- B. and the total is the sum of the rows --------------------------
var total_row: Array = rows[rows.size() - 1]
if String(total_row[0]) != "TOTAL":
return _fail("the last row is '%s', not a total" % total_row[0])
var summed: int = 0
for berth in breakdown.berths:
for resource_type in berth.costs:
summed += int(berth.costs[resource_type])
var shown: int = 0
for word in String(total_row[2]).split(" "):
if word.is_valid_int():
shown += int(word)
if shown != summed:
return _fail("the total row reads '%s' (%d), but the berths add to %d" \
% [total_row[2], shown, summed])
if summed <= 0:
return _fail("the berths cost nothing, so a total proves nothing")
print("total row '%s' matches the %d the berths add to" % [total_row[2], summed])
# --- C. the headline carries both figures -----------------------------
var headline: String = menu.total_label.text
if not headline.contains("PER MINUTE") or not headline.contains("EVERY"):
return _fail("the headline does not state both the bill and the rate: '%s'" % headline)
print("headline: %s" % headline)
# --- F. and it is not rebuilt every frame -----------------------------
var first: Array = menu.sheet.get_children()
menu.refresh()
menu.refresh()
var again: Array = menu.sheet.get_children()
if first.size() != again.size():
return _fail("the row count changed across a refresh with no change behind it")
for i in range(first.size()):
if first[i] != again[i]:
return _fail("refreshing rebuilt the table though nothing changed")
print("refreshing twice reuses the same %d cells" % again.size())
# --- D. an unpaid berth is called out ---------------------------------
for building in game_state.buildings:
if String(building.type) == GameState.BUILDING_FLEET_OFFICE:
building["starved"] = true
menu.refresh()
var marked: bool = false
for row in _rows():
if String(row[4]) == "UNPAID":
marked = true
if not marked:
return _fail("a starved berth is not marked unpaid")
print("a starved berth is marked UNPAID")
print("TEST_FLEET_UPKEEP_SHEET: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_FLEET_UPKEEP_SHEET: FAIL (%s)" % message)
quit(1)
return true

View file

@ -4,7 +4,7 @@ extends SceneTree
# godot --headless -s res://tests/test_keyboard_input.gd
# Desktop/web controls. The phone build is driven entirely by touch, but the
# web build is played with a keyboard and mouse, so WASD (and the arrows)
# must steer the fleet, Q/E must pivot the formation, and releasing the keys
# must steer the fleet, the pivot pair must turn the formation, and releasing
# must stop - the stick has a centred-again signal to hook, the keyboard has
# nothing equivalent, so a missed release would leave the fleet flying off
# forever.
@ -127,21 +127,32 @@ func _process(delta: float) -> bool:
quit(1)
return true
# --- Q/E pivot ------------------------------------------------
# --- the pivot pair -------------------------------------------
# Read off the controller's own constants rather than naming the
# keys again here. #231 moved this pair from Q/E to Z/C because
# the abilities took Q, E and space, and a test that spelled the
# old keys out reported a broken pivot when the pivot was fine.
var game_state: Node = root.get_node("GameState")
var cw_key: int = controller.KEY_PIVOT_CW
var ccw_key: int = controller.KEY_PIVOT_CCW
if cw_key in [KEY_Q, KEY_E, KEY_SPACE] or ccw_key in [KEY_Q, KEY_E, KEY_SPACE]:
print("TEST_KEYBOARD_INPUT: FAIL (pivot is bound to an ability key)")
quit(1)
return true
var before: float = game_state.formation[1].angle()
_key(KEY_E, true)
_key(KEY_E, false)
_key(cw_key, true)
_key(cw_key, false)
var after_cw: float = game_state.formation[1].angle()
_key(KEY_Q, true)
_key(KEY_Q, false)
_key(ccw_key, true)
_key(ccw_key, false)
var after_ccw: float = game_state.formation[1].angle()
var cw_turn: float = angle_difference(before, after_cw)
var ccw_turn: float = angle_difference(after_cw, after_ccw)
print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn])
print("key %d turned %.3f rad, key %d turned %.3f rad" \
% [cw_key, cw_turn, ccw_key, ccw_turn])
if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(ccw_turn):
print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)")
print("TEST_KEYBOARD_INPUT: FAIL (the pivot pair did not turn in opposite directions)")
quit(1)
return true

View file

@ -262,6 +262,12 @@ func _process(delta: float) -> bool:
# 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.
# 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
var kinds: Dictionary = {}
var lancer_at: int = 0
for size in range(1, 12):
@ -269,6 +275,7 @@ func _process(delta: float) -> bool:
kinds[id] = true
if id == "lancer" and lancer_at == 0:
lancer_at = size
game_state.wave_number = settled_wave
if lancer_at == 0:
return _fail("no wave against a fleet of 1..11 contains a lancer")
if not kinds.has("chaser"):

View file

@ -193,23 +193,39 @@ func _process(delta: float) -> bool:
if _pool() != null and _sounding() > 0:
return _fail("something was already sounding before anything happened")
# --- B. a shot, and then far more than there are voices --
# --- B. more at once than there are voices ---------------
Sfx.play(self, Sfx.SHOT)
if _sounding() != 1:
return _fail("firing played %d sounds, expected 1" % _sounding())
if _pool().get_parent() != root:
return _fail("the sound pool hangs off the scene - a death would take it with it")
# Driven by a DEATH, not a shot. #229 coalesces shots - a volley
# is one event to the ear and now gets one voice - so shots can
# no longer saturate anything. Deaths carry no window, because
# several things dying together really is several events, and
# they are what the pool has to absorb now.
for i in range(Sfx.VOICES * 3):
Sfx.play(self, Sfx.SHOT)
Sfx.play(self, Sfx.SHIP_DEATH)
var busy: int = _sounding()
if busy != Sfx.VOICES:
return _fail("%d voices sounding after %d shots, the pool holds %d" \
return _fail("%d voices sounding after %d deaths, the pool holds %d" \
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
if _pool().get_child_count() != Sfx.VOICES:
return _fail("the pool grew to %d voices" % _pool().get_child_count())
print("%d shots into a %d-voice pool: %d sounding, none leaked" \
print("%d deaths into a %d-voice pool: %d sounding, none leaked" \
% [Sfx.VOICES * 3, Sfx.VOICES, busy])
# --- G. and a volley does not stack (#229) ----------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
Sfx._last_started_msec.clear()
for i in range(Sfx.VOICES * 3):
Sfx.play(self, Sfx.SHOT)
if _sounding() != 1:
return _fail("%d simultaneous shots took %d voices - that is the volley getting louder per ship" \
% [Sfx.VOICES * 3, _sounding()])
print("%d simultaneous shots still take 1 voice" % (Sfx.VOICES * 3))
# --- F. and a dialog does not silence it -----------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()

View file

@ -0,0 +1,134 @@
extends SceneTree
# Forgejo #229: a volley does not get louder for every ship in the fleet.
#
# Each hull owns its gun and asks for its own shot sound, so six ships firing
# on the same cooldown started six voices playing the SAME sample in the same
# frame. Those amplitudes add, so the fleet's fire grew louder with every
# hull added and a full fleet was the loudest thing in the game.
#
# Covers:
# A. a whole fleet's simultaneous shots produce ONE voice, not one each
# B. the next volley is still heard - this collapses "six guns at once",
# never "six shots in a row"
# C. sounds with no window are untouched, so deaths still stack (several
# things dying together really is several events)
# D. the fleet size makes no difference to how many voices start
const VOLLEY: int = 8
func _initialize() -> void:
pass
func _voices_playing() -> int:
var pool: Node = root.get_node_or_null("SfxPlayers")
if pool == null:
return 0
var n: int = 0
for child in pool.get_children():
if (child as AudioStreamPlayer).playing:
n += 1
return n
func _process(_delta: float) -> bool:
# The headless build has a Dummy audio driver, so `playing` is not a
# reliable readout. Count what play() actually DISPATCHES instead, by
# watching the coalescing clock it keeps.
Sfx.warm(self)
# --- A. one volley, one voice ---------------------------------------
Sfx._last_started_msec.clear()
var started: int = 0
for i in range(VOLLEY):
if _dispatches(Sfx.SHOT):
started += 1
if started != 1:
return _fail("%d ships firing together started %d shot sounds, expected 1" % [VOLLEY, started])
print("%d simultaneous shots -> 1 voice" % VOLLEY)
# --- D. and that holds however big the fleet is ----------------------
for size in [2, 4, 16, 32]:
Sfx._last_started_msec.clear()
var n: int = 0
for i in range(size):
if _dispatches(Sfx.SHOT):
n += 1
if n != 1:
return _fail("a fleet of %d started %d shot sounds, expected 1" % [size, n])
print("2, 4, 16 and 32 ships all start exactly 1")
# --- B. the next volley is still heard -------------------------------
Sfx._last_started_msec.clear()
if not _dispatches(Sfx.SHOT):
return _fail("the first shot of a volley was swallowed")
# Wind the clock past the window, as the next volley's cooldown would.
var window: int = int(Sfx.COALESCE_MSEC[Sfx.SHOT])
Sfx._last_started_msec[Sfx.SHOT] = Time.get_ticks_msec() - window - 1
if not _dispatches(Sfx.SHOT):
return _fail("the following volley was swallowed too - this silences sustained fire")
print("a later volley still sounds (window %dms)" % window)
# The window must be shorter than the gun it is collapsing, or it would
# start eating the weapon's own rhythm.
var fastest: float = _fastest_fire_cooldown()
if fastest <= 0.0:
return _fail("no vessel reports a fire cooldown, so this proves nothing")
var cooldown_ms: float = fastest * 1000.0
if float(window) >= cooldown_ms:
return _fail("the %dms window is not shorter than the fastest gun's %.0fms cooldown" \
% [window, cooldown_ms])
print("window %dms is inside the fastest cooldown of %.0fms" % [window, cooldown_ms])
# --- C. unwindowed sounds are untouched -------------------------------
Sfx._last_started_msec.clear()
var deaths: int = 0
for i in range(4):
if _dispatches(Sfx.SHIP_DEATH):
deaths += 1
if deaths != 4:
return _fail("4 deaths together produced %d sounds - deaths must still stack" % deaths)
print("4 simultaneous deaths still produce 4 sounds")
print("TEST_SHOT_VOLLEY_LOUDNESS: PASS")
quit(0)
return true
# Would play() actually start a voice for this id right now? Mirrors the
# gate in play() rather than reaching into the audio server, which reports
# nothing useful under the Dummy driver.
func _dispatches(id: String) -> bool:
var window: int = int(Sfx.COALESCE_MSEC.get(id, 0))
if window <= 0:
Sfx.play(self, id)
return true
var now: int = Time.get_ticks_msec()
var last: int = int(Sfx._last_started_msec.get(id, -1000000))
Sfx.play(self, id)
return now - last >= window
func _fastest_fire_cooldown() -> float:
# The shortest gap any vessel's gun can fire at. ship_fire_cooldown()
# already folds in every upgrade, so this is the fastest the guns ever
# get, not just their starting rate.
var best: float = INF
var gs: Node = root.get_node("GameState")
for vessel_type in [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_CARRIER,
GameState.VESSEL_LASER, GameState.VESSEL_DISTORDER]:
var cooldown: float = gs.ship_fire_cooldown(vessel_type)
if cooldown > 0.0:
best = minf(best, cooldown)
if is_inf(best):
return -1.0
return best
func _fail(message: String) -> bool:
print("TEST_SHOT_VOLLEY_LOUDNESS: FAIL (%s)" % message)
quit(1)
return true

View file

@ -0,0 +1,102 @@
extends SceneTree
# Forgejo #234: gas is always in reach of where the game starts.
#
# Gas giants are gated behind two independent noise fields - the SPACE biome
# AND the planet roll - so whether any sat near the spawn was luck. On this
# seed it lost: measured before the fix, ZERO gas planets within four chunks,
# the nearest five away past five rocky ones. Gas is an early need (the
# burner, ammunition), so the opening cannot depend on the noise being kind.
#
# Covers:
# A. a gas planet exists within four chunks of the spawn
# B. it is a real planet - it spawns, and it actually yields gas
# C. it is not sitting on top of the spawn itself
# D. forcing it does not reshuffle any other chunk, which is the property
# the whole of WorldGen is built on
const SPAWN_CHUNK: Vector2i = Vector2i(0, 0)
const REACH: int = 4
var WG
var PD
func _initialize() -> void:
WG = load("res://world/WorldGen.gd")
PD = load("res://planets/PlanetDefinitions.gd")
func _process(_delta: float) -> bool:
# --- A. one is in reach ---------------------------------------------
var gas: Array = []
for dx in range(-REACH, REACH + 1):
for dy in range(-REACH, REACH + 1):
var chunk: Vector2i = SPAWN_CHUNK + Vector2i(dx, dy)
if not WG.has_planet_at_chunk(chunk):
continue
if String(WG.planet_data_at_chunk(chunk).get("definition_id", "")) == PD.GAS_GIANT_ID:
gas.append(chunk)
if gas.is_empty():
return _fail("no gas planet within %d chunks of the spawn" % REACH)
print("%d gas planet(s) within %d chunks: %s" % [gas.size(), REACH, gas])
# --- C. and not underneath the fleet ---------------------------------
if gas.has(SPAWN_CHUNK):
return _fail("the guaranteed gas planet is on the spawn chunk itself")
# --- B. it is a real, generated, gas-bearing planet -------------------
var chunk: Vector2i = WG.SPAWN_GAS_GIANT_CHUNK
var chebyshev: int = maxi(absi(chunk.x - SPAWN_CHUNK.x), absi(chunk.y - SPAWN_CHUNK.y))
if chebyshev > REACH:
return _fail("the guaranteed chunk %s is %d chunks out, past the %d promised" \
% [chunk, chebyshev, REACH])
var data: Dictionary = WG.planet_data_at_chunk(chunk)
if String(data.get("definition_id", "")) != PD.GAS_GIANT_ID:
return _fail("the guaranteed chunk is '%s', not a gas giant" % data.get("definition_id", ""))
var definition: Resource = PD.get_definition(PD.GAS_GIANT_ID)
if String(definition.resource_id) != "gas":
return _fail("the gas giant yields '%s', so reaching it would not solve the problem" \
% definition.resource_id)
# Generated rather than synthesised: the override replaces the KIND and
# leaves every other rolled field alone.
if String(data.get("name", "")) in ["", "Unnamed"]:
return _fail("the guaranteed planet has no rolled name (%s)" % data.get("name", ""))
var local: Vector2 = data.get("local_pos", Vector2(-1, -1))
if local.x < 0.0 or local.y < 0.0 or local.x > WG.CHUNK_SIZE or local.y > WG.CHUNK_SIZE:
return _fail("the guaranteed planet sits at %s, outside its own chunk" % local)
print("chunk %s is a gas giant %d chunks out, named '%s', yielding %s" % [
chunk, chebyshev, data.get("name", ""), definition.resource_id])
# --- D. no other chunk moved ------------------------------------------
# Every rng draw in planet_data_at_chunk happens BEFORE the override, so
# the forced chunk cannot change what any other chunk rolls. Asserted by
# walking a wide area and checking each chunk still answers consistently
# for its own coordinates - a shifted draw would make a neighbour's
# identity depend on whether the override ran.
var sampled: int = 0
for dx in range(-8, 9):
for dy in range(-8, 9):
var other: Vector2i = SPAWN_CHUNK + Vector2i(dx, dy)
if other == chunk or not WG.has_planet_at_chunk(other):
continue
var a: Dictionary = WG.planet_data_at_chunk(other)
var b: Dictionary = WG.planet_data_at_chunk(other)
if a.get("definition_id") != b.get("definition_id") \
or a.get("shape_seed") != b.get("shape_seed") \
or a.get("name") != b.get("name"):
return _fail("chunk %s generates differently on two calls" % other)
sampled += 1
if sampled < 10:
return _fail("only %d other planets sampled - that proves nothing about the rest of the map" % sampled)
print("%d other planets nearby still generate deterministically" % sampled)
print("TEST_SPAWN_GAS_PLANET: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_SPAWN_GAS_PLANET: FAIL (%s)" % message)
quit(1)
return true

View file

@ -108,12 +108,34 @@ func _state_cases() -> bool:
game_state.wave_number = before_number
if later.size() <= small.size():
return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation")
# A big wave must not be the same swarm at greater length.
# A big wave must not be the same swarm at greater length - but read at a
# wave number where variety is ALLOWED. #233 gates each enemy kind behind
# a minimum wave as well as a minimum fleet, so that the openings teach
# one enemy at a time however big the fleet already is; a fleet of nine
# meeting only chasers in wave 1 is now the intent, not the swarm this
# case is guarding against.
game_state.wave_number = before_number + 6
var varied: PackedStringArray = game_state.wave_composition(9)
game_state.wave_number = before_number
var heavy_kinds: Dictionary = {}
for id in large:
for id in varied:
heavy_kinds[id] = true
if heavy_kinds.size() < 2:
return _fail("a large wave is %d copies of one enemy" % large.size())
return _fail("a large wave at wave %d is %d copies of one enemy" \
% [before_number + 6, varied.size()])
# And the openings really are single-kind, whatever the fleet looks like.
game_state.wave_number = 1
var opener: PackedStringArray = game_state.wave_composition(9)
game_state.wave_number = before_number
var opening_kinds: Dictionary = {}
for id in opener:
opening_kinds[id] = true
if opening_kinds.size() != 1:
return _fail("wave 1 against a fleet of 9 already mixes %d kinds" % opening_kinds.size())
if opener.size() > game_state.wave_max_enemies():
return _fail("wave 1 sends %d enemies, past the %d an opening wave may" \
% [opener.size(), game_state.wave_max_enemies()])
if large.size() > GameState.WAVE_MAX_ENEMIES:
return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES])
print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \
@ -140,15 +162,23 @@ func _state_cases() -> bool:
# Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting
# capacity to 1, and this is about the roster's own thresholds (4, 5, 6),
# not about where the fleet happens to begin.
# Read past the opening waves: #233 also gates each kind behind a minimum
# WAVE, so that the first waves teach one enemy at a time whatever the
# fleet looks like. This case is about the roster's FLEET thresholds, so
# it asks at a wave number where every kind is already permitted.
var settled: int = game_state.wave_number
game_state.wave_number = 8
var previous_kinds: int = 1
for size in range(3, 8):
var kinds_here: Dictionary = {}
for id in game_state.wave_composition(size):
kinds_here[id] = true
if kinds_here.size() < previous_kinds:
game_state.wave_number = settled
return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \
% [size, kinds_here.size(), previous_kinds])
previous_kinds = kinds_here.size()
game_state.wave_number = settled
if previous_kinds < 3:
return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds)
print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds)
@ -244,6 +274,22 @@ func _process(delta: float) -> bool:
print("wave %d arrived: %d enemies, nearest %.0fpx away" \
% [game_state.wave_number, arrived, nearest])
# #233 lands a wave over several seconds rather than in one
# frame, so "the whole wave" is not on the field yet. Run the
# arrivals out first - otherwise this kills the one hull that
# has landed and calls that the wave cleared.
for i in range(40):
spawner._process(GameState.WAVE_MAX_SECONDS)
if spawner.arriving.is_empty():
break
if not spawner.arriving.is_empty():
return _fail("%d hulls never finished arriving" % spawner.arriving.size())
var landed: int = _enemy_count()
if landed <= arrived:
return _fail("running the arrivals out added nothing (%d then %d) - the wave is not staggered" \
% [arrived, landed])
print("the rest of the wave followed: %d -> %d enemies" % [arrived, landed])
for enemy in get_nodes_in_group("enemies"):
if is_instance_valid(enemy):
enemy.queue_free()

View file

@ -14,10 +14,22 @@ extends "res://ui/menus/ModalMenu.gd"
# of "the game is lying to me" the ticket is about.
@onready var total_label: Label = $TotalLabel
@onready var detail_label: Label = $DetailLabel
@onready var sheet: GridContainer = $Sheet
@onready var empty_label: Label = $EmptyLabel
@onready var formula_label: Label = $FormulaLabel
const HEADER_COLOR: Color = Color(0.55, 0.78, 0.86)
const CELL_COLOR: Color = Color(0.72, 0.88, 0.96)
const TOTAL_COLOR: Color = Color(1.0, 0.85, 0.35)
const UNPAID_COLOR: Color = Color(1.0, 0.45, 0.4)
const COLUMNS: Array = ["BERTH", "VESSEL", "PER CHARGE", "RATE", ""]
var building_id: int = 0
# What the sheet was last built from. The menu is refreshed every frame while
# it is open (see BuildController), and rebuilding a dozen Labels per frame to
# show numbers that change every 25 seconds is the thing issues #6 and #10
# are about.
var _built_from: String = ""
func open_for(next_building_id: int) -> void:
@ -38,34 +50,99 @@ func refresh() -> void:
var berths: Array = breakdown.berths
var per_minute: Dictionary = breakdown.per_minute
# The bill, both ways round: what leaves the stores at each charge, and
# what that adds up to over a minute. The per-charge figure is the one
# the player watches happen; the rate is the one they plan against.
var totals: Dictionary = {}
for berth in berths:
for resource_type in berth.costs:
totals[resource_type] = int(totals.get(resource_type, 0)) \
+ int(berth.costs[resource_type])
if per_minute.is_empty():
total_label.text = "NOTHING TO KEEP"
else:
var parts: PackedStringArray = PackedStringArray()
for resource_type in per_minute:
parts.append("%.1f %s" % [float(per_minute[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
total_label.text = "%s PER MINUTE" % " · ".join(parts)
total_label.text = "%s EVERY %dS · %s PER MINUTE" % [
_amounts(totals), int(breakdown.charge_seconds), _rates(per_minute)]
# One line per paying berth: which hull, what it is charged, and the
# multiplier that got it there. An empty berth is charged nothing and is
# left out rather than listed at zero.
var lines: PackedStringArray = PackedStringArray()
for berth in berths:
var costs: PackedStringArray = PackedStringArray()
for resource_type in berth.costs:
costs.append("%d %s" % [int(berth.costs[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
lines.append("BERTH %d %-16s %s (x%.2f)%s" % [
int(berth.slot), GameState.vessel_name(String(berth.vessel_type)),
" ".join(costs), float(berth.scale),
" — UNPAID" if bool(berth.starved) else "",
])
detail_label.text = "\n".join(lines) if not lines.is_empty() \
else "No berth is being charged. Build a Fleet Office and put a vessel in it."
var signature: String = "%s|%s" % [str(berths), str(totals)]
if signature != _built_from:
_built_from = signature
_build_sheet(berths, totals)
formula_label.text = ("Each berth is charged every %ds: the vessel's build price "
empty_label.visible = berths.is_empty()
sheet.visible = not berths.is_empty()
empty_label.text = "No berth is being charged. Build a Fleet Office and put a vessel in it."
formula_label.text = ("Each berth is charged every %ds: the vessel\'s build price "
+ "divided by %d, then multiplied by %.2f for every berth past the first "
+ "paid one. The first vessel is free.") % [
int(breakdown.charge_seconds), int(breakdown.divisor),
float(breakdown.growth)]
# One row per paying berth, plus a totals row - a berth standing empty is
# charged nothing and is left out rather than listed at zero.
func _build_sheet(berths: Array, totals: Dictionary) -> void:
for child in sheet.get_children():
child.free()
if berths.is_empty():
return
for column in COLUMNS:
_cell(String(column), HEADER_COLOR)
for berth in berths:
var starved: bool = bool(berth.starved)
var row_color: Color = UNPAID_COLOR if starved else CELL_COLOR
_cell(str(int(berth.slot)), row_color)
_cell(GameState.vessel_name(String(berth.vessel_type)), row_color)
_cell(_amounts(berth.costs), row_color)
_cell("x%.2f" % float(berth.scale), row_color)
_cell("UNPAID" if starved else "", UNPAID_COLOR)
# The totals row is what #230 asked for: the per-berth lines never added
# up to anything on screen, so the bill had to be done in the head.
_cell("TOTAL", TOTAL_COLOR)
_cell("", TOTAL_COLOR)
_cell(_amounts(totals), TOTAL_COLOR)
_cell("", TOTAL_COLOR)
_cell("", TOTAL_COLOR)
func _cell(text: String, colour: Color) -> void:
var label := Label.new()
label.text = text
label.add_theme_font_size_override("font_size", 28)
label.add_theme_color_override("font_color", colour)
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
sheet.add_child(label)
# In the game's own resource order, not the dictionary's. Iterating the costs
# directly put "2 ore 1 energy" on one row and "2 gas 2 ore" on the next,
# which is most of what a column is for - you cannot read down a table whose
# entries are in a different order on every line.
func _ordered(costs: Dictionary) -> Array:
var out: Array = []
for resource_type in GameState.resource_types():
if costs.has(resource_type):
out.append(resource_type)
# Anything the catalog does not know about still gets shown rather than
# silently dropped.
for resource_type in costs:
if not out.has(resource_type):
out.append(resource_type)
return out
func _amounts(costs: Dictionary) -> String:
var parts: PackedStringArray = PackedStringArray()
for resource_type in _ordered(costs):
parts.append("%d %s" % [int(costs[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
return " ".join(parts) if not parts.is_empty() else "-"
func _rates(per_minute: Dictionary) -> String:
var parts: PackedStringArray = PackedStringArray()
for resource_type in _ordered(per_minute):
parts.append("%.1f %s" % [float(per_minute[resource_type]),
GameState.resource_name(String(resource_type)).to_lower()])
return " ".join(parts)

View file

@ -50,16 +50,28 @@ theme_override_colors/font_color = Color(1, 0.85, 0.35, 1)
text = ""
horizontal_alignment = 1
[node name="DetailLabel" type="Label" parent="."]
[node name="Sheet" type="GridContainer" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 250.0
offset_right = -32.0
offset_bottom = 860.0
columns = 5
theme_override_constants/h_separation = 24
theme_override_constants/v_separation = 10
[node name="EmptyLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 32.0
offset_top = 250.0
offset_right = -32.0
offset_bottom = 400.0
theme_override_font_sizes/font_size = 30
theme_override_colors/font_color = Color(0.72, 0.88, 0.96, 1)
text = ""
horizontal_alignment = 1
autowrap_mode = 2
[node name="FormulaLabel" type="Label" parent="."]

View file

@ -34,8 +34,9 @@ static func create(parent: Node, at: Vector2, colour: Color, alpha: float = 1.0)
return piece
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void:
polygons.append({"points": points, "pulse": piece_pulse})
func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0,
width: float = 1.0) -> void:
polygons.append({"points": points, "pulse": piece_pulse, "width": width})
func add_path(points: PackedVector2Array, piece_pulse: float = 1.0,
@ -49,7 +50,8 @@ func add_line(from: Vector2, to: Vector2, width: float, piece_pulse: float = 1.0
func _draw() -> void:
for poly in polygons:
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color)
NeonDraw.draw_glowing_polygon(self, poly.points, glow_color,
poly.pulse * pulse, core_color, float(poly.get("width", 1.0)))
for path in paths:
NeonDraw.draw_glowing_path(self, path.points, glow_color,
path.pulse * pulse, path.width, core_color)

View file

@ -43,6 +43,19 @@ const RESEARCH_LAB_CHANCE: float = 0.16
# anchor and one shape make the actual rule representable: one multi-chunk
# octopus biome, with its boss at the centre.
const OCTOPUS_BIOME_CENTER: Vector2i = Vector2i(12, -9)
# Forgejo #234: a gas giant the opening is guaranteed to be able to reach.
#
# Gas is gated behind two independent noise fields - the SPACE biome and the
# planet roll - so whether any is in reach of the spawn was luck, and on this
# seed it lost: measured, there were ZERO gas planets within four chunks and
# the nearest sat five away, past five rocky ones. Gas is needed early (the
# burner, ammunition), so the opening cannot depend on the noise being kind.
#
# Authored like OCTOPUS_BIOME_CENTER above rather than found by searching
# outward: a fixed chunk is reproducible from anywhere without walking the
# map, which is the property everything in this file is built on.
const SPAWN_GAS_GIANT_CHUNK: Vector2i = Vector2i(2, -2)
const OCTOPUS_BIOME_OFFSETS: Array[Vector2i] = [
Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0),
Vector2i(0, -1), Vector2i(0, 1),
@ -134,6 +147,8 @@ static func has_planet_at_chunk(chunk: Vector2i) -> bool:
return true
if chunk == OCTOPUS_BIOME_CENTER:
return true
if chunk == SPAWN_GAS_GIANT_CHUNK:
return true
if is_octopus_chunk(chunk):
return false
var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y))
@ -173,6 +188,11 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary:
definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID
elif research_lab_roll < RESEARCH_LAB_CHANCE:
definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID
# #234, applied after the rolls for the same reason the dev planting is:
# forcing this chunk must not shift which random values any OTHER chunk
# consumes, or one guaranteed planet would reshuffle the whole map.
if chunk == SPAWN_GAS_GIANT_CHUNK:
definition_id = PLANET_DEFINITIONS.GAS_GIANT_ID
# Dev mode's fixed planting wins over every roll above. Applied last, and
# AFTER all the rng draws, so switching it on cannot shift which random
# values any other chunk consumes - a dev world and a normal one differ