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/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/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/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/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/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_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/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() 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="."] 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) 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