From 885f9777684b17bf28ea2c36090a6fe7c08572ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 10:49:25 -0400 Subject: [PATCH 1/4] #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 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- audio/Sfx.gd | 36 ++++++++ tests/test_sfx.gd | 24 +++++- tests/test_shot_volley_loudness.gd | 134 +++++++++++++++++++++++++++++ tests/test_spawn_gas_planet.gd | 102 ++++++++++++++++++++++ world/WorldGen.gd | 20 +++++ 5 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 tests/test_shot_volley_loudness.gd create mode 100644 tests/test_spawn_gas_planet.gd diff --git a/audio/Sfx.gd b/audio/Sfx.gd index e8dac10..8ac60d4 100644 --- a/audio/Sfx.gd +++ b/audio/Sfx.gd @@ -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(): diff --git a/tests/test_sfx.gd b/tests/test_sfx.gd index fac9813..b041b38 100644 --- a/tests/test_sfx.gd +++ b/tests/test_sfx.gd @@ -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() diff --git a/tests/test_shot_volley_loudness.gd b/tests/test_shot_volley_loudness.gd new file mode 100644 index 0000000..fcc36b2 --- /dev/null +++ b/tests/test_shot_volley_loudness.gd @@ -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 diff --git a/tests/test_spawn_gas_planet.gd b/tests/test_spawn_gas_planet.gd new file mode 100644 index 0000000..074a234 --- /dev/null +++ b/tests/test_spawn_gas_planet.gd @@ -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 diff --git a/world/WorldGen.gd b/world/WorldGen.gd index 02a4650..220494a 100644 --- a/world/WorldGen.gd +++ b/world/WorldGen.gd @@ -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 From cb3650e3a9becbcb4b2416db0272e965395070a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:35:50 -0400 Subject: [PATCH 2/4] #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 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- core/GameState.gd | 30 ++++++++++++---- enemies/EnemySpawner.gd | 70 +++++++++++++++++++++++++++++++------- tests/test_lancer_enemy.gd | 7 ++++ tests/test_wave_system.gd | 52 ++++++++++++++++++++++++++-- 4 files changed, 138 insertions(+), 21 deletions(-) diff --git a/core/GameState.gd b/core/GameState.gd index 15be5b5..e84bc38 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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 diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index 2554887..acebd42 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -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 diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd index 10cfea7..738f06a 100644 --- a/tests/test_lancer_enemy.gd +++ b/tests/test_lancer_enemy.gd @@ -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"): diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd index e006315..c8c0322 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -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() From 4ebd832a70263e92c681f9828056a42bc7f24689 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:29:22 -0400 Subject: [PATCH 3/4] #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 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- surface/GasVentField.gd | 123 ++++++++++++++++++++++++++-------------- vfx/NeonPiece.gd | 8 ++- 2 files changed, 86 insertions(+), 45 deletions(-) diff --git a/surface/GasVentField.gd b/surface/GasVentField.gd index e2476ce..4f5b381 100644 --- a/surface/GasVentField.gd +++ b/surface/GasVentField.gd @@ -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) diff --git a/vfx/NeonPiece.gd b/vfx/NeonPiece.gd index 906a510..25e00ba 100644 --- a/vfx/NeonPiece.gd +++ b/vfx/NeonPiece.gd @@ -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) From 34e252037e1ede208be3ce1b3ee635d249c7efa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 22:27:00 -0400 Subject: [PATCH 4/4] #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 Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD --- controllers/GameController.gd | 7 +- controllers/MouseCombatController.gd | 25 +++++ tests/test_ability_keys.gd | 121 +++++++++++++++++++++ tests/test_fleet_upkeep_sheet.gd | 150 +++++++++++++++++++++++++++ tests/test_keyboard_input.gd | 27 +++-- ui/menus/FleetUpkeepMenu.gd | 123 ++++++++++++++++++---- ui/menus/FleetUpkeepMenu.tscn | 14 ++- 7 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 tests/test_ability_keys.gd create mode 100644 tests/test_fleet_upkeep_sheet.gd diff --git a/controllers/GameController.gd b/controllers/GameController.gd index 168774c..45c4b0e 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -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 diff --git a/controllers/MouseCombatController.gd b/controllers/MouseCombatController.gd index 9777eb3..336d1a0 100644 --- a/controllers/MouseCombatController.gd +++ b/controllers/MouseCombatController.gd @@ -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() diff --git a/tests/test_ability_keys.gd b/tests/test_ability_keys.gd new file mode 100644 index 0000000..1dabd24 --- /dev/null +++ b/tests/test_ability_keys.gd @@ -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 diff --git a/tests/test_fleet_upkeep_sheet.gd b/tests/test_fleet_upkeep_sheet.gd new file mode 100644 index 0000000..a1df891 --- /dev/null +++ b/tests/test_fleet_upkeep_sheet.gd @@ -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 diff --git a/tests/test_keyboard_input.gd b/tests/test_keyboard_input.gd index 271cd78..d633092 100644 --- a/tests/test_keyboard_input.gd +++ b/tests/test_keyboard_input.gd @@ -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 diff --git a/ui/menus/FleetUpkeepMenu.gd b/ui/menus/FleetUpkeepMenu.gd index c662d52..5b21717 100644 --- a/ui/menus/FleetUpkeepMenu.gd +++ b/ui/menus/FleetUpkeepMenu.gd @@ -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) diff --git a/ui/menus/FleetUpkeepMenu.tscn b/ui/menus/FleetUpkeepMenu.tscn index ad4d649..dd5c669 100644 --- a/ui/menus/FleetUpkeepMenu.tscn +++ b/ui/menus/FleetUpkeepMenu.tscn @@ -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="."]