diff --git a/audio/Sfx.gd b/audio/Sfx.gd index 8ac60d4..e8dac10 100644 --- a/audio/Sfx.gd +++ b/audio/Sfx.gd @@ -73,35 +73,6 @@ 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. @@ -157,13 +128,6 @@ 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 45c4b0e..168774c 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -92,11 +92,8 @@ const KEY_DIRECTIONS: Dictionary = { KEY_A: Vector2.LEFT, KEY_LEFT: Vector2.LEFT, KEY_D: Vector2.RIGHT, KEY_RIGHT: Vector2.RIGHT, } -# 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 KEY_PIVOT_CW: int = KEY_E +const KEY_PIVOT_CCW: int = KEY_Q const WHEEL_ZOOM_STEP: float = 1.12 diff --git a/controllers/MouseCombatController.gd b/controllers/MouseCombatController.gd index 336d1a0..9777eb3 100644 --- a/controllers/MouseCombatController.gd +++ b/controllers/MouseCombatController.gd @@ -25,18 +25,6 @@ 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") @@ -109,19 +97,6 @@ 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 e84bc38..15be5b5 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -3275,30 +3275,14 @@ 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, "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}, + {"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}, ] 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) @@ -3306,15 +3290,13 @@ 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 wave_number < int(entry.get("min_wave", 0)): + or fleet_size < int(entry.get("min_fleet", 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 - var ceiling: int = wave_max_enemies() - while share >= cost and budget >= cost and out.size() < ceiling: + while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES: out.append(String(entry.id)) budget -= cost share -= cost diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index acebd42..2554887 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -27,59 +27,15 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn") var wave_enemies: Array = [] -# 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: +func _process(_delta: float) -> void: wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e)) if GameState.wave_pending: _launch_wave() return - _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(): + if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.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(): @@ -93,20 +49,18 @@ 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 - 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 + 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) diff --git a/surface/GasVentField.gd b/surface/GasVentField.gd index 4f5b381..e2476ce 100644 --- a/surface/GasVentField.gd +++ b/surface/GasVentField.gd @@ -9,23 +9,7 @@ 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: @@ -40,73 +24,10 @@ 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))) - _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 + queue_redraw() func _process(delta: float) -> void: emission_phase = fmod(emission_phase + delta * 0.55, 1.0) - # Node properties only. Nothing is rasterised after the first frame. - _place_bubbles() + queue_redraw() for vent in vents: if fleet.get_centroid().distance_to(vent) < 90.0: progress += delta @@ -121,3 +42,43 @@ 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 deleted file mode 100644 index 1dabd24..0000000 --- a/tests/test_ability_keys.gd +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index a1df891..0000000 --- a/tests/test_fleet_upkeep_sheet.gd +++ /dev/null @@ -1,150 +0,0 @@ -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 d633092..271cd78 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, the pivot pair must turn the formation, and releasing +# must steer the fleet, Q/E must pivot the formation, and releasing the keys # 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,32 +127,21 @@ func _process(delta: float) -> bool: quit(1) return true - # --- 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. + # --- Q/E pivot ------------------------------------------------ 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(cw_key, true) - _key(cw_key, false) + _key(KEY_E, true) + _key(KEY_E, false) var after_cw: float = game_state.formation[1].angle() - _key(ccw_key, true) - _key(ccw_key, false) + _key(KEY_Q, true) + _key(KEY_Q, 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("key %d turned %.3f rad, key %d turned %.3f rad" \ - % [cw_key, cw_turn, ccw_key, ccw_turn]) + print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, 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 (the pivot pair did not turn in opposite directions)") + print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)") quit(1) return true diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd index 738f06a..10cfea7 100644 --- a/tests/test_lancer_enemy.gd +++ b/tests/test_lancer_enemy.gd @@ -262,12 +262,6 @@ 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): @@ -275,7 +269,6 @@ 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 b041b38..fac9813 100644 --- a/tests/test_sfx.gd +++ b/tests/test_sfx.gd @@ -193,39 +193,23 @@ func _process(delta: float) -> bool: if _pool() != null and _sounding() > 0: return _fail("something was already sounding before anything happened") - # --- B. more at once than there are voices --------------- + # --- B. a shot, and then far more 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.SHIP_DEATH) + Sfx.play(self, Sfx.SHOT) var busy: int = _sounding() if busy != Sfx.VOICES: - return _fail("%d voices sounding after %d deaths, the pool holds %d" \ + return _fail("%d voices sounding after %d shots, 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 deaths into a %d-voice pool: %d sounding, none leaked" \ + print("%d shots 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 deleted file mode 100644 index fcc36b2..0000000 --- a/tests/test_shot_volley_loudness.gd +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index 074a234..0000000 --- a/tests/test_spawn_gas_planet.gd +++ /dev/null @@ -1,102 +0,0 @@ -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 c8c0322..e006315 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -108,34 +108,12 @@ 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 - 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 + # A big wave must not be the same swarm at greater length. var heavy_kinds: Dictionary = {} - for id in varied: + for id in large: heavy_kinds[id] = true if heavy_kinds.size() < 2: - 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()]) + return _fail("a large wave is %d copies of one enemy" % large.size()) 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" \ @@ -162,23 +140,15 @@ 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) @@ -274,22 +244,6 @@ 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 5b21717..c662d52 100644 --- a/ui/menus/FleetUpkeepMenu.gd +++ b/ui/menus/FleetUpkeepMenu.gd @@ -14,22 +14,10 @@ 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 sheet: GridContainer = $Sheet -@onready var empty_label: Label = $EmptyLabel +@onready var detail_label: Label = $DetailLabel @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: @@ -50,99 +38,34 @@ 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: - total_label.text = "%s EVERY %dS · %s PER MINUTE" % [ - _amounts(totals), int(breakdown.charge_seconds), _rates(per_minute)] + 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) - var signature: String = "%s|%s" % [str(berths), str(totals)] - if signature != _built_from: - _built_from = signature - _build_sheet(berths, totals) + # 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." - 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 " + 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 dd5c669..ad4d649 100644 --- a/ui/menus/FleetUpkeepMenu.tscn +++ b/ui/menus/FleetUpkeepMenu.tscn @@ -50,28 +50,16 @@ theme_override_colors/font_color = Color(1, 0.85, 0.35, 1) text = "" horizontal_alignment = 1 -[node name="Sheet" type="GridContainer" parent="."] +[node name="DetailLabel" type="Label" 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 25e00ba..906a510 100644 --- a/vfx/NeonPiece.gd +++ b/vfx/NeonPiece.gd @@ -34,9 +34,8 @@ 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, - width: float = 1.0) -> void: - polygons.append({"points": points, "pulse": piece_pulse, "width": width}) +func add_polygon(points: PackedVector2Array, piece_pulse: float = 1.0) -> void: + polygons.append({"points": points, "pulse": piece_pulse}) func add_path(points: PackedVector2Array, piece_pulse: float = 1.0, @@ -50,8 +49,7 @@ 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, float(poly.get("width", 1.0))) + NeonDraw.draw_glowing_polygon(self, poly.points, glow_color, poly.pulse * pulse, core_color) 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 220494a..02a4650 100644 --- a/world/WorldGen.gd +++ b/world/WorldGen.gd @@ -43,19 +43,6 @@ 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), @@ -147,8 +134,6 @@ 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)) @@ -188,11 +173,6 @@ 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