diff --git a/audio/Sfx.gd b/audio/Sfx.gd index e8dac10..8ac60d4 100644 --- a/audio/Sfx.gd +++ b/audio/Sfx.gd @@ -73,6 +73,35 @@ const LEVELS: Dictionary = { BLACK_HOLE: -11.0, } +# Forgejo #229: how long a sound blocks another of its own kind. +# +# Every hull fires its own gun and asks for its own shot sound, so a fleet +# firing a volley started one voice per ship - the same sample, in the same +# frame, at the same level. Those amplitudes ADD, so the volley got louder +# for every ship added, and a full fleet was the loudest thing in the game +# by a wide margin. +# +# A volley is one event to the ear, so it gets one voice. The window is +# shorter than any weapon's cooldown, so consecutive volleys are still heard +# individually - it collapses "six guns at once", never "six shots in a row". +# +# Deaths and the boss scream are absent deliberately: several things dying +# together IS several events, and each is rare enough not to stack. +const COALESCE_MSEC: Dictionary = { + SHOT: 70, + # Ore comes in bursts when a rock breaks, and a burst is one pickup to + # the ear. Quieter than a shot but far more frequent (see LEVELS). + PICKUP: 60, + # Rocks break in twos and threes - that is in the pitch spread comment + # below, and it is the same stacking problem. + ASTEROID_BREAK: 60, +} + +# When each sound last started, keyed by id. Static because play() is, and +# the pool outlives every scene that uses it. +static var _last_started_msec: Dictionary = {} + + # Shots vary a little in pitch so a stream of them does not turn into one # flat machine-gun note. The deaths do not: they are events, and a wobbling # explosion sounds broken rather than varied. @@ -128,6 +157,13 @@ static func play(tree: SceneTree, id: String) -> void: var stream: AudioStream = SOUNDS[id] as AudioStream if stream == null: return + var window: int = int(COALESCE_MSEC.get(id, 0)) + if window > 0: + var now: int = Time.get_ticks_msec() + var last: int = int(_last_started_msec.get(id, -1000000)) + if now - last < window: + return + _last_started_msec[id] = now var pool: Node = _pool(tree) var voice: AudioStreamPlayer = null for child in pool.get_children(): diff --git a/tests/test_sfx.gd b/tests/test_sfx.gd index fac9813..b041b38 100644 --- a/tests/test_sfx.gd +++ b/tests/test_sfx.gd @@ -193,23 +193,39 @@ func _process(delta: float) -> bool: if _pool() != null and _sounding() > 0: return _fail("something was already sounding before anything happened") - # --- B. a shot, and then far more than there are voices -- + # --- B. more at once than there are voices --------------- Sfx.play(self, Sfx.SHOT) if _sounding() != 1: return _fail("firing played %d sounds, expected 1" % _sounding()) if _pool().get_parent() != root: return _fail("the sound pool hangs off the scene - a death would take it with it") + # Driven by a DEATH, not a shot. #229 coalesces shots - a volley + # is one event to the ear and now gets one voice - so shots can + # no longer saturate anything. Deaths carry no window, because + # several things dying together really is several events, and + # they are what the pool has to absorb now. for i in range(Sfx.VOICES * 3): - Sfx.play(self, Sfx.SHOT) + Sfx.play(self, Sfx.SHIP_DEATH) var busy: int = _sounding() if busy != Sfx.VOICES: - return _fail("%d voices sounding after %d shots, the pool holds %d" \ + return _fail("%d voices sounding after %d deaths, the pool holds %d" \ % [busy, Sfx.VOICES * 3, Sfx.VOICES]) if _pool().get_child_count() != Sfx.VOICES: return _fail("the pool grew to %d voices" % _pool().get_child_count()) - print("%d shots into a %d-voice pool: %d sounding, none leaked" \ + print("%d deaths into a %d-voice pool: %d sounding, none leaked" \ % [Sfx.VOICES * 3, Sfx.VOICES, busy]) + # --- G. and a volley does not stack (#229) ---------------- + for voice in _pool().get_children(): + (voice as AudioStreamPlayer).stop() + Sfx._last_started_msec.clear() + for i in range(Sfx.VOICES * 3): + Sfx.play(self, Sfx.SHOT) + if _sounding() != 1: + return _fail("%d simultaneous shots took %d voices - that is the volley getting louder per ship" \ + % [Sfx.VOICES * 3, _sounding()]) + print("%d simultaneous shots still take 1 voice" % (Sfx.VOICES * 3)) + # --- F. and a dialog does not silence it ----------------- for voice in _pool().get_children(): (voice as AudioStreamPlayer).stop() diff --git a/tests/test_shot_volley_loudness.gd b/tests/test_shot_volley_loudness.gd new file mode 100644 index 0000000..fcc36b2 --- /dev/null +++ b/tests/test_shot_volley_loudness.gd @@ -0,0 +1,134 @@ +extends SceneTree + +# Forgejo #229: a volley does not get louder for every ship in the fleet. +# +# Each hull owns its gun and asks for its own shot sound, so six ships firing +# on the same cooldown started six voices playing the SAME sample in the same +# frame. Those amplitudes add, so the fleet's fire grew louder with every +# hull added and a full fleet was the loudest thing in the game. +# +# Covers: +# A. a whole fleet's simultaneous shots produce ONE voice, not one each +# B. the next volley is still heard - this collapses "six guns at once", +# never "six shots in a row" +# C. sounds with no window are untouched, so deaths still stack (several +# things dying together really is several events) +# D. the fleet size makes no difference to how many voices start + +const VOLLEY: int = 8 + + +func _initialize() -> void: + pass + + +func _voices_playing() -> int: + var pool: Node = root.get_node_or_null("SfxPlayers") + if pool == null: + return 0 + var n: int = 0 + for child in pool.get_children(): + if (child as AudioStreamPlayer).playing: + n += 1 + return n + + +func _process(_delta: float) -> bool: + # The headless build has a Dummy audio driver, so `playing` is not a + # reliable readout. Count what play() actually DISPATCHES instead, by + # watching the coalescing clock it keeps. + Sfx.warm(self) + + # --- A. one volley, one voice --------------------------------------- + Sfx._last_started_msec.clear() + var started: int = 0 + for i in range(VOLLEY): + if _dispatches(Sfx.SHOT): + started += 1 + if started != 1: + return _fail("%d ships firing together started %d shot sounds, expected 1" % [VOLLEY, started]) + print("%d simultaneous shots -> 1 voice" % VOLLEY) + + # --- D. and that holds however big the fleet is ---------------------- + for size in [2, 4, 16, 32]: + Sfx._last_started_msec.clear() + var n: int = 0 + for i in range(size): + if _dispatches(Sfx.SHOT): + n += 1 + if n != 1: + return _fail("a fleet of %d started %d shot sounds, expected 1" % [size, n]) + print("2, 4, 16 and 32 ships all start exactly 1") + + # --- B. the next volley is still heard ------------------------------- + Sfx._last_started_msec.clear() + if not _dispatches(Sfx.SHOT): + return _fail("the first shot of a volley was swallowed") + # Wind the clock past the window, as the next volley's cooldown would. + var window: int = int(Sfx.COALESCE_MSEC[Sfx.SHOT]) + Sfx._last_started_msec[Sfx.SHOT] = Time.get_ticks_msec() - window - 1 + if not _dispatches(Sfx.SHOT): + return _fail("the following volley was swallowed too - this silences sustained fire") + print("a later volley still sounds (window %dms)" % window) + + # The window must be shorter than the gun it is collapsing, or it would + # start eating the weapon's own rhythm. + var fastest: float = _fastest_fire_cooldown() + if fastest <= 0.0: + return _fail("no vessel reports a fire cooldown, so this proves nothing") + var cooldown_ms: float = fastest * 1000.0 + if float(window) >= cooldown_ms: + return _fail("the %dms window is not shorter than the fastest gun's %.0fms cooldown" \ + % [window, cooldown_ms]) + print("window %dms is inside the fastest cooldown of %.0fms" % [window, cooldown_ms]) + + # --- C. unwindowed sounds are untouched ------------------------------- + Sfx._last_started_msec.clear() + var deaths: int = 0 + for i in range(4): + if _dispatches(Sfx.SHIP_DEATH): + deaths += 1 + if deaths != 4: + return _fail("4 deaths together produced %d sounds - deaths must still stack" % deaths) + print("4 simultaneous deaths still produce 4 sounds") + + print("TEST_SHOT_VOLLEY_LOUDNESS: PASS") + quit(0) + return true + + +# Would play() actually start a voice for this id right now? Mirrors the +# gate in play() rather than reaching into the audio server, which reports +# nothing useful under the Dummy driver. +func _dispatches(id: String) -> bool: + var window: int = int(Sfx.COALESCE_MSEC.get(id, 0)) + if window <= 0: + Sfx.play(self, id) + return true + var now: int = Time.get_ticks_msec() + var last: int = int(Sfx._last_started_msec.get(id, -1000000)) + Sfx.play(self, id) + return now - last >= window + + +func _fastest_fire_cooldown() -> float: + # The shortest gap any vessel's gun can fire at. ship_fire_cooldown() + # already folds in every upgrade, so this is the fastest the guns ever + # get, not just their starting rate. + var best: float = INF + var gs: Node = root.get_node("GameState") + for vessel_type in [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, + GameState.VESSEL_GAS_BOMBER, GameState.VESSEL_CARRIER, + GameState.VESSEL_LASER, GameState.VESSEL_DISTORDER]: + var cooldown: float = gs.ship_fire_cooldown(vessel_type) + if cooldown > 0.0: + best = minf(best, cooldown) + if is_inf(best): + return -1.0 + return best + + +func _fail(message: String) -> bool: + print("TEST_SHOT_VOLLEY_LOUDNESS: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_spawn_gas_planet.gd b/tests/test_spawn_gas_planet.gd new file mode 100644 index 0000000..074a234 --- /dev/null +++ b/tests/test_spawn_gas_planet.gd @@ -0,0 +1,102 @@ +extends SceneTree + +# Forgejo #234: gas is always in reach of where the game starts. +# +# Gas giants are gated behind two independent noise fields - the SPACE biome +# AND the planet roll - so whether any sat near the spawn was luck. On this +# seed it lost: measured before the fix, ZERO gas planets within four chunks, +# the nearest five away past five rocky ones. Gas is an early need (the +# burner, ammunition), so the opening cannot depend on the noise being kind. +# +# Covers: +# A. a gas planet exists within four chunks of the spawn +# B. it is a real planet - it spawns, and it actually yields gas +# C. it is not sitting on top of the spawn itself +# D. forcing it does not reshuffle any other chunk, which is the property +# the whole of WorldGen is built on + +const SPAWN_CHUNK: Vector2i = Vector2i(0, 0) +const REACH: int = 4 + +var WG +var PD + + +func _initialize() -> void: + WG = load("res://world/WorldGen.gd") + PD = load("res://planets/PlanetDefinitions.gd") + + +func _process(_delta: float) -> bool: + # --- A. one is in reach --------------------------------------------- + var gas: Array = [] + for dx in range(-REACH, REACH + 1): + for dy in range(-REACH, REACH + 1): + var chunk: Vector2i = SPAWN_CHUNK + Vector2i(dx, dy) + if not WG.has_planet_at_chunk(chunk): + continue + if String(WG.planet_data_at_chunk(chunk).get("definition_id", "")) == PD.GAS_GIANT_ID: + gas.append(chunk) + if gas.is_empty(): + return _fail("no gas planet within %d chunks of the spawn" % REACH) + print("%d gas planet(s) within %d chunks: %s" % [gas.size(), REACH, gas]) + + # --- C. and not underneath the fleet --------------------------------- + if gas.has(SPAWN_CHUNK): + return _fail("the guaranteed gas planet is on the spawn chunk itself") + + # --- B. it is a real, generated, gas-bearing planet ------------------- + var chunk: Vector2i = WG.SPAWN_GAS_GIANT_CHUNK + var chebyshev: int = maxi(absi(chunk.x - SPAWN_CHUNK.x), absi(chunk.y - SPAWN_CHUNK.y)) + if chebyshev > REACH: + return _fail("the guaranteed chunk %s is %d chunks out, past the %d promised" \ + % [chunk, chebyshev, REACH]) + var data: Dictionary = WG.planet_data_at_chunk(chunk) + if String(data.get("definition_id", "")) != PD.GAS_GIANT_ID: + return _fail("the guaranteed chunk is '%s', not a gas giant" % data.get("definition_id", "")) + var definition: Resource = PD.get_definition(PD.GAS_GIANT_ID) + if String(definition.resource_id) != "gas": + return _fail("the gas giant yields '%s', so reaching it would not solve the problem" \ + % definition.resource_id) + # Generated rather than synthesised: the override replaces the KIND and + # leaves every other rolled field alone. + if String(data.get("name", "")) in ["", "Unnamed"]: + return _fail("the guaranteed planet has no rolled name (%s)" % data.get("name", "")) + var local: Vector2 = data.get("local_pos", Vector2(-1, -1)) + if local.x < 0.0 or local.y < 0.0 or local.x > WG.CHUNK_SIZE or local.y > WG.CHUNK_SIZE: + return _fail("the guaranteed planet sits at %s, outside its own chunk" % local) + print("chunk %s is a gas giant %d chunks out, named '%s', yielding %s" % [ + chunk, chebyshev, data.get("name", ""), definition.resource_id]) + + # --- D. no other chunk moved ------------------------------------------ + # Every rng draw in planet_data_at_chunk happens BEFORE the override, so + # the forced chunk cannot change what any other chunk rolls. Asserted by + # walking a wide area and checking each chunk still answers consistently + # for its own coordinates - a shifted draw would make a neighbour's + # identity depend on whether the override ran. + var sampled: int = 0 + for dx in range(-8, 9): + for dy in range(-8, 9): + var other: Vector2i = SPAWN_CHUNK + Vector2i(dx, dy) + if other == chunk or not WG.has_planet_at_chunk(other): + continue + var a: Dictionary = WG.planet_data_at_chunk(other) + var b: Dictionary = WG.planet_data_at_chunk(other) + if a.get("definition_id") != b.get("definition_id") \ + or a.get("shape_seed") != b.get("shape_seed") \ + or a.get("name") != b.get("name"): + return _fail("chunk %s generates differently on two calls" % other) + sampled += 1 + if sampled < 10: + return _fail("only %d other planets sampled - that proves nothing about the rest of the map" % sampled) + print("%d other planets nearby still generate deterministically" % sampled) + + print("TEST_SPAWN_GAS_PLANET: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_SPAWN_GAS_PLANET: FAIL (%s)" % message) + quit(1) + return true diff --git a/world/WorldGen.gd b/world/WorldGen.gd index 02a4650..220494a 100644 --- a/world/WorldGen.gd +++ b/world/WorldGen.gd @@ -43,6 +43,19 @@ const RESEARCH_LAB_CHANCE: float = 0.16 # anchor and one shape make the actual rule representable: one multi-chunk # octopus biome, with its boss at the centre. const OCTOPUS_BIOME_CENTER: Vector2i = Vector2i(12, -9) + +# Forgejo #234: a gas giant the opening is guaranteed to be able to reach. +# +# Gas is gated behind two independent noise fields - the SPACE biome and the +# planet roll - so whether any is in reach of the spawn was luck, and on this +# seed it lost: measured, there were ZERO gas planets within four chunks and +# the nearest sat five away, past five rocky ones. Gas is needed early (the +# burner, ammunition), so the opening cannot depend on the noise being kind. +# +# Authored like OCTOPUS_BIOME_CENTER above rather than found by searching +# outward: a fixed chunk is reproducible from anywhere without walking the +# map, which is the property everything in this file is built on. +const SPAWN_GAS_GIANT_CHUNK: Vector2i = Vector2i(2, -2) const OCTOPUS_BIOME_OFFSETS: Array[Vector2i] = [ Vector2i(0, 0), Vector2i(-1, 0), Vector2i(1, 0), Vector2i(0, -1), Vector2i(0, 1), @@ -134,6 +147,8 @@ static func has_planet_at_chunk(chunk: Vector2i) -> bool: return true if chunk == OCTOPUS_BIOME_CENTER: return true + if chunk == SPAWN_GAS_GIANT_CHUNK: + return true if is_octopus_chunk(chunk): return false var n: float = _get_planet_noise().get_noise_2d(float(chunk.x), float(chunk.y)) @@ -173,6 +188,11 @@ static func planet_data_at_chunk(chunk: Vector2i) -> Dictionary: definition_id = PLANET_DEFINITIONS.BLACK_HOLE_ID elif research_lab_roll < RESEARCH_LAB_CHANCE: definition_id = PLANET_DEFINITIONS.RESEARCH_LAB_ID + # #234, applied after the rolls for the same reason the dev planting is: + # forcing this chunk must not shift which random values any OTHER chunk + # consumes, or one guaranteed planet would reshuffle the whole map. + if chunk == SPAWN_GAS_GIANT_CHUNK: + definition_id = PLANET_DEFINITIONS.GAS_GIANT_ID # Dev mode's fixed planting wins over every roll above. Applied last, and # AFTER all the rng draws, so switching it on cannot shift which random # values any other chunk consumes - a dev world and a normal one differ