diff --git a/controllers/AimedFleetAbilityController.gd b/controllers/AimedFleetAbilityController.gd index 191c062..46af21d 100644 --- a/controllers/AimedFleetAbilityController.gd +++ b/controllers/AimedFleetAbilityController.gd @@ -33,7 +33,10 @@ func _ready() -> void: func _process(delta: float) -> void: var had_visuals: bool = aiming or _has_effect_visuals() - button.visible = fleet.has_capability(_component_id()) + # #122: "there's no action button available, only movements". Checked + # here rather than by the director reaching in, because this line runs + # every frame and would put the button straight back. + button.visible = fleet.has_capability(_component_id()) and not GameState.intro_active button.enabled = _next_ready_ship() != null and _can_afford_shot() if not button.visible and aiming: _cancel_aim() diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 273de3d..cd3bd69 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -397,6 +397,19 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: _check_fleet_wipe() + # #122: the prologue leaves the player movement and nothing else. Handled + # at the top, before any of the context detection below, so nothing can + # turn a button back on further down. + if GameState.intro_active: + _hide_all_actions() + return + # Build is permanently under the resource counter (see + # _layout_context_buttons) - it is the one action button nothing else + # ever hides, so the prologue taking it away is the only thing that has + # to give it back. + if not build_button.visible: + build_button.visible = true + if build_menu.visible: # Keep the greyed state live while the menu is open, so flying onto # a planet lights the option up without reopening it. @@ -735,6 +748,21 @@ func _refresh_build_option_states() -> void: entry[1].queue_redraw() +# Everything the player can PRESS, as opposed to steer with. Used by the +# prologue (#122), which takes them all away at once. +func _hide_all_actions() -> void: + current_building = null + pending_building = null + current_extractor_index = -1 + pending_extractor_index = -1 + current_landing_planet = null + context_actions_expanded = false + for control in [build_button, action_button, building_action_button, + landing_button, transfer_button, hangar_button]: + if control != null: + control.visible = false + + func _close_build_menu() -> void: # The popup is a child panel of this menu; leaving it "open" while its # parent is hidden is what blocked every button in the game. diff --git a/controllers/GameController.gd b/controllers/GameController.gd index dfb608a..a2fe086 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -250,15 +250,13 @@ func _process(delta: float) -> void: # anchor in _process, so this only needs to report the direction. The # keyboard takes precedence, and releasing the keys has to stop the fleet # itself - unlike the stick, there is no centred-again signal to hook. - var keyed: Vector2 = keyboard_direction() - if keyed != Vector2.ZERO: - keyboard_steering = true - fleet.move_in_direction(keyed) - elif keyboard_steering: + # #124: the breach drives. One check here covers the stick, the keyboard + # and the released-again path together - three separate gates would be + # three chances for one of them to keep steering. + if GameState.intro_locks_movement: keyboard_steering = false - fleet.stop() - elif joystick != null and joystick.direction != Vector2.ZERO: - fleet.move_in_direction(joystick.direction) + else: + _apply_steering() var target: Vector2 = fleet.get_centroid() if camera.global_position.distance_to(target) <= camera_snap_distance: @@ -272,6 +270,18 @@ func _process(delta: float) -> void: camera.global_position = camera.global_position.lerp(target, t) +func _apply_steering() -> void: + var keyed: Vector2 = keyboard_direction() + if keyed != Vector2.ZERO: + keyboard_steering = true + fleet.move_in_direction(keyed) + elif keyboard_steering: + keyboard_steering = false + fleet.stop() + elif joystick != null and joystick.direction != Vector2.ZERO: + fleet.move_in_direction(joystick.direction) + + func _unhandled_input(event: InputEvent) -> void: if event is InputEventScreenTouch: _handle_touch(event) diff --git a/core/GameState.gd b/core/GameState.gd index 8ee5ed4..a70f41e 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -24,6 +24,24 @@ var surviving_vessel_types: PackedStringArray = PackedStringArray() # whenever the composition changes. GameState cannot see scene nodes, and the # census the Planning Center works from has to include the fleet. var fleet_vessel_types: PackedStringArray = PackedStringArray() +# #122: the prologue is running. Set by reset_new_game() so it plays once at +# the start of a NEW game and never on a resumed one, and cleared by the +# director when the prologue hands over. +# +# It is a flag rather than a separate scene because the prologue DIRECTS the +# real game: the real fleet, the real enemies, the real asteroid field. That +# is also what makes "then the game starts" cost nothing - the director stops +# steering and there is nothing left to load. +var intro_active: bool = false +# #124: the breach takes the wheel. Movement input is ignored while this is +# set, so the fleet goes where the script sends it rather than fighting the +# player for the last few seconds of the prologue. +var intro_locks_movement: bool = false +# #123: the prologue's convoy is bigger than any fleet the player can field +# yet - it is not their fleet, it is the one they are about to lose. An +# explicit override rather than a fake doctrine, so nothing about the +# player's own progression has to be bent to fit a cutscene. +var intro_fleet_size: int = 0 var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 @@ -566,6 +584,8 @@ func research_for_building(type: String) -> String: func max_fleet_size() -> int: + if intro_fleet_size > 0: + return mini(intro_fleet_size, DEFAULT_FORMATION.size()) var n: int = BASE_FLEET_SIZE for id in unlocked: n += int(skill_node(id).get("fleet_bonus", 0)) @@ -582,6 +602,9 @@ func reset_progression() -> void: # process would start from wherever the last one left off instead of a # fresh game. func reset_new_game() -> void: + intro_active = true + intro_locks_movement = false + intro_fleet_size = 0 has_return_position = false surviving_ship_count = STARTING_SHIP_COUNT surviving_vessel_types = PackedStringArray() diff --git a/planets/PlanetAtmosphereController.gd b/planets/PlanetAtmosphereController.gd index 60dc775..7b1d562 100644 --- a/planets/PlanetAtmosphereController.gd +++ b/planets/PlanetAtmosphereController.gd @@ -39,6 +39,12 @@ func _ready() -> void: camera.position = GameState.return_world_position camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) GameState.has_return_position = false + elif GameState.intro_active: + # #108: the prologue opens with a convoy, and crews it itself. This + # node still runs first, so spawning the ordinary opening fleet here + # would be immediately thrown away - and worse, would be the fleet + # for the one frame before the director replaced it. + pass else: # A brand new game. STARTING_SHIP_COUNT rather than a literal: this # said 5, which was the old BASE_FLEET_SIZE written out by hand, and diff --git a/tests/test_dev_mode.gd b/tests/test_dev_mode.gd index c443c42..57e027d 100644 --- a/tests/test_dev_mode.gd +++ b/tests/test_dev_mode.gd @@ -36,6 +36,9 @@ func _process(delta: float) -> bool: 0: # --- E. a normal game hands over nothing ---------------------- game_state.reset_new_game() + # #108: reset_new_game() also arms the prologue. Nothing below is + # about it, and this test never builds a scene for it to run in. + game_state.intro_active = false if game_state.rocks != 0: return _fail("a normal game started with %d ore banked" % game_state.rocks) for resource_type in game_state.resource_types(): diff --git a/tests/test_fleet_growth.gd b/tests/test_fleet_growth.gd index 4c58207..53bb41a 100644 --- a/tests/test_fleet_growth.gd +++ b/tests/test_fleet_growth.gd @@ -31,6 +31,11 @@ var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") game_state.reset_new_game() + # #108: reset_new_game() arms the prologue, and the prologue opens with a + # convoy rather than the game's own starting fleet. This test is about + # what the GAME starts from, which is where the prologue hands over - + # see test_intro_scene.gd for the prologue itself. + game_state.intro_active = false func _process(delta: float) -> bool: @@ -96,6 +101,9 @@ func _process(delta: float) -> bool: # --- B. what actually flies ---------------------------------- game_state.reset_new_game() + # ... and again here: this is the reset the scene below is built + # from, so the prologue has to be off for THIS one too. + game_state.intro_active = false scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd new file mode 100644 index 0000000..fda172a --- /dev/null +++ b/tests/test_intro_scene.gd @@ -0,0 +1,296 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_intro_scene.gd +# Forgejo #108 (#122, #123, #124): the prologue. +# +# The director drives the REAL scene rather than being a scene of its own, so +# everything below is checked against the real fleet, the real enemies and +# the real asteroid field. +# +# Covers: +# A. it runs on a NEW game and not on a resumed one +# B. "no action button available, only movements" - every pressable +# affordance is gone, and the joystick is not +# C. the convoy: bigger than the player's own ceiling, and mostly holds +# D. the guide speaks, and the banner never shows a line nobody set +# E. the ambush is easy: chasers only, and clearing them advances the beat +# F. the belt is dense, and it is across the lane rather than beside it +# G. the breach takes the wheel: player input stops moving the fleet +# H. the vortex leaves exactly one vessel, and it is the default interceptor +# I. the handover: the flag clears, the buttons come back, the spawner and +# the asteroid field are running again, and the fleet is a new game's +# J. skipping lands in the same state as playing it through + +var game_state: Node +var scene: Node +var director: Node +var fleet: Node +var narration: Control +var stage: int = 0 +var elapsed: float = 0.0 +var convoy_size: int = 0 +var skipped_props: Array = [] + + +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.reset_new_game() + game_state.rocks = 0 + + +func _fail(message: String) -> bool: + print("TEST_INTRO_SCENE: FAIL (%s)" % message) + quit(1) + return true + + +func _load_scene() -> void: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + director = scene.get_node_or_null("IntroDirector") + narration = scene.get_node("UI/NarrationBanner") + + +func _drop_scene() -> void: + scene.queue_free() + scene = null + + +func _hidden(path: String) -> bool: + var node: Control = scene.get_node_or_null(path) + return node == null or not node.visible + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + # --- A. a new game gets one ------------------------------ + if not game_state.intro_active: + return _fail("reset_new_game() did not arm the prologue") + _load_scene() + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + if director == null: + return _fail("no IntroDirector in the scene") + if not director.running: + return _fail("the director did not start on a new game") + + # --- C. the convoy -------------------------------------- + convoy_size = fleet.ships.size() + if convoy_size <= GameState.BASE_FLEET_SIZE: + return _fail("the convoy is %d vessels, no more than a new game's %d slots" \ + % [convoy_size, GameState.BASE_FLEET_SIZE]) + var holds: int = 0 + var escorts: int = 0 + for ship in fleet.ships: + if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + escorts += 1 + else: + holds += 1 + if holds <= escorts: + return _fail("the convoy is %d holds to %d escorts - it should be mostly cargo" \ + % [holds, escorts]) + if escorts <= 0: + return _fail("the convoy has no escorts at all") + print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts]) + + # --- B. movement and nothing else ----------------------- + for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton", + "UI/BuildingActionButton", "UI/EnterAtmosphereButton", + "UI/TransferButton", "UI/HangarButton", "UI/LaserButton", + "UI/GasBombButton"]: + if not _hidden(pressable): + return _fail("%s is offered during the prologue" % pressable) + var joystick: Control = scene.get_node("UI/Joystick") + if not joystick.visible: + return _fail("the joystick is hidden - the player cannot even fly") + print("every action button is away; the joystick is not") + + # --- D. the guide speaks -------------------------------- + if not narration.is_speaking(): + return _fail("the prologue opened without a word from the guide") + if narration.label.text.strip_edges() == "": + return _fail("the banner is up with nothing on it") + print("guide: \"%s\"" % narration.label.text) + + # --- E. the ambush -------------------------------------- + director._begin(director.Beat.AMBUSH) + if director.ambush.is_empty(): + return _fail("the ambush beat spawned nothing") + for enemy in director.ambush: + if String(enemy.enemy_id) != "chaser": + return _fail("the ambush includes a %s - it is meant to be easy" % enemy.enemy_id) + if director._beat_complete(): + return _fail("the ambush counts as cleared while its raiders are alive") + for enemy in director.ambush: + enemy.queue_free() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.3: + return false + # The director advances itself, so the evidence that clearing the + # ambush ended the beat is that it has MOVED ON - asking + # _beat_complete() again would be asking about a beat it left. + if director.beat <= director.Beat.AMBUSH: + return _fail("killing every raider did not end the ambush beat") + print("%d raiders, all chasers, and clearing them ends the beat" % director.AMBUSH_COUNT) + + # --- F. the belt ---------------------------------------- + director._begin(director.Beat.GAUNTLET) + # Only the rocks still standing: the convoy flies into them, and + # a rock it has already broken is not evidence about the belt. + var standing: Array = [] + for rock in director.rocks: + if is_instance_valid(rock) and not rock.is_queued_for_deletion(): + standing.append(rock) + if standing.size() < 40: + return _fail("the \"pretty dense\" belt is %d rocks" % standing.size()) + var ahead: int = 0 + for rock in standing: + if rock.global_position.y < fleet.get_centroid().y: + ahead += 1 + if ahead < standing.size() / 2: + return _fail("only %d of %d rocks are on the lane ahead" \ + % [ahead, standing.size()]) + print("belt: %d rocks, %d of them across the lane ahead" % [standing.size(), ahead]) + + # --- G. the breach takes the wheel ---------------------- + director._begin(director.Beat.BREACH) + if not game_state.intro_locks_movement: + return _fail("the breach beat left the player in control") + # GameController.gd is the script on Main itself, not a child. + var before: Vector2 = fleet.fleet_target + scene._process(0.1) + if fleet.fleet_target != before: + return _fail("player steering still moved the fleet during the breach") + print("the breach has the wheel: steering input moves nothing") + + # --- H. the vortex -------------------------------------- + director._begin(director.Beat.VORTEX) + elapsed = 0.0 + stage = 3 + return false + 3: + # Left to the director's own clock rather than ticked by hand: + # the vortex is a timed animation and driving it twice would not + # be the thing that ships. + if elapsed < 4.2: + return false + var alive: int = 0 + for ship in fleet.ships: + if is_instance_valid(ship) and not ship.is_queued_for_deletion(): + alive += 1 + if alive != 1: + return _fail("the vortex left %d vessels, expected 1" % alive) + if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR: + return _fail("the survivor is a %s, not the default interceptor" \ + % fleet.ships[0].vessel_type) + print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1)) + + # --- I. the handover ------------------------------------ + director.finish() + elapsed = 0.0 + stage = 4 + return false + 4: + if elapsed < 0.3: + return false + if game_state.intro_active or game_state.intro_locks_movement: + return _fail("the prologue finished without clearing its own flags") + if game_state.intro_fleet_size != 0: + return _fail("the convoy's fleet-size override outlived the prologue") + if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE: + return _fail("the fleet still holds %d slots after the prologue" \ + % game_state.max_fleet_size()) + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("the game starts with %d vessels, expected %d" \ + % [fleet.ships.size(), GameState.STARTING_SHIP_COUNT]) + if not scene.get_node("EnemySpawner").is_processing(): + return _fail("waves are still switched off after the prologue") + if not scene.get_node("AsteroidField").is_processing(): + return _fail("the asteroid field is still switched off after the prologue") + scene.get_node("BuildController")._process(0.1) + if _hidden("UI/BuildButton"): + return _fail("the build button never came back") + if narration.is_speaking(): + return _fail("the guide is still talking after the handover") + print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back") + _drop_scene() + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < 0.3: + return false + # --- A (second half). a resumed game gets no prologue ---- + game_state.intro_active = false + _load_scene() + elapsed = 0.0 + stage = 6 + return false + 6: + if elapsed < 0.4: + return false + if director != null and is_instance_valid(director) and director.running: + return _fail("the prologue ran again on a game that is past it") + if fleet.ships.size() > GameState.BASE_FLEET_SIZE: + return _fail("a resumed game opened with a convoy") + print("a game already past the prologue does not replay it") + _drop_scene() + elapsed = 0.0 + stage = 7 + return false + 7: + if elapsed < 0.3: + return false + # --- J. skipping lands where playing it through does ----- + game_state.reset_new_game() + _load_scene() + elapsed = 0.0 + stage = 8 + return false + 8: + if elapsed < 0.4: + return false + # Skipped mid-prologue, with props on the board, so the check + # below is about clearing them rather than about an empty stage. + director._begin(director.Beat.AMBUSH) + director._begin(director.Beat.GAUNTLET) + skipped_props = director.ambush + director.rocks + if skipped_props.size() < 40: + return _fail("nothing was on the board to skip past") + director.skip() + elapsed = 0.0 + stage = 9 + return false + 9: + if elapsed < 0.3: + return false + if game_state.intro_active or game_state.intro_locks_movement: + return _fail("skipping left the prologue's flags set") + if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE: + return _fail("skipping left the convoy's fleet size behind") + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + return _fail("skipping left %d vessels flying" % fleet.ships.size()) + for prop in skipped_props: + if is_instance_valid(prop) and not prop.is_queued_for_deletion(): + return _fail("skipping left the prologue's own props on the board") + print("skipping clears %d props and lands where playing it through does" \ + % skipped_props.size()) + + print("TEST_INTRO_SCENE: PASS") + quit(0) + return true + return false diff --git a/tests/test_lab_research_reward.gd b/tests/test_lab_research_reward.gd index 14e0154..82948bd 100644 --- a/tests/test_lab_research_reward.gd +++ b/tests/test_lab_research_reward.gd @@ -24,6 +24,11 @@ var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") game_state.reset_new_game() + # #108: reset_new_game() arms the prologue, and the prologue opens with a + # convoy rather than the game's own starting fleet. This test is about + # what the GAME starts from, which is where the prologue hands over - + # see test_intro_scene.gd for the prologue itself. + game_state.intro_active = false func _process(delta: float) -> bool: diff --git a/tests/test_research_points.gd b/tests/test_research_points.gd index 32b3b39..0981714 100644 --- a/tests/test_research_points.gd +++ b/tests/test_research_points.gd @@ -65,6 +65,11 @@ func _initialize() -> void: # --- F. a fresh game still reaches a crafter and an extractor ------------- game_state.reset_new_game() + # #108: reset_new_game() arms the prologue, and the prologue opens with a + # convoy rather than the game's own starting fleet. This test is about + # what the GAME starts from, which is where the prologue hands over - + # see test_intro_scene.gd for the prologue itself. + game_state.intro_active = false # can_unlock() also checks the ore price, and a new game now banks # nothing - the opening mining run is the point. Stock the ore so this # checks what it says it checks: reachability at ZERO RESEARCH POINTS. diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd new file mode 100644 index 0000000..f67d5d8 --- /dev/null +++ b/ui/NarrationBanner.gd @@ -0,0 +1,110 @@ +extends Control + +# #122: what the guide says during the prologue. +# +# A banner rather than a dialogue box: the player is flying while they read +# it, so it sits across the top out of the way of the fleet and never takes +# a press to dismiss. The SCRIPT decides when a line is done, not the player +# - a prologue that waits for a tap is a prologue that can be sat in. +# +# Lines queue rather than replacing each other, so a beat that fires while +# the previous line is still up does not silently eat it. + +const HOLD_SECONDS: float = 4.5 +const FADE_SECONDS: float = 0.5 +const TEXT_COLOR: Color = Color(0.82, 0.92, 1.0) +const FRAME_COLOR: Color = Color(0.45, 0.68, 0.95) + +var label: Label +var queued: Array = [] +var current: String = "" +var time_left: float = 0.0 + + +func _ready() -> void: + _ensure_label() + + +# The director is a world node and this banner lives under the UI layer, +# which readies afterwards - so the first line can arrive before _ready(). +# Building on demand is simpler than making the director wait a frame, and +# it cannot be got wrong twice. +func _ensure_label() -> void: + if label != null: + return + mouse_filter = Control.MOUSE_FILTER_IGNORE + visible = false + label = Label.new() + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", 30) + label.add_theme_color_override("font_color", TEXT_COLOR) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 0.95)) + label.add_theme_constant_override("outline_size", 4) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(label) + resized.connect(_layout) + _layout() + + +func _layout() -> void: + if label == null: + return + label.position = Vector2(24.0, 0.0) + label.size = Vector2(maxf(size.x - 48.0, 0.0), size.y) + + +# Says one line. Held for `hold` seconds, then the next in the queue. +func say(line: String, hold: float = HOLD_SECONDS) -> void: + _ensure_label() + queued.append({"line": line, "hold": maxf(hold, 0.1)}) + if current == "": + _advance() + + +func clear() -> void: + queued.clear() + current = "" + time_left = 0.0 + visible = false + + +func is_speaking() -> bool: + return current != "" or not queued.is_empty() + + +func _advance() -> void: + if queued.is_empty(): + current = "" + time_left = 0.0 + visible = false + return + var next: Dictionary = queued.pop_front() + current = String(next.line) + time_left = float(next.hold) + label.text = current + visible = true + queue_redraw() + + +func _process(delta: float) -> void: + if current == "": + return + time_left -= delta + queue_redraw() + if time_left <= 0.0: + _advance() + + +func _draw() -> void: + if current == "": + return + # Fades out over its last moments so lines hand over rather than cutting. + var alpha: float = clampf(time_left / FADE_SECONDS, 0.0, 1.0) + label.modulate.a = alpha + draw_rect(Rect2(Vector2.ZERO, size), Color(0.02, 0.05, 0.12, 0.72 * alpha)) + NeonDraw.draw_glowing_line(self, Vector2(0.0, size.y), Vector2(size.x, size.y), + Color(FRAME_COLOR, alpha), 0.7, 0.55) + NeonDraw.draw_glowing_line(self, Vector2(0.0, 0.0), Vector2(size.x, 0.0), + Color(FRAME_COLOR, alpha * 0.6), 0.5, 0.4) diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd new file mode 100644 index 0000000..b5eaed4 --- /dev/null +++ b/world/IntroDirector.gd @@ -0,0 +1,350 @@ +extends Node2D + +# #108: the prologue - the convoy, the ambush, the asteroid belt, and the +# breach that leaves the player with one ship and no way home. +# +# It directs the REAL scene rather than being a scene of its own. It wants +# the real fleet, the real enemies and the real asteroid field, and it makes +# "then the game starts" cost nothing: the director stops steering, clears +# GameState.intro_active, and what is left running is the ordinary game with +# exactly the opening position a new game already has. +# +# The script is a list of BEATS. Each has a deadline as well as its own "am +# I done" test, so a beat whose condition is never met - the player refusing +# to fly at the ambush, say - cannot wedge the prologue; it simply runs out +# and the next one starts. skip() ends the whole thing at any point and +# lands in the same state as playing it through, so there is one way for the +# game to start rather than two that have to be kept in step. + +signal finished + +# Beats, in order. The names are the whole outline. +enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH } + +# Where the shipment was going, and never arrives. +const WORLD_SHIP_AT: Vector2 = Vector2(0.0, -16000.0) +# The convoy: mostly holds, a few escorts. Larger than any fleet the player +# can crew yet, which is the point - this is not their fleet. +# Carriers are the game's cargo hull; transporters belong to hubs and are +# not fleet-eligible at all, so a convoy of them would be silently filtered +# down to its escorts. +const CONVOY: PackedStringArray = ["interceptor", "carrier", "carrier", + "carrier", "interceptor", "carrier", "carrier", "interceptor", + "carrier", "interceptor"] +const AMBUSH_COUNT: int = 3 +const AMBUSH_AHEAD: float = 1100.0 +const RUN_DISTANCE: float = 1500.0 +const BELT_AHEAD: float = 2200.0 +const BELT_ROCKS: int = 90 +const BELT_HALF_WIDTH: float = 2600.0 +const BELT_DEPTH: float = 1400.0 +const BREACH_PULL: float = 620.0 +const SHAKE_MAX: float = 34.0 +const VORTEX_COLORS: Array = [ + Color(0.55, 0.35, 1.0), Color(0.25, 0.9, 1.0), Color(1.0, 0.35, 0.8), + Color(1.0, 0.8, 0.3), Color(0.4, 1.0, 0.6), +] + +@onready var fleet: Node2D = get_node("../Fleet") +@onready var camera: Camera2D = get_node("../Camera2D") +@onready var narration: Control = get_node("../UI/NarrationBanner") +@onready var asteroid_field: Node2D = get_node("../AsteroidField") +@onready var enemy_spawner: Node2D = get_node("../EnemySpawner") + +var running: bool = false +var beat: int = Beat.BRIEF +var beat_time: float = 0.0 +var start_at: Vector2 = Vector2.ZERO +var belt_at: Vector2 = Vector2.ZERO +var breach_at: Vector2 = Vector2.ZERO +var ambush: Array = [] +var rocks: Array = [] +var shake: float = 0.0 +var vortex_time: float = 0.0 +var survivor: Node2D = null +var _done: bool = false + + +func _ready() -> void: + # A NEW-game thing. A player returning from a planet surface, or + # resuming, is already past it. + running = GameState.intro_active + if not running: + queue_free() + return + # Nothing arrives on its own while the prologue runs: every enemy and + # every rock in it is placed by this script. + enemy_spawner.set_process(false) + asteroid_field.set_process(false) + for hazard in get_tree().get_nodes_in_group("asteroids") \ + + get_tree().get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() + _begin(Beat.BRIEF) + + +# --- the frame ------------------------------------------------------------ + +func _process(delta: float) -> void: + if not running: + return + beat_time += delta + _tick(delta) + _apply_shake(delta) + if beat_time >= _deadline() or _beat_complete(): + if beat >= Beat.AFTERMATH: + finish() + return + _begin(beat + 1) + + +# Ends the prologue and hands over. The same call ends it whether it was +# played out or skipped. +func finish() -> void: + if _done: + return + _done = true + running = false + _clear_props() + GameState.intro_active = false + GameState.intro_locks_movement = false + GameState.intro_fleet_size = 0 + narration.clear() + camera.offset = Vector2.ZERO + enemy_spawner.set_process(true) + asteroid_field.set_process(true) + # Exactly a new game's opening position: one interceptor, nothing else. + if fleet.ships.size() != GameState.STARTING_SHIP_COUNT: + fleet.spawn_ships(GameState.STARTING_SHIP_COUNT, fleet.get_centroid(), + PackedStringArray([GameState.VESSEL_INTERCEPTOR])) + finished.emit() + queue_free() + + +func skip() -> void: + finish() + + +func _clear_props() -> void: + for prop in ambush + rocks: + if is_instance_valid(prop): + prop.queue_free() + ambush.clear() + rocks.clear() + queue_redraw() + + +# --- the script ----------------------------------------------------------- + +func _begin(next: int) -> void: + beat = next + beat_time = 0.0 + match beat: + Beat.BRIEF: + start_at = fleet.get_centroid() + GameState.intro_fleet_size = CONVOY.size() + fleet.spawn_ships(CONVOY.size(), start_at, CONVOY) + narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.", 5.0) + narration.say("Hold your heading and keep the holds intact. We will be watching.", 4.5) + Beat.RUN: + narration.say("World Ship bears ahead. Fly on.", 3.5) + Beat.AMBUSH: + _spawn_ambush() + narration.say("Contacts ahead - raiders, a light group. Break them and continue.", 4.5) + Beat.CLEAR: + narration.say("Clean work, Kestrel. Resume your heading.", 3.5) + Beat.GAUNTLET: + _spawn_belt() + narration.say("Debris belt across your lane. Take it slow - there is no way around.", 5.0) + Beat.BREACH: + GameState.intro_locks_movement = true + breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) + narration.say("Kestrel, your heading is - something is opening ahead of you -", 4.0) + Beat.VORTEX: + vortex_time = 0.0 + narration.clear() + Beat.AFTERMATH: + GameState.intro_locks_movement = false + shake = 0.0 + narration.say("It seems you've lost your way through the space, lost and abandoned.", 5.0) + narration.say("No communication with the World Ship can be heard.", 5.0) + + +func _deadline() -> float: + match beat: + Beat.BRIEF: return 10.0 + Beat.RUN: return 45.0 + Beat.AMBUSH: return 75.0 + Beat.CLEAR: return 4.0 + Beat.GAUNTLET: return 60.0 + Beat.BREACH: return 4.2 + Beat.VORTEX: return 3.6 + Beat.AFTERMATH: return 10.5 + return 1.0 + + +func _beat_complete() -> bool: + match beat: + Beat.RUN: + return start_at.distance_to(fleet.get_centroid()) >= RUN_DISTANCE + Beat.AMBUSH: + return _living(ambush) == 0 + Beat.GAUNTLET: + # Past the belt, rather than merely inside it. + return fleet.get_centroid().y <= belt_at.y - BELT_DEPTH * 0.4 + return false + + +func _tick(delta: float) -> void: + match beat: + Beat.BREACH: + shake = minf(shake + delta * SHAKE_MAX * 0.9, SHAKE_MAX) + # Dragged in, not flown in: the player has already lost the wheel. + var to_breach: Vector2 = breach_at - fleet.get_centroid() + if to_breach.length() > 1.0: + fleet.move_in_direction(to_breach.normalized()) + queue_redraw() + Beat.VORTEX: + vortex_time += delta + shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35) + _whirl(delta) + queue_redraw() + Beat.AFTERMATH: + shake = maxf(shake - delta * SHAKE_MAX, 0.0) + queue_redraw() + + +func _apply_shake(delta: float) -> void: + if shake <= 0.01: + if camera.offset != Vector2.ZERO: + camera.offset = Vector2.ZERO + return + # Offset, never position: GameController owns where the camera IS, and a + # shake written into that would be smoothed away by its own follow. + camera.offset = Vector2(randf_range(-shake, shake), randf_range(-shake, shake)) + if beat != Beat.BREACH and beat != Beat.VORTEX: + shake = maxf(shake - delta * SHAKE_MAX, 0.0) + + +# --- the pieces ----------------------------------------------------------- + +func _spawn_ambush() -> void: + for enemy in ambush: + if is_instance_valid(enemy): + enemy.queue_free() + ambush.clear() + var ahead: Vector2 = fleet.get_centroid() + Vector2(0.0, -AMBUSH_AHEAD) + for i in range(AMBUSH_COUNT): + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + # Chasers, and only chasers. "easy enough" is the requirement, and a + # sniper or an artillery piece against a convoy the player cannot + # yet fight properly is neither easy nor a good first impression. + enemy.enemy_id = "chaser" + enemy.position = ahead + Vector2((float(i) - 1.0) * 260.0, randf_range(-120.0, 120.0)) + get_parent().add_child(enemy) + ambush.append(enemy) + + +func _spawn_belt() -> void: + # Idempotent: replaying the beat replaces the belt rather than laying a + # second one on top of it. + for rock in rocks: + if is_instance_valid(rock): + rock.queue_free() + rocks.clear() + belt_at = fleet.get_centroid() + Vector2(0.0, -BELT_AHEAD) + var scene: PackedScene = load("res://world/Asteroid.tscn") + for i in range(BELT_ROCKS): + var rock: Node2D = scene.instantiate() + rock.position = belt_at + Vector2( + randf_range(-BELT_HALF_WIDTH, BELT_HALF_WIDTH), + randf_range(-BELT_DEPTH, BELT_DEPTH)) + get_parent().add_child(rock) + rocks.append(rock) + + +# The colourful part: the fleet is thrown around the breach and comes apart, +# one vessel at a time, until a single interceptor is left. +func _whirl(delta: float) -> void: + var keep: Node2D = _survivor() + var spin: float = vortex_time * 5.0 + for i in range(fleet.ships.size() - 1, -1, -1): + var ship: Node2D = fleet.ships[i] + if not is_instance_valid(ship): + continue + var angle: float = spin + TAU * float(i) / maxf(float(fleet.ships.size()), 1.0) + var radius: float = lerpf(520.0, 60.0, clampf(vortex_time / 3.0, 0.0, 1.0)) + var want: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * radius + ship.position = ship.position.lerp(want, clampf(delta * 4.0, 0.0, 1.0)) + ship.move_target = ship.position + if ship == keep: + continue + # Staggered rather than all at once, so the fleet visibly comes apart + # instead of blinking out. + if vortex_time >= 0.6 + 0.22 * float(i): + ship.take_damage(1) + + +func _survivor() -> Node2D: + if is_instance_valid(survivor): + return survivor + # The one that lives is the ordinary interceptor - the ship the game + # starts from - not whichever happened to be first in the list. + for ship in fleet.ships: + if is_instance_valid(ship) and String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR: + survivor = ship + return survivor + survivor = fleet.ships[0] if not fleet.ships.is_empty() else null + return survivor + + +func _living(nodes: Array) -> int: + var n: int = 0 + for node in nodes: + if is_instance_valid(node) and not node.is_queued_for_deletion(): + n += 1 + return n + + +# --- what the prologue draws ---------------------------------------------- + +func _draw() -> void: + if not running: + return + if beat == Beat.BREACH: + _draw_breach(clampf(beat_time / _deadline(), 0.0, 1.0)) + elif beat == Beat.VORTEX: + _draw_breach(1.0) + _draw_vortex() + + +func _draw_breach(progress: float) -> void: + var radius: float = lerpf(20.0, 340.0, progress) + draw_circle(breach_at, radius, Color(0.06, 0.0, 0.12, 0.75 * progress)) + for i in range(VORTEX_COLORS.size()): + var colour: Color = VORTEX_COLORS[i] + var r: float = radius * (0.45 + 0.14 * float(i)) + var spin: float = beat_time * (1.4 + 0.5 * float(i)) + NeonDraw.draw_glowing_path(self, _spiral(breach_at, r, spin, i), + Color(colour, 0.55 + 0.35 * progress), 0.9, 0.5) + + +func _draw_vortex() -> void: + # Ribbons dragged inward, brightening as the fleet is pulled apart. + var pull: float = clampf(vortex_time / 3.0, 0.0, 1.0) + for i in range(VORTEX_COLORS.size() * 2): + var colour: Color = VORTEX_COLORS[i % VORTEX_COLORS.size()] + var angle: float = vortex_time * 3.2 + TAU * float(i) / float(VORTEX_COLORS.size() * 2) + var outer: float = lerpf(900.0, 120.0, pull) + var from: Vector2 = breach_at + Vector2(cos(angle), sin(angle)) * outer + var to: Vector2 = breach_at + Vector2(cos(angle + 1.1), sin(angle + 1.1)) * 40.0 + NeonDraw.draw_glowing_line(self, from, to, Color(colour, 0.35 + 0.5 * pull), 1.0, 0.8) + + +func _spiral(centre: Vector2, radius: float, phase: float, arm: int) -> PackedVector2Array: + var points: PackedVector2Array = PackedVector2Array() + for i in range(18): + var t: float = float(i) / 17.0 + var angle: float = phase + t * TAU * 0.8 + float(arm) + points.append(centre + Vector2(cos(angle), sin(angle)) * radius * (0.35 + 0.65 * t)) + return points diff --git a/world/Main.tscn b/world/Main.tscn index 9f30978..a203216 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -24,6 +24,8 @@ [ext_resource type="Script" path="res://ui/GarrisonPanel.gd" id="33"] [ext_resource type="Script" path="res://vessels/GarrisonField.gd" id="34"] [ext_resource type="Script" path="res://ui/PlanningPanel.gd" id="35"] +[ext_resource type="Script" path="res://ui/NarrationBanner.gd" id="36"] +[ext_resource type="Script" path="res://world/IntroDirector.gd" id="37"] [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"] @@ -92,6 +94,9 @@ script = ExtResource("11") [node name="BuildingField" type="Node2D" parent="."] script = ExtResource("14") +[node name="IntroDirector" type="Node2D" parent="."] +script = ExtResource("37") + [node name="GarrisonField" type="Node2D" parent="."] script = ExtResource("34") @@ -214,6 +219,16 @@ offset_bottom = 172 script = ExtResource("10") icon = 0 +[node name="NarrationBanner" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 60.0 +offset_right = -60.0 +offset_top = 300.0 +offset_bottom = 428.0 +script = ExtResource("36") + [node name="WaveAlert" type="Control" parent="UI"] visible = false mouse_filter = 2 diff --git a/world/WorldMap.gd b/world/WorldMap.gd index b1f631a..6714532 100644 --- a/world/WorldMap.gd +++ b/world/WorldMap.gd @@ -21,6 +21,20 @@ func _ready() -> void: close_button.pressed.connect(close_map) +func _process(_delta: float) -> void: + # #122: the prologue leaves movement and nothing else, and the map is a + # button like any other. Owned here rather than by BuildController, which + # hides the rest - this button belongs to the map. + if GameState.intro_active: + if open_button.visible: + open_button.visible = false + if visible: + close_map() + open_button.visible = false + elif not open_button.visible and not visible: + open_button.visible = true + + func open_map() -> void: visible = true open_button.visible = false