diff --git a/core/GameState.gd b/core/GameState.gd index 15be5b5..e84bc38 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -3275,14 +3275,30 @@ func wave_strength(fleet_size: int) -> int: # roster until the fleet is big enough to have earned it. At BASE_FLEET_SIZE # the player meets chasers and nothing else, however long they take, and each # vessel past the starting three opens exactly one new kind of trouble. +# +# min_wave is the third gate (#233). min_fleet alone still let a player who +# had grown early meet two kinds of trouble in the FIRST wave they ever saw - +# a fleet of four opened on two lancers and three chasers. Whatever the fleet +# looks like, the opening waves teach one enemy at a time. const WAVE_ROSTER: Array = [ - {"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6}, - {"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5}, - {"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4}, - {"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0}, + {"id": "artillery", "cost": 9, "min_strength": 26, "min_fleet": 6, "min_wave": 4}, + {"id": "sniper", "cost": 6, "min_strength": 16, "min_fleet": 5, "min_wave": 3}, + {"id": "lancer", "cost": 4, "min_strength": 12, "min_fleet": 4, "min_wave": 2}, + {"id": "chaser", "cost": 3, "min_strength": 0, "min_fleet": 0, "min_wave": 0}, ] const WAVE_MAX_ENEMIES: int = 18 +# How many can arrive in one wave, early on (#233). The variety gate above +# fixed "too complex" but made the other half of that ticket WORSE: budget +# that used to buy a lancer now buys two more chasers, so a grown fleet's +# FIRST wave went from a mixed six to seven chasers at once. +# +# Strength still rises with the fleet and the wave number - it simply cannot +# all be spent at once yet. What the budget cannot buy is not carried over; +# an early wave is meant to be smaller, not delayed. +func wave_max_enemies() -> int: + return mini(WAVE_MAX_ENEMIES, 2 + wave_number * 2) + func wave_composition(fleet_size: int) -> PackedStringArray: var budget: int = wave_strength(fleet_size) @@ -3290,13 +3306,15 @@ func wave_composition(fleet_size: int) -> PackedStringArray: var out: PackedStringArray = PackedStringArray() for entry in WAVE_ROSTER: if strength < int(entry.min_strength) \ - or fleet_size < int(entry.get("min_fleet", 0)): + or fleet_size < int(entry.get("min_fleet", 0)) \ + or wave_number < int(entry.get("min_wave", 0)): continue var cost: int = int(entry.cost) # The cheapest tier is last and takes whatever is left, so the budget # is always spent rather than rounded away. var share: int = budget if String(entry.id) == "chaser" else budget / 2 - while share >= cost and budget >= cost and out.size() < WAVE_MAX_ENEMIES: + var ceiling: int = wave_max_enemies() + while share >= cost and budget >= cost and out.size() < ceiling: out.append(String(entry.id)) budget -= cost share -= cost diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index 2554887..acebd42 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -27,15 +27,59 @@ var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn") var wave_enemies: Array = [] -func _process(_delta: float) -> void: +# Seconds between one hull of a wave landing and the next. +const ARRIVAL_INTERVAL: float = 0.45 + +# Hulls of the current wave that have not landed yet, with the position each +# was assigned when the wave was aimed. +var arriving: Array = [] +var arrival_timer: float = 0.0 + + +func _process(delta: float) -> void: wave_enemies = wave_enemies.filter(func(e): return is_instance_valid(e)) if GameState.wave_pending: _launch_wave() return - if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE and wave_enemies.is_empty(): + _land_arrivals(delta) + # `arriving` as well as `wave_enemies`: between the first hull landing and + # the last, the field can legitimately be empty of live enemies, and + # clearing the wave there would end it before it had finished arriving. + if GameState.wave_phase == GameState.WAVE_PHASE_ACTIVE \ + and wave_enemies.is_empty() and arriving.is_empty(): GameState.wave_cleared() +# Forgejo #233: a wave used to appear entirely within one frame, as a closed +# ring around the fleet. That reads as a wall rather than as an attack, and +# it was the "arrive at the same time" half of the complaint - the other half +# being how many there were, which wave_composition now limits. +# +# The first hull still lands immediately, so a wave never feels delayed; the +# rest follow at ARRIVAL_INTERVAL. Same enemies, same bearing, same total - +# they just do not all exist at once. +func _land_arrivals(delta: float) -> void: + if arriving.is_empty(): + return + arrival_timer -= delta + while arrival_timer <= 0.0 and not arriving.is_empty(): + _spawn_arrival(arriving.pop_front()) + arrival_timer += ARRIVAL_INTERVAL + + +func _spawn_arrival(entry: Dictionary) -> void: + var enemy: Node2D = enemy_scene.instantiate() + # Before add_child: Enemy._ready() resolves enemy_id into a definition. + enemy.enemy_id = String(entry.id) + # #113: a wave is an attack on what the player has built. It converges + # on the nearest structure from wherever it spawned rather than + # waiting to be flown near. + enemy.relentless = true + enemy.position = entry.at + get_parent().add_child(enemy) + wave_enemies.append(enemy) + + # Public so a test can put a wave in the world without waiting out the peace. func _launch_wave() -> void: if fleet == null or fleet.ships.is_empty(): @@ -49,18 +93,20 @@ func _launch_wave() -> void: var radius: float = visible_rect.size.length() / 2.0 + spawn_margin var centre: Vector2 = fleet.get_centroid() var bearing: float = randf() * TAU + # Positions are settled now, while the fleet is where the wave was aimed + # at - not as each hull lands, or a moving fleet would drag the later + # arrivals around behind it and the ring would smear. + arriving.clear() for i in range(composition.size()): # Spread around one bearing rather than scattered at random: a wave # should read as an attack arriving, not as the map filling up. var angle: float = bearing + randf_range(-wave_spread, wave_spread) \ + TAU * float(i) / float(maxi(composition.size(), 1)) * wave_spread - var enemy: Node2D = enemy_scene.instantiate() - # Before add_child: Enemy._ready() resolves enemy_id into a definition. - enemy.enemy_id = composition[i] - # #113: a wave is an attack on what the player has built. It converges - # on the nearest structure from wherever it spawned rather than - # waiting to be flown near. - enemy.relentless = true - enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius - get_parent().add_child(enemy) - wave_enemies.append(enemy) + arriving.append({ + "id": composition[i], + "at": centre + Vector2(cos(angle), sin(angle)) * radius, + }) + # The first lands at once, so a wave is never felt as late. + if not arriving.is_empty(): + _spawn_arrival(arriving.pop_front()) + arrival_timer = ARRIVAL_INTERVAL diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd index 10cfea7..738f06a 100644 --- a/tests/test_lancer_enemy.gd +++ b/tests/test_lancer_enemy.gd @@ -262,6 +262,12 @@ func _process(delta: float) -> bool: # the player now is a wave, so that is what has to be able to # produce one - and it must not have displaced the chaser while # doing it. + # Read past the openings: #233 gates each kind behind a minimum + # WAVE as well as a minimum fleet, so the first waves are chasers + # only however large the fleet is. This case is about whether a + # wave can field a lancer at all, not about when it starts to. + var settled_wave: int = game_state.wave_number + game_state.wave_number = 8 var kinds: Dictionary = {} var lancer_at: int = 0 for size in range(1, 12): @@ -269,6 +275,7 @@ func _process(delta: float) -> bool: kinds[id] = true if id == "lancer" and lancer_at == 0: lancer_at = size + game_state.wave_number = settled_wave if lancer_at == 0: return _fail("no wave against a fleet of 1..11 contains a lancer") if not kinds.has("chaser"): diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd index e006315..c8c0322 100644 --- a/tests/test_wave_system.gd +++ b/tests/test_wave_system.gd @@ -108,12 +108,34 @@ func _state_cases() -> bool: game_state.wave_number = before_number if later.size() <= small.size(): return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation") - # A big wave must not be the same swarm at greater length. + # A big wave must not be the same swarm at greater length - but read at a + # wave number where variety is ALLOWED. #233 gates each enemy kind behind + # a minimum wave as well as a minimum fleet, so that the openings teach + # one enemy at a time however big the fleet already is; a fleet of nine + # meeting only chasers in wave 1 is now the intent, not the swarm this + # case is guarding against. + game_state.wave_number = before_number + 6 + var varied: PackedStringArray = game_state.wave_composition(9) + game_state.wave_number = before_number var heavy_kinds: Dictionary = {} - for id in large: + for id in varied: heavy_kinds[id] = true if heavy_kinds.size() < 2: - return _fail("a large wave is %d copies of one enemy" % large.size()) + return _fail("a large wave at wave %d is %d copies of one enemy" \ + % [before_number + 6, varied.size()]) + + # And the openings really are single-kind, whatever the fleet looks like. + game_state.wave_number = 1 + var opener: PackedStringArray = game_state.wave_composition(9) + game_state.wave_number = before_number + var opening_kinds: Dictionary = {} + for id in opener: + opening_kinds[id] = true + if opening_kinds.size() != 1: + return _fail("wave 1 against a fleet of 9 already mixes %d kinds" % opening_kinds.size()) + if opener.size() > game_state.wave_max_enemies(): + return _fail("wave 1 sends %d enemies, past the %d an opening wave may" \ + % [opener.size(), game_state.wave_max_enemies()]) if large.size() > GameState.WAVE_MAX_ENEMIES: return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES]) print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \ @@ -140,15 +162,23 @@ func _state_cases() -> bool: # Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting # capacity to 1, and this is about the roster's own thresholds (4, 5, 6), # not about where the fleet happens to begin. + # Read past the opening waves: #233 also gates each kind behind a minimum + # WAVE, so that the first waves teach one enemy at a time whatever the + # fleet looks like. This case is about the roster's FLEET thresholds, so + # it asks at a wave number where every kind is already permitted. + var settled: int = game_state.wave_number + game_state.wave_number = 8 var previous_kinds: int = 1 for size in range(3, 8): var kinds_here: Dictionary = {} for id in game_state.wave_composition(size): kinds_here[id] = true if kinds_here.size() < previous_kinds: + game_state.wave_number = settled return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \ % [size, kinds_here.size(), previous_kinds]) previous_kinds = kinds_here.size() + game_state.wave_number = settled if previous_kinds < 3: return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds) print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds) @@ -244,6 +274,22 @@ func _process(delta: float) -> bool: print("wave %d arrived: %d enemies, nearest %.0fpx away" \ % [game_state.wave_number, arrived, nearest]) + # #233 lands a wave over several seconds rather than in one + # frame, so "the whole wave" is not on the field yet. Run the + # arrivals out first - otherwise this kills the one hull that + # has landed and calls that the wave cleared. + for i in range(40): + spawner._process(GameState.WAVE_MAX_SECONDS) + if spawner.arriving.is_empty(): + break + if not spawner.arriving.is_empty(): + return _fail("%d hulls never finished arriving" % spawner.arriving.size()) + var landed: int = _enemy_count() + if landed <= arrived: + return _fail("running the arrivals out added nothing (%d then %d) - the wave is not staggered" \ + % [arrived, landed]) + print("the rest of the wave followed: %d -> %d enemies" % [arrived, landed]) + for enemy in get_nodes_in_group("enemies"): if is_instance_valid(enemy): enemy.queue_free()