diff --git a/core/GameState.gd b/core/GameState.gd index 3466e37..2f05648 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -556,6 +556,7 @@ func reset_new_game() -> void: extractors.clear() transporters.clear() planet_capture.clear() + reset_waves() reset_progression() reset_formation() @@ -613,6 +614,9 @@ var vessel_deliveries: Array = [] const VESSEL_DELIVERY_SPEED: float = 180.0 signal order_completed(building_id: int, type: String) +# #99: fired after the record is already gone, so a listener that reacts by +# asking GameState about the world sees the world without it. +signal building_destroyed(building_id: int, type: String) func add_building(type: String, at_position: Vector2) -> int: @@ -621,6 +625,7 @@ func add_building(type: String, at_position: Vector2) -> int: buildings.append({ "id": id, "type": type, "position": at_position, "inventory": INVENTORY.new(), "orders": [], + "hp": building_max_hp(type), }) var building: Dictionary = buildings[-1] if building_has_capability(building, "transport_hub"): @@ -635,6 +640,94 @@ func find_building(id: int) -> Dictionary: return {} +# --- what a structure can take before it comes down (#99) ------------------ +# +# Health lives on the RECORD, not on the Building node. Main.tscn is torn +# down and rebuilt on every trip to a planet surface, so damage kept on a +# node would heal itself the moment the player landed - the same reason the +# buildings themselves live here. + +func building_definition(type: String) -> Resource: + return BUILDING_DEFINITIONS.get_definition(type) + + +func building_max_hp(type: String) -> int: + var definition: Resource = building_definition(type) + return 1 if definition == null else maxi(int(definition.max_hp), 1) + + +func building_hp(id: int) -> int: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + # Records written before #99 carry no "hp" at all. Reading that as zero + # would present every structure in an existing save as rubble, so a + # missing field means undamaged. + return int(b.get("hp", building_max_hp(String(b.type)))) + + +func building_health_fraction(id: int) -> float: + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0.0 + return clampf(float(building_hp(id)) / float(building_max_hp(String(b.type))), 0.0, 1.0) + + +# The mothership is where the bank, the hangar and research all live, and it +# is where a wiped-out fleet is rebuilt. Losing it would delete the player's +# savings and their stored vessels with no route back, so it bottoms out at +# one hit point instead of dying. Keyed on the capability rather than on the +# type: whatever building holds the bank is the one that cannot be lost. +func building_is_indestructible(building: Dictionary) -> bool: + return building_has_capability(building, "bank") + + +# Returns true when this killed the building. +func damage_building(id: int, amount: int) -> bool: + if amount <= 0: + return false + var b: Dictionary = find_building(id) + if b.is_empty(): + return false + var floor_hp: int = 1 if building_is_indestructible(b) else 0 + b.hp = maxi(building_hp(id) - amount, floor_hp) + if int(b.hp) > 0: + return false + return destroy_building(id) + + +func repair_building(id: int, amount: int) -> int: + if amount <= 0: + return 0 + var b: Dictionary = find_building(id) + if b.is_empty(): + return 0 + var before: int = building_hp(id) + b.hp = mini(before + amount, building_max_hp(String(b.type))) + return int(b.hp) - before + + +func destroy_building(id: int) -> bool: + var b: Dictionary = find_building(id) + if b.is_empty() or building_is_indestructible(b): + return false + var type: String = String(b.type) + var was_hub: bool = building_has_capability(b, "transport_hub") + buildings.erase(b) + if was_hub: + # Its transporters belonged to it and have nowhere to go home to. + var kept: Array = [] + for transporter in transporters: + if int(transporter.get("hub_id", 0)) != id: + kept.append(transporter) + transporters = kept + # Everything else keyed on a building id already copes with the building + # being gone: a transporter mid-delivery puts its cargo back rather than + # destroying it (see _arrive), and find_building simply returns empty. + building_destroyed.emit(id, type) + return true + + func building_has_capability(building: Dictionary, capability: String) -> bool: if building.is_empty(): return false @@ -1520,9 +1613,132 @@ func total_capture_points(planet_key: String) -> int: return capture_state(planet_key).get("captured", []).size() +# --- waves (#100) ---------------------------------------------------------- +# +# #97 replaces the roaming spawner - one enemy every few seconds, forever - +# with waves: a long quiet, a warning, then an attack. The CLOCK lives here +# rather than in the space scene for the usual reason, that Main.tscn is +# destroyed on every landing; a wave must not be cancelled, nor restarted +# from zero, by a trip to a planet. +# +# What is deliberately NOT here is the spawning. GameState cannot see the +# world, so EnemySpawner reads the phase and puts the enemies in it. That +# also settles what happens if the wave turns while the player is on a +# surface: the phase goes to ACTIVE with nothing to spawn it, and the wave +# is waiting in space when they come back. +const WAVE_PHASE_PEACE: String = "peace" +const WAVE_PHASE_WARNING: String = "warning" +const WAVE_PHASE_ACTIVE: String = "active" + +const WAVE_PEACE_SECONDS: float = 50.0 +# How long the alert is up before anything arrives. Long enough to fly home +# and take a position, short enough that it is a warning and not a chore. +const WAVE_WARNING_SECONDS: float = 9.0 +# A wave that wandered off and never died must not stall the next one for +# ever. The director normally ends the phase by clearing the wave. +const WAVE_MAX_SECONDS: float = 240.0 + +var wave_number: int = 0 +var wave_phase: String = WAVE_PHASE_PEACE +var wave_timer: float = 0.0 +# Set when a wave turns ACTIVE, cleared by whoever puts it in the world. +# Without it a director arriving mid-wave (the player just came back from a +# planet) could not tell "this wave still owes you enemies" from "this wave +# is already out there and half dead". +var wave_pending: bool = false + + +func reset_waves() -> void: + wave_number = 0 + wave_phase = WAVE_PHASE_PEACE + wave_timer = 0.0 + wave_pending = false + + +func wave_is_incoming() -> bool: + return wave_phase == WAVE_PHASE_WARNING + + +# Deliberately NOT a remaining-seconds accessor. #97: "No timer should be +# visible" - so the one number that could be rendered as a countdown is not +# offered to the UI at all, and the alert cannot drift into being a clock. +func wave_warning_pulse() -> float: + if wave_phase != WAVE_PHASE_WARNING: + return 0.0 + return 0.5 + 0.5 * sin(wave_timer * 7.0) + + +# Strength is a function of the FLEET, per #97 - a bigger fleet is a bigger +# target, so growing it raises the stakes rather than trivialising them. +# The wave number is a slower second term, so a player who never grows still +# meets escalation rather than the same two chasers all game. +func wave_strength(fleet_size: int) -> int: + # The flat term matters: without it the opening wave against a one-vessel + # fleet is a single chaser, which reads as the system not working rather + # than as an easy first wave. + return 3 + maxi(fleet_size, 1) * 3 + wave_number * 2 + + +# Spent most-expensive-first, so a large budget buys heavier enemies rather +# than an ever-larger swarm of chasers. min_strength keeps the openings +# honest: the first waves are chasers whatever the arithmetic says. +const WAVE_ROSTER: Array = [ + {"id": "artillery", "cost": 9, "min_strength": 26}, + {"id": "sniper", "cost": 6, "min_strength": 16}, + {"id": "lancer", "cost": 4, "min_strength": 12}, + {"id": "chaser", "cost": 3, "min_strength": 0}, +] +const WAVE_MAX_ENEMIES: int = 18 + + +func wave_composition(fleet_size: int) -> PackedStringArray: + var budget: int = wave_strength(fleet_size) + var strength: int = budget + var out: PackedStringArray = PackedStringArray() + for entry in WAVE_ROSTER: + if strength < int(entry.min_strength): + 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: + out.append(String(entry.id)) + budget -= cost + share -= cost + if out.is_empty(): + out.append("chaser") + return out + + +func advance_waves(delta: float) -> void: + wave_timer += delta + match wave_phase: + WAVE_PHASE_PEACE: + if wave_timer >= WAVE_PEACE_SECONDS: + wave_phase = WAVE_PHASE_WARNING + wave_timer = 0.0 + WAVE_PHASE_WARNING: + if wave_timer >= WAVE_WARNING_SECONDS: + wave_phase = WAVE_PHASE_ACTIVE + wave_timer = 0.0 + wave_number += 1 + wave_pending = true + WAVE_PHASE_ACTIVE: + if wave_timer >= WAVE_MAX_SECONDS: + wave_cleared() + + +func wave_cleared() -> void: + wave_phase = WAVE_PHASE_PEACE + wave_timer = 0.0 + wave_pending = false + + func _process(delta: float) -> void: # Runs in the autoload, so mining and transport continue while the player # is on a planet's surface or nowhere near either. + advance_waves(delta) _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index 759e19b..1ca5a12 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -1,51 +1,62 @@ extends Node2D -@export var min_spawn_interval: float = 3.0 -@export var max_spawn_interval: float = 6.0 +# The wave director (#100 / #97). Keeps its old node name on purpose: it is +# still "the thing that puts enemies in the world", and every test that +# wanted a quiet world already says +# `main.get_node("EnemySpawner").set_process(false)`. Renaming it would have +# changed 88 test files to say the same thing differently. +# +# What it no longer does is trickle. It used to spawn one enemy every 3-6 +# seconds for ever, so there was no rhythm to the danger and nothing to +# prepare for. Now the CLOCK lives in GameState (which survives the trip to +# a planet surface) and this node only reacts to it: when a wave turns +# active, it puts the wave in the world; when the wave is dead, it says so +# and the quiet starts again. + @export var spawn_margin: float = 150.0 -# #96: this spawner is the only place the player meets an enemy off an -# authored map, so a new open-space enemy has to take a share of it or it -# only ever exists in the setup editor. Kept a minority of the wave: the -# lancer is punctuation against a background of chasers, and a screen of -# them would be all telegraph and no fight. -@export var lancer_chance: float = 0.25 +# Waves arrive as a ring around the fleet rather than from one bearing: a +# wave that all came from the left would be dodged by flying right. +@export var wave_spread: float = 0.55 @onready var fleet: Node2D = get_node("../Fleet") var enemy_scene: PackedScene = preload("res://enemies/Enemy.tscn") -var timer: float = 0.0 -var next_spawn_time: float = 0.0 +# The enemies this director launched, so "the wave is dead" is a question +# about THIS wave and not about every enemy on the map - a planet garrison +# or an enemy mothership's own spawns must not hold the next wave back. +var wave_enemies: Array = [] -func _ready() -> void: - randomize() - _reset_timer() - - -func _process(delta: float) -> void: - timer += delta - if timer >= next_spawn_time: - timer = 0.0 - _reset_timer() - _spawn_enemy() - - -func _reset_timer() -> void: - next_spawn_time = randf_range(min_spawn_interval, max_spawn_interval) - - -func _spawn_enemy() -> void: - if fleet.ships.is_empty(): +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(): + GameState.wave_cleared() + +# 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(): + # Nothing to attack. Leave it pending rather than spending the wave + # on an empty map - the fleet-wipe safety net rebuilds within a frame + # and the wave arrives then. + return + GameState.wave_pending = false + var composition: PackedStringArray = GameState.wave_composition(fleet.ships.size()) var visible_rect: Rect2 = ViewUtils.get_visible_world_rect(self) var radius: float = visible_rect.size.length() / 2.0 + spawn_margin - var angle: float = randf() * TAU - var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius - - var enemy: Node2D = enemy_scene.instantiate() - # Set before add_child: Enemy._ready() is what resolves enemy_id into a - # definition, and it runs the moment the node enters the tree. - enemy.enemy_id = "lancer" if randf() < lancer_chance else "chaser" - enemy.position = spawn_pos - get_parent().add_child(enemy) + var centre: Vector2 = fleet.get_centroid() + var bearing: float = randf() * TAU + 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] + enemy.position = centre + Vector2(cos(angle), sin(angle)) * radius + get_parent().add_child(enemy) + wave_enemies.append(enemy) diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd index 5adc7dd..2832aad 100644 --- a/tests/test_lancer_enemy.gd +++ b/tests/test_lancer_enemy.gd @@ -24,8 +24,8 @@ extends SceneTree # ship that moves after the lock is missed, which is what makes the # telegraph worth reading # F. it dies on contact, taking the vessel with it -# G. the roaming spawner can actually produce one, and still produces -# chasers - a new enemy nobody meets is not a new enemy +# G. a wave can actually field one, and still fields chasers - a new +# enemy nobody meets is not a new enemy var game_state: Node var main: Node @@ -241,29 +241,25 @@ func _process(delta: float) -> bool: return _fail("the rammed vessel survived, so the lancer cost nothing to stop") print("lancer and vessel destroyed each other") - # --- G. the roaming spawner puts it in the game -------------- - var spawner: Node2D = main.get_node("EnemySpawner") - if spawner.lancer_chance <= 0.0 or spawner.lancer_chance >= 1.0: - return _fail("the spawner's lancer_chance is %.2f - it makes one enemy or the other, never a mix" \ - % spawner.lancer_chance) - # The spawner refuses to spawn against an empty fleet, and the - # ram just emptied it - the wipe safety net normally refills it, - # but this must not depend on that having happened yet. - if fleet.ships.is_empty(): - fleet.spawn_ships(1, Vector2(8000.0, 0.0)) - var made: Dictionary = {} - for i in range(40): - spawner.lancer_chance = 1.0 if i % 2 == 0 else 0.0 - spawner._spawn_enemy() - for enemy in get_nodes_in_group("enemies"): - if is_instance_valid(enemy): - made[enemy.enemy_id] = true - enemy.queue_free() - if not made.has("lancer"): - return _fail("40 spawns at lancer_chance 1.0 produced no lancer") - if not made.has("chaser"): - return _fail("40 spawns at lancer_chance 0.0 produced no chaser") - print("the roaming spawner makes both %s" % str(made.keys())) + # --- G. waves can field one --------------------------------- + # This originally checked the roaming spawner's share of lancers. + # #100 removed that spawner entirely: the way a lancer reaches + # 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. + var kinds: Dictionary = {} + var lancer_at: int = 0 + for size in range(1, 12): + for id in game_state.wave_composition(size): + kinds[id] = true + if id == "lancer" and lancer_at == 0: + lancer_at = size + if lancer_at == 0: + return _fail("no wave against a fleet of 1..11 contains a lancer") + if not kinds.has("chaser"): + return _fail("lancers displaced the chaser out of every wave") + print("waves field lancers from a fleet of %d up, alongside %s" \ + % [lancer_at, str(kinds.keys())]) print("TEST_LANCER_ENEMY: PASS") quit(0) diff --git a/tests/test_wave_system.gd b/tests/test_wave_system.gd new file mode 100644 index 0000000..9e89c98 --- /dev/null +++ b/tests/test_wave_system.gd @@ -0,0 +1,228 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_wave_system.gd +# Forgejo #100 (#97b): waves replace the roaming enemy spawner. +# +# The old EnemySpawner put one enemy on the map every 3-6 seconds for ever. +# There was no rhythm to it and nothing to prepare for. Enemies now arrive +# in waves: a long quiet, a warning, an attack, and quiet again once the +# wave is dead. +# +# The clock lives in GameState because Main.tscn is destroyed on every trip +# to a planet surface - a wave must not be cancelled, nor restarted from +# zero, by a landing. +# +# Covers: +# A. the phase machine runs peace -> warning -> active, and a wave that is +# killed puts it back to peace +# B. no timer is exposed and none is drawn. #97 asks for an alert with no +# visible countdown, so the alert's own text must carry no digits and +# GameState must not offer a remaining-seconds accessor at all +# C. waves get stronger with the FLEET, and with the wave number +# D. the trickle is gone: a live director with no wave running leaves the +# map empty, where the old spawner would have filled it +# E. a launched wave really arrives, around the fleet, and clearing it +# returns the world to peace +# F. the alert is up during the warning and at no other time + +var game_state: Node +var scene: Node +var spawner: Node2D +var fleet: Node2D +var alert: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.reset_progression() + game_state.reset_waves() + + +func _fail(message: String) -> bool: + print("TEST_WAVE_SYSTEM: FAIL (%s)" % message) + quit(1) + return true + + +func _state_cases() -> bool: + # --- A. the phase machine -------------------------------------------- + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("a reset game does not open in peace") + game_state.advance_waves(GameState.WAVE_PEACE_SECONDS - 0.1) + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("the peace ended early") + game_state.advance_waves(0.2) + if not game_state.wave_is_incoming(): + return _fail("the peace ran out without a warning") + if game_state.wave_number != 0: + return _fail("the wave was counted while it was still only a warning") + game_state.advance_waves(GameState.WAVE_WARNING_SECONDS - 0.1) + if not game_state.wave_is_incoming(): + return _fail("the warning ended early") + game_state.advance_waves(0.2) + if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE: + return _fail("the warning did not turn into an attack") + if game_state.wave_number != 1 or not game_state.wave_pending: + return _fail("wave %d turned active without being owed to anyone" % game_state.wave_number) + game_state.wave_cleared() + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE or game_state.wave_pending: + return _fail("clearing the wave did not return the world to peace") + print("peace -> warning -> wave 1 -> peace") + + # --- B. nothing that could be read as a countdown -------------------- + for forbidden in ["wave_seconds_left", "wave_time_left", "wave_countdown", + "seconds_until_wave", "wave_remaining"]: + if game_state.has_method(forbidden): + return _fail("GameState.%s() exists - the alert could become a timer" % forbidden) + game_state.advance_waves(GameState.WAVE_PEACE_SECONDS + 0.1) + if not game_state.wave_is_incoming(): + return _fail("could not get back into a warning for the pulse check") + var pulse: float = game_state.wave_warning_pulse() + if pulse < 0.0 or pulse > 1.0: + return _fail("the warning pulse is %f, outside 0..1" % pulse) + print("no remaining-seconds accessor exists; urgency is a %.2f pulse" % pulse) + + # --- C. strength follows the fleet ----------------------------------- + var small: PackedStringArray = game_state.wave_composition(1) + var large: PackedStringArray = game_state.wave_composition(9) + if small.is_empty(): + return _fail("a wave against a single vessel is empty") + if large.size() <= small.size(): + return _fail("a fleet of 9 draws %d enemies, no more than the %d a single vessel draws" \ + % [large.size(), small.size()]) + if game_state.wave_strength(9) <= game_state.wave_strength(1): + return _fail("wave strength does not rise with fleet size") + var before_number: int = game_state.wave_number + game_state.wave_number = before_number + 20 + var later: PackedStringArray = game_state.wave_composition(1) + 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. + var heavy_kinds: Dictionary = {} + for id in large: + heavy_kinds[id] = true + if heavy_kinds.size() < 2: + 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" \ + % [small.size(), large.size(), heavy_kinds.size()]) + game_state.reset_waves() + return false + + +func _enemy_count() -> int: + var n: int = 0 + for enemy in get_nodes_in_group("enemies"): + if is_instance_valid(enemy) and not enemy.is_queued_for_deletion(): + n += 1 + return n + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if _state_cases(): + return true + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + spawner = scene.get_node("EnemySpawner") + fleet = scene.get_node("Fleet") + alert = scene.get_node("UI/WaveAlert") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + # The director stays ENABLED for this test - it is the thing + # under test. + game_state.reset_waves() + elapsed = 0.0 + stage = 2 + return false + 2: + # --- D. no trickle ------------------------------------------ + # Comfortably longer than the old 3-6s spawn interval, so the + # removed behaviour would have shown up several times over. + if elapsed < 8.0: + if _enemy_count() > 0: + return _fail("an enemy appeared with no wave running - the trickle is still there") + return false + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("the peace should still be running after %.0fs" % elapsed) + print("%.0fs of peace and not one enemy - the old trickle is gone" % elapsed) + + # --- F. the alert is up only during the warning -------------- + if alert.visible: + return _fail("the wave alert is showing during peacetime") + game_state.wave_phase = GameState.WAVE_PHASE_WARNING + game_state.wave_timer = 0.0 + alert._process(0.0) + if not alert.visible: + return _fail("the wave alert did not appear for an incoming wave") + var text: String = alert.label.text + for c in text: + if c >= "0" and c <= "9": + return _fail("the alert reads \"%s\" - that is a timer" % text) + print("the alert reads \"%s\" during the warning, with no digits in it" % text) + elapsed = 0.0 + stage = 3 + return false + 3: + # --- E. the wave really arrives ----------------------------- + game_state.wave_phase = GameState.WAVE_PHASE_ACTIVE + game_state.wave_number += 1 + game_state.wave_pending = true + spawner._process(0.0) + var arrived: int = _enemy_count() + if arrived <= 0: + return _fail("the wave turned active and nothing arrived") + if game_state.wave_pending: + return _fail("the wave was launched but is still owed") + if alert.visible: + alert._process(0.0) + if alert.visible: + return _fail("the alert is still up after the wave arrived") + var centre: Vector2 = fleet.get_centroid() + var nearest: float = INF + for enemy in get_nodes_in_group("enemies"): + if is_instance_valid(enemy): + nearest = minf(nearest, centre.distance_to(enemy.global_position)) + if nearest < 200.0: + return _fail("a wave enemy arrived %.0fpx from the fleet - that is on top of them" % nearest) + print("wave %d arrived: %d enemies, nearest %.0fpx away" \ + % [game_state.wave_number, arrived, nearest]) + + for enemy in get_nodes_in_group("enemies"): + if is_instance_valid(enemy): + enemy.queue_free() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.2: + return false + spawner._process(0.0) + if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: + return _fail("killing the whole wave did not bring the quiet back") + print("the wave died and the quiet came back") + + print("TEST_WAVE_SYSTEM: PASS") + quit(0) + return true + return false diff --git a/ui/WaveAlert.gd b/ui/WaveAlert.gd new file mode 100644 index 0000000..69b779c --- /dev/null +++ b/ui/WaveAlert.gd @@ -0,0 +1,68 @@ +extends Control + +# #100: "an small alert on top right appear when a new wave is coming. No +# timer should be visible." +# +# The second half of that is a constraint on this file, not just a note. +# GameState deliberately offers no remaining-seconds accessor - only +# wave_warning_pulse(), which is a 0..1 throb - so there is no number here +# that could be rendered as a countdown even by accident. The urgency is +# carried by the pulse rate, which says "soon" without saying "in 4". + +const GLOW: Color = Color(1.0, 0.45, 0.2) +const TEXT: String = "WAVE INCOMING" + +var label: Label + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + label = Label.new() + label.text = TEXT + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 22) + label.add_theme_color_override("font_color", GLOW) + label.add_theme_color_override("font_outline_color", Color(0.05, 0.01, 0.0, 0.95)) + label.add_theme_constant_override("outline_size", 3) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + add_child(label) + resized.connect(_layout) + _layout() + + +func _layout() -> void: + if label == null: + return + # Room on the left for the chevron _draw() puts there. + label.position = Vector2(size.y, 0.0) + label.size = Vector2(maxf(size.x - size.y, 0.0), size.y) + + +func _process(_delta: float) -> void: + var incoming: bool = GameState.wave_is_incoming() + if incoming != visible: + visible = incoming + if incoming: + queue_redraw() + + +func _draw() -> void: + if not visible: + return + var pulse: float = GameState.wave_warning_pulse() + var alpha: float = 0.35 + 0.65 * pulse + draw_rect(Rect2(Vector2.ZERO, size), Color(0.16, 0.04, 0.01, 0.62)) + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + Vector2(0.0, 0.0), Vector2(size.x, 0.0), Vector2(size.x, size.y), + Vector2(0.0, size.y), Vector2(0.0, 0.0), + ]), Color(GLOW, alpha), 0.9, 0.7) + # A warning triangle, not a clock face. + var centre: Vector2 = Vector2(size.y * 0.5, size.y * 0.5) + var radius: float = size.y * 0.28 + NeonDraw.draw_glowing_polygon(self, PackedVector2Array([ + centre + Vector2(0.0, -radius), + centre + Vector2(radius * 0.92, radius * 0.72), + centre + Vector2(-radius * 0.92, radius * 0.72), + ]), Color(GLOW, alpha), 1.0) diff --git a/world/Main.tscn b/world/Main.tscn index 0750e9b..03e5c60 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -20,6 +20,7 @@ [ext_resource type="Script" path="res://ui/NeonNumber.gd" id="19"] [ext_resource type="Script" path="res://ui/NeonPanel.gd" id="20"] [ext_resource type="Script" path="res://ui/SkillTreeView.gd" id="21"] +[ext_resource type="Script" path="res://ui/WaveAlert.gd" id="32"] [ext_resource type="Script" path="res://logistics/TransporterField.gd" id="23"] [ext_resource type="Script" path="res://ui/ResourceBar.gd" id="24"] [ext_resource type="Script" path="res://logistics/VesselDeliveryField.gd" id="26"] @@ -207,6 +208,17 @@ offset_bottom = 172 script = ExtResource("10") icon = 0 +[node name="WaveAlert" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -300.0 +offset_right = -24.0 +offset_top = 188.0 +offset_bottom = 252.0 +script = ExtResource("32") + [node name="Joystick" type="Control" parent="UI"] mouse_filter = 2 script = ExtResource("13")