diff --git a/controllers/FireStickController.gd b/controllers/FireStickController.gd index dee06ef..338d607 100644 --- a/controllers/FireStickController.gd +++ b/controllers/FireStickController.gd @@ -2,6 +2,11 @@ extends Node2D # Points every armed vessel's gun wherever the right-hand stick is pushed. # +# #172: what it points them at is a PLACE, not a heading. The mouse names one +# directly; a pushed stick names the one at the guns' reach along it. Each hull +# then works out its own heading to that place, so the fleet's fire converges +# there instead of leaving in parallel - see VesselWeaponComponent. +# # The fleet's guns used to seek their own targets and fire on their own. That # made combat something that happened TO the player rather than something they # did, and left the right half of the screen with nothing on it. Aiming is now @@ -20,6 +25,12 @@ const GUIDE_DASH: float = 14.0 const GUIDE_GAP: float = 16.0 const GUIDE_WIDTH: float = 2.0 const GUIDE_COLOR: Color = Color(1.0, 0.55, 0.38, 0.28) +# #172: the fleet aims at a PLACE now, so the guide ends in a mark on that +# place rather than trailing off into the dark. Four ticks around a gap, not +# a filled dot - the thing being aimed at has to stay visible inside it. +const RETICLE_RADIUS: float = 22.0 +const RETICLE_TICK: float = 11.0 +const RETICLE_COLOR: Color = Color(1.0, 0.62, 0.42, 0.55) # By path rather than by class name, for the load-order reason documented in # PlanetSetups and elsewhere. @@ -30,33 +41,82 @@ const WEAPON_COMPONENT = preload("res://vessels/VesselWeaponComponent.gd") # runs - the guns simply stay quiet, which is the correct resting state. @onready var stick: Node = get_node_or_null("../UI/FireStick") +# The guide's heading, measured from the fleet's centre to the aimed point. +# Derived rather than input since #172 - it is what gets DRAWN, not what gets +# fired: each hull works out its own heading to the point (see +# VesselWeaponComponent.firing_heading). var aim_direction: Vector2 = Vector2.ZERO +# Where the fleet is shooting, in world space, and whether it is shooting at +# all. #172: a place, not a heading, so the fleet's fire converges on it. +var aim_point: Vector2 = Vector2.ZERO +var has_aim: bool = false # Set by MouseCombatController (#80) when a desktop player holds the left -# button. Kept as a second input rather than a second aimer, so there is still -# exactly one thing in the scene that points a gun. -var external_direction: Vector2 = Vector2.ZERO +# button: the world point under the cursor, which is already a place and needs +# no converting. Kept as a second input rather than a second aimer, so there is +# still exactly one thing in the scene that points a gun. +var external_target: Vector2 = Vector2.ZERO +var has_external_target: bool = false func _process(_delta: float) -> void: var direction: Vector2 = stick.direction if stick != null else Vector2.ZERO - if direction == Vector2.ZERO: + var was_aiming: bool = has_aim + if direction != Vector2.ZERO: # The stick wins when both are live: a finger on it is a deliberate # act, a mouse resting somewhere is not. - direction = external_direction - aim(direction) + aim_at(stick_point(direction)) + elif has_external_target: + aim_at(external_target) + else: + hold_fire() # The guide starts at the fleet, which is usually moving, so this redraws # while aiming rather than only when the heading changes. Once the stick is # released it redraws once more to clear the line, then stops. - var was_aiming: bool = aim_direction != Vector2.ZERO - aim_direction = direction - if direction != Vector2.ZERO or was_aiming: + if has_aim or was_aiming: queue_redraw() +# #172: a stick reports a heading, and the guns want a place. The place is at +# the guns' own reach along that heading - far enough out that a pushed stick +# still reads as "everything that way", and near enough that the fleet's fire +# meets rather than spreading for ever. +func stick_point(direction: Vector2) -> Vector2: + if fleet == null or direction == Vector2.ZERO: + return Vector2.ZERO + return fleet.get_centroid() + direction.normalized() * maxf(guide_range(), 1.0) + + # Also the entry point for anything driving the guns without a stick - tests # aim through this rather than reaching into each weapon component, so what -# they exercise is the same path the stick uses. +# they exercise is the same path the stick uses. Takes a HEADING, which is +# what a stick has; aim_at() takes the place it resolves to. func aim(direction: Vector2) -> void: + if direction == Vector2.ZERO: + hold_fire() + return + aim_at(stick_point(direction)) + + +# Points every armed hull at one place. Each works out its own heading to it, +# which is the whole of #172: two ships either side of a target now both hit +# it instead of both missing it by half the gap between them. +func aim_at(point: Vector2) -> void: + if fleet == null: + return + aim_point = point + has_aim = true + aim_direction = point - fleet.get_centroid() + for ship in fleet.ships: + if not is_instance_valid(ship): + continue + var weapon: Node = ship.get_component("weapon") + if weapon != null: + weapon.aim_at(point) + + +func hold_fire() -> void: + has_aim = false + aim_direction = Vector2.ZERO if fleet == null: return for ship in fleet.ships: @@ -64,7 +124,7 @@ func aim(direction: Vector2) -> void: continue var weapon: Node = ship.get_component("weapon") if weapon != null: - weapon.set_fire_direction(direction) + weapon.hold_fire() # How far the guide reaches: the actual range of a gun that is actually @@ -80,7 +140,8 @@ func guide_range() -> float: func _draw() -> void: - if aim_direction == Vector2.ZERO or fleet == null or fleet.ships.is_empty(): + if not has_aim or aim_direction == Vector2.ZERO or fleet == null \ + or fleet.ships.is_empty(): return var reach: float = guide_range() if reach <= 0.0: @@ -90,12 +151,29 @@ func _draw() -> void: # the line keeps telling the truth about where fire is going (#89). var heading: Vector2 = WEAPON_COMPONENT.assisted_heading(get_tree(), origin, aim_direction, reach) + # #172: the line stops at the aimed PLACE when that is inside the guns' + # reach. Running it on to full range past a point the fleet is converging + # on would be drawing a direction again, which is what the ticket took + # away. Beyond reach it stops at reach, as it always did - the guide never + # promises further than the guns carry (#86). + var length: float = minf(aim_direction.length(), reach) var travelled: float = GUIDE_GAP - while travelled < reach: - var dash_end: float = minf(travelled + GUIDE_DASH, reach) + while travelled < length: + var dash_end: float = minf(travelled + GUIDE_DASH, length) draw_line(origin + heading * travelled, origin + heading * dash_end, GUIDE_COLOR, GUIDE_WIDTH, true) travelled = dash_end + GUIDE_GAP + _draw_reticle(origin + heading * length) + + +# Four ticks around the aimed point, open in the middle so whatever is being +# shot at stays visible inside them. +func _draw_reticle(at: Vector2) -> void: + for step in range(4): + var out: Vector2 = Vector2.RIGHT.rotated(TAU * float(step) / 4.0) + draw_line(at + out * RETICLE_RADIUS, + at + out * (RETICLE_RADIUS + RETICLE_TICK), + RETICLE_COLOR, GUIDE_WIDTH, true) func is_firing() -> bool: diff --git a/controllers/GameController.gd b/controllers/GameController.gd index 28999cd..75fa7c9 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -172,22 +172,38 @@ func _handle_key(event: InputEventKey) -> void: # #85: on a computer the on-screen sticks and the pivot pair are dead weight - -# WASD steers, the mouse aims, Q/E pivot. They are taken away together, once, -# the first time the player uses a keyboard or a mouse. +# WASD steers, the mouse aims, Q/E pivot. # -# Two guards, both deliberate. A touch device that ALSO has a keyboard keeps -# everything, because a finger is still a way to play it. And a device wrongly -# reported as non-touch keeps its controls until the player has demonstrably -# used something else - so nobody is ever left with no way to steer or shoot. +# #171: they now come and GO, rather than being taken away once and for all. +# +# The old rule asked DisplayServer.is_touchscreen_available() and, if it said +# yes, kept everything for ever - because the failure to avoid is a phone left +# with no way to steer. In a browser that query says yes on half the laptops +# in the world, so the web build showed thumb sticks to people holding a +# mouse, which is the ticket. It also could not be undone: a wrong answer the +# other way stranded a tablet with no controls until it was restarted. +# +# Following the LAST INPUT ACTUALLY USED fixes both ends. Touch the screen and +# the controls are there; use the mouse or the keyboard and they go. Nobody is +# ever stranded, because the gesture that needs them is the same gesture that +# brings them back, and it takes one tap. GameState owns the reading (see +# note_touch_input) so the mouse-aiming gate cannot disagree with this. func _note_keyboard() -> void: - if keyboard_seen: - return keyboard_seen = true - if DisplayServer.is_touchscreen_available(): - return + GameState.note_pointer_input() + _sync_touch_controls() + + +func _note_touch() -> void: + GameState.note_touch_input() + _sync_touch_controls() + + +func _sync_touch_controls() -> void: + var offered: bool = GameState.pointer_is_touch for control in [joystick, fire_stick, pivot_cw_button, pivot_ccw_button]: - if control != null: - control.visible = false + if control != null and control.visible != offered: + control.visible = offered func _button_holds(button: IconButton, at_position: Vector2) -> bool: @@ -339,6 +355,11 @@ func _unhandled_input(event: InputEvent) -> void: func _handle_touch(event: InputEventScreenTouch) -> void: + # #171: a finger on the glass is the one unambiguous piece of evidence + # that this is a touch device being played by touch, and it is recorded + # before anything gets a chance to claim the event - a touch swallowed by + # the joystick is still a touch. + _note_touch() # The joystick gets first refusal on every touch, and only claims ones # that begin inside its own rect - so pinch-to-zoom still works anywhere # else on screen. @@ -372,6 +393,8 @@ func _handle_touch(event: InputEventScreenTouch) -> void: func _handle_drag(event: InputEventScreenDrag) -> void: + # A finger moving on the glass, same as a finger arriving on it (#171). + _note_touch() if gas_bomb_controller != null and gas_bomb_controller.handle_drag(event): return if laser_controller != null and laser_controller.handle_drag(event): diff --git a/controllers/MouseCombatController.gd b/controllers/MouseCombatController.gd index 0d16845..5ea06a2 100644 --- a/controllers/MouseCombatController.gd +++ b/controllers/MouseCombatController.gd @@ -10,10 +10,15 @@ extends Node2D # # Touch is unaffected, and deliberately so: Godot emulates a mouse button from # every touch, so without a guard a tap anywhere on a phone would fire the -# guns. The guard is the presence of a touchscreen rather than a "has used a -# mouse" flag, because the wrong answer has to fail safe - a desktop wrongly -# reported as touch-capable loses a convenience, while a phone wrongly treated -# as a desktop fires its weapons at every tap. +# guns. +# +# #171 changed what that guard asks. It used to be the presence of a +# touchscreen, which fails safe but fails often: a desktop browser reporting a +# touch-capable panel switched mouse aiming off on exactly the machine it was +# written for. It now asks what the player last actually used +# (GameState.pointer_is_touch), which still fails safe - a tap makes the +# reading "touch" before the emulated click arrives, so a phone can never fire +# through this path - and stops being wrong about desktops. # A literal, not PackedStringArray(...): a constructor call is not a constant # expression, and the script fails to PARSE rather than to run - which shows up @@ -42,11 +47,11 @@ func _ready() -> void: abilities.append(controller) -# AUTO decides from the display, which is what every scene ships with. The -# explicit settings exist because "is there a touchscreen" is not a question -# that can be answered honestly in every environment - headless reports one, -# so with AUTO alone the aiming below could never be exercised by a test at -# all, and the only covered behaviour would be the guard itself. +# AUTO follows the last input the player actually used, which is what every +# scene ships with. The explicit settings exist because that reading is seeded +# from the display in an environment - headless - where the answer varies by +# platform, so with AUTO alone the aiming below could never be exercised by a +# test at all, and the only covered behaviour would be the guard itself. enum PointerMode { AUTO, ON, OFF } @export var pointer_mode: PointerMode = PointerMode.AUTO @@ -58,7 +63,7 @@ func is_enabled() -> bool: return true PointerMode.OFF: return false - return not DisplayServer.is_touchscreen_available() + return not GameState.pointer_is_touch func weapon_count() -> int: @@ -117,7 +122,11 @@ func _process(_delta: float) -> void: # there is still exactly one thing in the scene that aims a gun. The stick # wins when both are live - a finger on it is a deliberate act, while a # mouse merely resting somewhere is not. - var heading: Vector2 = Vector2.ZERO - if is_enabled() and firing and selected_weapon == 0 and not fleet.ships.is_empty(): - heading = aim_world - fleet.get_centroid() - fire_stick_controller.external_direction = heading + # + # #172: the cursor's world position goes across as a PLACE. It always was + # one; it used to be flattened into a heading from the fleet's centre on + # the way out, which threw away the distance and made every hull fire + # parallel past the thing being clicked on. + fire_stick_controller.has_external_target = is_enabled() and firing \ + and selected_weapon == 0 and not fleet.ships.is_empty() + fire_stick_controller.external_target = aim_world diff --git a/core/CatalogCheck.gd b/core/CatalogCheck.gd new file mode 100644 index 0000000..5de9142 --- /dev/null +++ b/core/CatalogCheck.gd @@ -0,0 +1,129 @@ +class_name CatalogCheck +extends RefCounted + +# Forgejo #173: "in the web export version the fleet can not store any +# resources." +# +# That symptom has a history in this project, and it is not a web bug. Opening +# the project in the Godot editor re-saves .tres files, and the editor's +# writer DROPS properties it considers default-valued. An empty-looking +# PackedStringArray is the usual casualty, and it has now cost this project +# three times: +# +# GasBurnerBuildingDefinition lost `capabilities` and `collected_outputs`, +# which took the burner's whole conversion with it. +# +# InterceptorVesselDefinition lost `capabilities`, `hull_points`, +# `build_time`, `icon` and `glow_color`. The starting vessel kept its +# cargo_capacity of 20 and stopped claiming the "cargo" capability, which +# is the one thing fleet_cargo_capacity() gates on - so the hold was zero +# and nothing could be mined or carried. +# +# The second one is #173, word for word. A build exported from a tree in that +# state has a fleet that cannot store any resources, and NOTHING SAYS SO: the +# resource still loads, it is just quietly less than it was. +# +# test_definitions_intact caught it in the suite. What it could not do is +# catch it in a build, and an export is taken from the working tree at +# whatever moment the editor last touched it - which is exactly when the +# damage is done and exactly where nobody is running tests. +# +# So the same check now runs at STARTUP, in every build (see GameState._ready) +# and in the test, from this one function. On the web its push_error lands in +# the on-page error log that web_shell.html mirrors from the console, which is +# the only place a phone can read an error at all. A player still cannot mine, +# but the reason is on the screen instead of being reasoned about for an +# evening. +# +# It asserts the SHAPE of the catalog rather than any one value: every +# definition must carry the things that make it what it is, and two facts that +# have to agree - a hold and the capability that admits to it - must agree. + + +# Everything wrong with the catalog, in the order found. Empty is the healthy +# answer, which is what both callers check. +static func faults() -> PackedStringArray: + var found: PackedStringArray = PackedStringArray() + _check_vessels(found) + _check_buildings(found) + return found + + +# get() answers null for a property a definition's script never declared, and +# null has no is_empty(). These keep a missing field from looking like an empty +# one - and from taking the rest of the sweep down with it. +static func _text(definition: Resource, field: String) -> String: + var value: Variant = definition.get(field) + return "" if value == null else str(value) + + +static func _list(definition: Resource, field: String) -> Array: + var value: Variant = definition.get(field) + if value == null: + return [] + return Array(value.keys() if value is Dictionary else value) + + +static func _check_vessels(found: PackedStringArray) -> void: + for definition in VesselDefinitions.all(): + var id: String = String(definition.definition_id) + # --- whole ------------------------------------------------------ + if String(definition.display_name).strip_edges() == "": + found.append("vessel %s has no display name" % id) + if definition.base_stats.is_empty(): + found.append("vessel %s has no stats at all" % id) + # Only the hulls that actually join the fleet: a repair drone is a + # logistics unit driven by GameState, with nothing bolted to it. + if definition.fleet_eligible and definition.component_scenes.is_empty(): + found.append("vessel %s flies in the fleet with no components - an empty hull" % id) + if definition.hull_points.is_empty(): + found.append("vessel %s has no hull points - it would draw as the fallback triangle" % id) + # --- cargo: the #173 case --------------------------------------- + # The hold and the capability are two recordings of one fact, and + # fleet_cargo_capacity() reads only the second. They must agree. + var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0 + var claims: bool = "cargo" in definition.capabilities + if carries != claims: + found.append("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \ + % [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities]) + if "weapon" in definition.capabilities \ + and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0: + found.append("vessel %s claims a weapon and has no range" % id) + + +static func _check_buildings(found: PackedStringArray) -> void: + for definition in BuildingDefinitions.all(): + var id: String = String(definition.definition_id) + # --- whole ------------------------------------------------------ + if String(definition.display_name).strip_edges() == "": + found.append("building %s has no display name" % id) + if String(definition.description).strip_edges() == "": + found.append("building %s has no description - its info popup would be blank" % id) + if int(definition.cost) <= 0: + found.append("building %s is free" % id) + if int(definition.max_hp) <= 0: + found.append("building %s has no hit points" % id) + # Space-placed structures only: an extractor is a marker on a planet + # rather than a thing standing in the world, and has no scene. + if definition.placement_mode == "space" and definition.visual_scene == null: + found.append("building %s stands in space with nothing to draw" % id) + for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]: + if str(definition.get(field)).strip_edges() == "": + found.append("building %s has no %s - the build menu could not place it" % [id, field]) + if definition.capabilities.is_empty(): + found.append("building %s declares no capabilities - it would do nothing at all" % id) + # --- converters ------------------------------------------------- + # These live on the LOGISTICS profile, a sub-resource of the + # definition, not on the definition itself. Asking the definition for + # them answers null and the whole check quietly never fires - which is + # how the first draft of this passed while the gas burner sat there + # with its collected_outputs missing. + var logistics: Resource = definition.logistics + if logistics == null or _text(logistics, "conversion_input") == "": + continue + if _text(logistics, "conversion_output") == "": + found.append("building %s converts something into nothing" % id) + if _list(logistics, "collected_outputs").is_empty(): + found.append("building %s converts but lists no collected outputs - what it makes would never be fetched" % id) + if _list(logistics, "input_buffers").is_empty(): + found.append("building %s converts with no input buffer" % id) diff --git a/core/CatalogCheck.gd.uid b/core/CatalogCheck.gd.uid new file mode 100644 index 0000000..0ef2375 --- /dev/null +++ b/core/CatalogCheck.gd.uid @@ -0,0 +1 @@ +uid://bk64c5fpvnc57 diff --git a/core/GameState.gd b/core/GameState.gd index 501960e..808f402 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -10,6 +10,7 @@ const PLANET_DEFINITIONS = preload("res://planets/PlanetDefinitions.gd") # works around. const WORLD_GEN = preload("res://world/WorldGen.gd") const QUESTS = preload("res://core/Quests.gd") +const CATALOG_CHECK = preload("res://core/CatalogCheck.gd") # Autoload singleton - the one thing that survives change_scene_to_file() @@ -38,6 +39,10 @@ var fleet_position: Vector2 = Vector2.ZERO # 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 +# #173: what the startup catalog check found, if anything. Empty in a healthy +# build; see CatalogCheck for why a build can be unhealthy without anything +# else noticing. +var catalog_faults: PackedStringArray = PackedStringArray() # #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. @@ -47,6 +52,57 @@ var intro_locks_movement: bool = false # 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 + +# --- what the player is actually holding (#171) ----------------------------- +# +# "The web export should auto detect keyboard mouse or screen and remove the +# touch screen controls if not needed." +# +# DisplayServer.is_touchscreen_available() is the wrong question in a browser +# and always was. It answers "could a touch arrive", and a desktop browser on +# a laptop with a touch-capable panel - or one that merely reports +# navigator.maxTouchPoints - says yes. So the thumb sticks and the pivot pair +# sat over a screen being played with a mouse, and mouse aiming, which the +# same query gates, was switched off on the machine it exists for. +# +# The right question is what the player LAST actually used, which is a fact +# rather than a capability, and it is asked here rather than in a controller +# because two of them need the same answer: GameController shows or hides the +# on-screen controls, and MouseCombatController decides whether a click fires. +# One answer, so they can never disagree. +# +# The catch is that Godot can manufacture the other kind of event from each +# one - the pointing/emulate_* settings in project.godot turn taps into clicks +# and clicks into taps. Taken at face value a phone would look like it had a +# mouse the instant a finger touched the glass. So a touch SUPPRESSES mouse +# evidence for a moment afterwards: a manufactured click always follows its +# touch within the same frame, well inside the window, while a player who +# genuinely puts a finger down and then reaches for the mouse takes far longer +# than this to do it. Correct whether that emulation is on or off - with it +# off there is simply nothing for the window to suppress. +const POINTER_TOUCH_GRACE_MSEC: int = 1000 +# Seeded from the display's own answer so the very first frame is sensible on +# a phone, and immediately overwritten by the first real input either way. +var pointer_is_touch: bool = DisplayServer.is_touchscreen_available() +# When a touch was last seen, in engine milliseconds. -1 for never. Public so +# a test can age it out rather than sleeping for a second. +var last_touch_msec: int = -1 + + +func note_touch_input() -> void: + last_touch_msec = Time.get_ticks_msec() + pointer_is_touch = true + + +# A mouse or a key. Ignored while a touch is still recent, because that is +# what a manufactured event looks like. +func note_pointer_input() -> void: + if last_touch_msec >= 0 \ + and Time.get_ticks_msec() - last_touch_msec < POINTER_TOUCH_GRACE_MSEC: + return + pointer_is_touch = false + + var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 @@ -3084,6 +3140,23 @@ var visited_planets: Dictionary = {} var visited_planet_kinds: Dictionary = {} +# #173: the catalog is checked once, here, in every build - not only in the +# test suite. The editor can quietly strip a property out of a .tres between +# the last test run and the export, and the shipped result is a game that +# looks fine and cannot mine. push_error rather than a crash: the game is +# still playable in whatever way survived, and on the web these lines reach +# the on-page log web_shell.html mirrors from the console, which is the only +# error a phone can read. +func _ready() -> void: + catalog_faults = CATALOG_CHECK.faults() + if catalog_faults.is_empty(): + return + push_error("The definition catalog is damaged - see core/CatalogCheck.gd. %d fault(s):" \ + % catalog_faults.size()) + for fault in catalog_faults: + push_error(" catalog: %s" % fault) + + func reset_quests(armed: bool = false) -> void: quests_armed = armed quest_index = 0 @@ -3194,17 +3267,37 @@ func take_quest_messages() -> Array: # Ends the current quest whether or not its goal was met. The chain's only # way forward, used by _advance_quests below and by the tests. -func complete_active_quest() -> bool: +# +# #167: `announce` is what a SKIPPED quest turns off. The index still moves +# and quest_completed still fires - the quest is credited exactly as if it +# had been played - but nothing is queued for the banner to read out. +func complete_active_quest(announce: bool = true) -> bool: var quest: Dictionary = active_quest() if quest.is_empty(): return false - say_quest_line(String(quest.get("done", ""))) + if announce: + say_quest_line(String(quest.get("done", ""))) quest_index += 1 quest_announced = false quest_completed.emit(String(quest.id)) return true +# #167: a quest handed out with its goal already met, credited in silence. +# +# Both signals still fire, in the order they would have fired in had it been +# played, so anything watching the chain sees an ordinary start-then-finish +# rather than a quest that was never begun. What it does NOT do is speak: +# neither the brief nor the answer reaches quest_messages, so the game is +# never stopped twice to be told about something already done. +func skip_active_quest() -> bool: + var quest: Dictionary = active_quest() + if quest.is_empty(): + return false + quest_started.emit(String(quest.id)) + return complete_active_quest(false) + + func _advance_quests() -> void: # The prologue is already talking, and the fleet in it is not the # player's. The chain starts when the prologue hands over. @@ -3212,6 +3305,21 @@ func _advance_quests() -> void: return var quest: Dictionary = active_quest() if not quest_announced: + # #167: the chain hands out teaching quests, and by the time some of + # them come up the player has already done the thing - the extractor + # is down, the hangar is crewed. Announcing one of those stops the + # game to explain a task, then stops it again to congratulate them + # on a task they never did. So an unimportant quest whose goal is + # already met is banked without a word (see Quests.is_important); + # it still shows up as done in the objectives panel, which is where + # the player looks for what they have been credited with. + if quest_goal_met(quest) and not QUESTS.is_important(quest): + skip_active_quest() + # One a frame. Several already-met quests in a row are cleared + # over the next few frames rather than in a loop here, which + # keeps this function's shape - one step per call - and keeps a + # malformed chain from spinning inside a single tick. + return quest_announced = true say_quest_line(String(quest.get("brief", ""))) quest_started.emit(String(quest.id)) diff --git a/core/Quests.gd b/core/Quests.gd index 8ca948c..16c12f9 100644 --- a/core/Quests.gd +++ b/core/Quests.gd @@ -51,14 +51,31 @@ const GOAL_BANKED: String = "banked" # that fills it. const GOAL_GARRISONED: String = "garrisoned" +# #167: which quests carry the STORY, marked `"important": true` below. +# +# Most of the chain is teaching: bank some ore, place a crafter, open the +# hangar. A teaching quest handed to a player who has already done the thing +# has nothing to teach, and stopping the game twice to say so - once for the +# brief, once for the answer - is two dialogs about nothing. Those are +# skipped (GameState._advance_quests); they still land in the DONE list, so +# the player sees they were credited rather than lost. +# +# The ones marked here are different. They are the search for the World Ship +# - the wreck we start in, the call that gets no answer, the first world, the +# ground somebody else was already holding, the survey, the trail - and their +# lines are the plot rather than instructions. Those are read out whether the +# goal was already met or not, because skipping one drops a piece of the +# story on the floor. + const CHAIN: Array = [ { "id": "first_ore", "title": "SALVAGE", "objective": "Break asteroids for 5 ore", "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 5}, - "brief": "Hull intact. Reactor holding. Everything else we had is on the other side of that hole. There is ore in the rocks out here - break some, and we can start again.", - "done": "Five ore in the hold. It is not much. It is a beginning.", + "brief": "The hull holds. The reactor runs. Everything else we had is on the far side of that hole. These rocks carry ore. Break some, and we can start again.", + "done": "Five ore in the hold. It is not much, but it is a start.", + "important": true, }, { # #142: the gap this fills was a real one. "Collect 5 ore" was @@ -69,26 +86,28 @@ const CHAIN: Array = [ "title": "THE PILE", "objective": "Break asteroids for 20 ore", "goal": {"kind": GOAL_RESOURCE, "resource": "ore", "amount": 20}, - "brief": "Now do that four times over. Nothing permanent gets built out of five ore, and there is nothing else out here to spend it on yet.", - "done": "Twenty in the hold. That is a foundation.", + "brief": "Now do that four more times. Five ore builds nothing, and there is nothing else out here to spend it on yet.", + "done": "Twenty in the hold. Now we can build something.", }, { "id": "mothership", "title": "A PLACE TO STAND", "objective": "Build the mothership", "goal": {"kind": GOAL_BUILDING, "building": "mothership"}, - "brief": "A hull is not a base. Build the mothership - it is the only thing in the plans with a transmitter big enough to call the World Ship.", + "brief": "A ship is not a base. Build the mothership. It carries the only transmitter big enough to call the World Ship.", # The hinge of the whole chain (#129): the answer to "we built the # transmitter" is that the transmitter was never the problem. - "done": "Mothership online. Calling the World Ship. Calling. Nothing comes back - and the array is not at fault. Wherever the breach put us, it is further out than that signal will ever carry. We find their trail ourselves, or we do not find them.", + "done": "The mothership is online. Calling the World Ship. Calling. Nothing comes back, and the transmitter is not at fault. The breach threw us further out than any signal of ours will carry. So we find their trail ourselves, or we never find them.", + "important": true, }, { "id": "chart", "title": "DEAD RECKONING", "objective": "Chart 30 sectors", "goal": {"kind": GOAL_EXPLORE, "sectors": 30}, - "brief": "So we search. Fly, and let the map fill in behind us - whatever is left of their course is somewhere in it.", - "done": "Thirty sectors charted. No sign of the World Ship, but the sky behind us is no longer blank.", + "brief": "So we search. Fly, and the map fills in behind us. Their course is somewhere in it.", + "done": "Thirty sectors charted. No sign of the World Ship, but the map behind us is no longer blank.", + "important": true, }, { # #141: its own step, and the first time the chain sends anyone into @@ -99,42 +118,44 @@ const CHAIN: Array = [ "title": "SHIPYARDS", "objective": "Research the Shipyards doctrine", "goal": {"kind": GOAL_RESEARCH, "node": "shipyards"}, - "brief": "One hull will not chart a sky, and we cannot build what we have not remembered how to build. The mothership holds the archive - go aboard it and research Shipyards.", - "done": "Shipyard plans recovered. Everything else worth having is in that archive too.", + "brief": "One ship cannot chart a whole sky, and we have forgotten how to build more. The mothership holds the archive. Go aboard and research Shipyards.", + "done": "Shipyard plans recovered. There is more in that archive, if we go back for it.", }, { "id": "crafter", "title": "A YARD OF OUR OWN", "objective": "Build a ship crafter", "goal": {"kind": GOAL_BUILDING, "building": "ship_crafter"}, - "brief": "Now put a ship crafter down. Ore goes in, vessels come out - it is the only way this fleet grows past the one hull that came through the breach.", - "done": "The yard is live. We can build our own now.", + "brief": "Now build a ship crafter. Ore goes in, ships come out. It is the only way this fleet grows past the one hull that came through the breach.", + "done": "The yard is running. We can build our own ships now.", }, { "id": "second_hull", "title": "A SECOND HULL", "objective": "Fly two vessels at once", "goal": {"kind": GOAL_FLEET_VESSEL, "vessel": "interceptor", "count": 2}, - "brief": "Order an interceptor at the yard and crew it. Two guns is not a fleet, but it is twice what we flew out of the breach with.", - "done": "Two hulls in formation. It has been a while since this ship flew with company.", + "brief": "Order an interceptor at the yard and crew it. Two ships is not a fleet, but it is twice what we flew out of the breach with.", + "done": "Two ships in formation. It has been a long time since we flew with company.", }, { "id": "planetfall", "title": "PLANETFALL", "objective": "Land on a planet", "goal": {"kind": GOAL_PLANET_VISITS, "count": 1}, - "brief": "There are worlds out here. Bring the fleet in close to one and take it down - there is more on a surface than there is in all this dark.", - "done": "Down on solid ground. Whatever else this place is, it is somewhere.", + "brief": "There are worlds out here. Fly the fleet close to one and land on it. A surface holds more than all this empty dark.", + "done": "We are down on solid ground. Whatever else this place is, it is somewhere.", + "important": true, }, { "id": "conquest", "title": "GROUND HELD", "objective": "Take every capture point on one planet", "goal": {"kind": GOAL_CONQUERED, "count": 1}, - "brief": "Something got to this surface before us and it is still holding it. Take every point on the planet - what we pull out of their positions is worth more than the ore is.", + "brief": "Something reached this surface before us, and it is still holding it. Take every capture point on the planet. What we pull out of their camps is worth more than the ore.", # Says out loud where research points come from, because nothing # else in the game does. - "done": "Planet secured. Their equipment reads out as research data - the first we have had. Every doctrine worth having is going to cost some.", + "done": "The planet is ours. Their machines gave us research data, the first we have had. Every doctrine worth having will cost some.", + "important": true, }, { # #156: the extractor is the first thing the chain asks for that @@ -145,47 +166,47 @@ const CHAIN: Array = [ "title": "THE HOLD IS SMALL", "objective": "Bank 60 ore at the mothership", "goal": {"kind": GOAL_BANKED, "resource": "ore", "amount": 60}, - "brief": "Every hull out here carries twenty and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it - what is stored there is not carried by anyone.", - "done": "Sixty banked. The hold empties, the vault does not - that is the difference, and everything from here on depends on it.", + "brief": "Each ship carries twenty units and no more, and we are going to need more than twenty. Fly what we have back to the mothership and unload it. What is stored there stays there.", + "done": "Sixty in the vault. A hold empties on the next trip out. A vault does not. Everything from here on depends on that.", }, { "id": "extractor", "title": "STANDING MINE", "objective": "Build an extractor on a planet", "goal": {"kind": GOAL_BUILDING, "building": "extractor"}, - "brief": "Research Logistics and put an extractor down on a surface. It keeps digging whether we are standing over it or not.", - "done": "The extractor is cutting. Ore will be there when we come back for it.", + "brief": "Research Logistics, then place an extractor on a planet. It keeps digging whether we are standing over it or not.", + "done": "The extractor is digging. The ore will be waiting when we come back for it.", }, { "id": "gas", "title": "THE HEAVY AIR", "objective": "Bring back 25 gas", "goal": {"kind": GOAL_RESOURCE, "resource": "gas", "amount": 25}, - "brief": "The banded worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.", - "done": "Twenty-five units of gas aboard. Now we need something that can burn it.", + "brief": "The striped worlds are full of gas, and gas is the only thing out here that burns. Take the fleet into one and fill the tanks.", + "done": "Twenty-five gas aboard. Now we need something that can burn it.", }, { "id": "hub", "title": "FREIGHT", "objective": "Build a transport hub", "goal": {"kind": GOAL_BUILDING, "building": "transport_hub"}, - "brief": "Carrying every load by hand does not scale. Research Freight Handling and build a hub - its transporters run the routes without us.", - "done": "The hub is running its own traffic. That is our hands back.", + "brief": "Hauling every load ourselves is slow. Research Freight Handling and build a transport hub. Its transporters fly the routes without us.", + "done": "The hub runs its own traffic now. That frees us for other work.", }, { "id": "burner", "title": "POWER", "objective": "Build a gas burner", "goal": {"kind": GOAL_BUILDING, "building": "gas_burner"}, - "brief": "Research Power Systems and build a gas burner. Gas in, energy out - and energy is what the better weapons run on.", - "done": "The burner is lit. There is power on this fleet for the first time since the breach.", + "brief": "Research Power Systems and build a gas burner. Gas in, energy out. The better weapons run on energy.", + "done": "The burner is lit. This fleet has power again for the first time since the breach.", }, { "id": "lab", "title": "SHIP SCIENCE", "objective": "Build a ship lab", "goal": {"kind": GOAL_BUILDING, "building": "ship_lab"}, - "brief": "Research Vessel Science and build a ship lab. Every hull we fly can be made better there, and there are hulls in the archive we have never built.", + "brief": "Research Vessel Science and build a ship lab. It makes the ships we fly better, and it opens designs we have never built.", "done": "The lab is open.", }, { @@ -193,40 +214,42 @@ const CHAIN: Array = [ "title": "A NEW HULL", "objective": "Unlock the shield vessel", "goal": {"kind": GOAL_VESSEL_UNLOCKED, "vessel": "shield"}, - "brief": "The archive has a shield hull in it. Unlock it at the lab - the fleet has been taking every hit on the paint so far.", - "done": "Shield hull unlocked. There is more than one design in that archive.", + "brief": "The archive holds a shield ship. Unlock it at the lab. So far the fleet has taken every hit on bare hull.", + "done": "The shield ship is unlocked. There is more than one design in that archive.", }, { "id": "outpost", "title": "FORWARD BASES", "objective": "Build an outpost", "goal": {"kind": GOAL_BUILDING, "building": "outpost"}, - "brief": "We cannot be everywhere. Research Forward Bases and build an outpost - vessels stationed there hold ground we are not standing on.", - "done": "The outpost is crewed. That is one place we no longer have to fly back to.", + "brief": "We cannot be everywhere at once. Research Forward Bases and build an outpost. Ships stationed there hold ground we are not standing on.", + "done": "The outpost is up. That is one place we no longer have to fly back to.", }, { "id": "garrison", "title": "STANDING WATCH", "objective": "Station a vessel at the outpost", "goal": {"kind": GOAL_GARRISONED, "count": 1}, - "brief": "An empty outpost is a building. Open its hangar and station a vessel there - it holds that ground whether we are there or not.", - "done": "The outpost has a crew. It watches that approach now, and we do not have to.", + "brief": "An empty outpost is only a building. Open its hangar and station a ship there. It will hold that ground whether we are there or not.", + "done": "The outpost has a crew. It watches that approach now, so we do not have to.", }, { "id": "worlds", "title": "THE SURVEY", "objective": "Land on three different kinds of world", "goal": {"kind": GOAL_PLANET_KINDS, "count": 3}, - "brief": "Rock, gas, and whatever else is out here. Set down on three different kinds of world - if the World Ship passed this way, it passed something.", - "done": "Three kinds of world surveyed. Every one of them empty of anything that came through with us.", + "brief": "Rock, gas, and whatever else is out here. Land on three different kinds of world. If the World Ship came this way, it passed something.", + "done": "Three kinds of world surveyed. Not one of them holds any sign of the convoy.", + "important": true, }, { "id": "trail", "title": "THE TRAIL", "objective": "Chart 150 sectors", "goal": {"kind": GOAL_EXPLORE, "sectors": 150}, - "brief": "Then we widen it. A hundred and fifty sectors, and every one of them looked at properly. They are out there.", - "done": "A hundred and fifty sectors charted, and no trail. The search does not stop because the list ran out.", + "brief": "Then we search wider. A hundred and fifty sectors, and every one of them looked at properly. They are out there somewhere.", + "done": "A hundred and fifty sectors charted, and still no trail. The search does not stop just because the list ran out.", + "important": true, }, ] @@ -241,6 +264,12 @@ static func at(index: int) -> Dictionary: return CHAIN[index] +# #167: whether a quest's lines are the story rather than instructions. Only +# these are said when the goal was already met the moment it was handed out. +static func is_important(quest: Dictionary) -> bool: + return bool(quest.get("important", false)) + + static func by_id(id: String) -> Dictionary: for quest in CHAIN: if String(quest.id) == id: diff --git a/tests/test_aim_converges.gd b/tests/test_aim_converges.gd new file mode 100644 index 0000000..c7ffe4a --- /dev/null +++ b/tests/test_aim_converges.gd @@ -0,0 +1,216 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_aim_converges.gd +# Forgejo #172: "when aiming, whether with mouse or touch controls, we should +# aim for a given point on the space (still using mouse or the joystick). As +# it's now a point and not a direction, it lets the player try to get the +# fleet's shot converge and not go in parallel. The auto aim when trying to +# aim for an enemy vessel should still work." +# +# The failure this replaces is worth stating, because it is invisible in a +# fleet of one and gets worse with every hull added: every gun was handed the +# SAME direction, so a fleet firing at something threw a sheet of parallel +# bolts and only the ship that happened to be lined up could hit. The wider +# the formation, the further the fire missed by. +# +# Covers: +# A. two hulls either side of a target aim at IT, not along one shared +# heading - and each one's line actually arrives at the point +# B. the mouse hands over a place: the cursor's world position, distance +# included, rather than a bearing from the fleet's centre +# C. a pushed stick still works, resolving to the point at the guns' reach +# along it - the touch player loses nothing +# D. the assist (#89) still bends a shot onto an enemy, now per hull and +# around each hull's own heading to the point +# E. releasing stops every gun in the fleet +# F. range belongs to the gun, not to the aim: a point past reach is fired +# toward rather than refused or clamped into a different heading + +const WEAPON = preload("res://vessels/VesselWeaponComponent.gd") + +var game_state: Node +var main: Node +var fleet: Node +var controller: Node +var mouse: Node +var stage: int = 0 +var elapsed: float = 0.0 +var left_ship: Node2D +var right_ship: Node2D + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.intro_active = false + game_state.reset_quests() + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + controller = main.get_node("FireStickController") + mouse = main.get_node("MouseCombatController") + + +func _fail(message: String) -> bool: + print("TEST_AIM_CONVERGES: FAIL (%s)" % message) + quit(1) + return true + + +func _clear() -> void: + for node in get_nodes_in_group("enemies") + get_nodes_in_group("asteroids") \ + + get_nodes_in_group("player_projectiles"): + if is_instance_valid(node): + node.free() + + +func _weapon(ship: Node2D) -> Node: + return ship.get_component("weapon") + + +# Where a shot from this hull would arrive if it flew far enough: the hull, +# plus its own heading, times the distance to the aimed point. The whole of +# "converging" is that this comes out the same for every hull. +func _lands_at(ship: Node2D, point: Vector2) -> Vector2: + var weapon: Node = _weapon(ship) + return ship.global_position \ + + weapon.firing_heading() * ship.global_position.distance_to(point) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + _clear() + fleet.spawn_ships(2, Vector2.ZERO, PackedStringArray( + [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR])) + if fleet.ships.size() != 2: + return _fail("wanted two hulls, got %d" % fleet.ships.size()) + # Placed by hand, wide apart and level, so the geometry the test + # talks about is the geometry it set up rather than whatever the + # current default formation happens to be. + left_ship = fleet.ships[0] + right_ship = fleet.ships[1] + left_ship.global_position = Vector2(-400.0, 0.0) + right_ship.global_position = Vector2(400.0, 0.0) + if _weapon(left_ship) == null or _weapon(right_ship) == null: + return _fail("an interceptor has no weapon component") + stage = 1 + elapsed = 0.0 + return false + 1: + if elapsed < 0.1: + return false + + # --- A. two hulls, one point -------------------------------- + var point := Vector2(0.0, -900.0) + controller.aim_at(point) + var left: Vector2 = _weapon(left_ship).firing_heading() + var right: Vector2 = _weapon(right_ship).firing_heading() + if left == Vector2.ZERO or right == Vector2.ZERO: + return _fail("aiming at a point left a gun with no heading") + if left.distance_to(right) < 0.1: + return _fail("both hulls fire along %s - that is a direction, not a point" % left) + for ship in [left_ship, right_ship]: + var lands: Vector2 = _lands_at(ship, point) + if lands.distance_to(point) > 1.0: + return _fail("a hull at %s fires at %s, %.0f from the point it was given" \ + % [ship.global_position, lands, lands.distance_to(point)]) + print("hulls %.0f apart fire on headings %.0f degrees apart, and both arrive at the point" \ + % [left_ship.global_position.distance_to(right_ship.global_position), + rad_to_deg(left.angle_to(right))]) + + # --- B. the mouse hands over a place ------------------------ + mouse.pointer_mode = mouse.PointerMode.ON + mouse.select_weapon(0) + # Deliberately NOT on the line from the fleet's centre through + # anything: a bearing-from-the-centre implementation would still + # pass a point straight ahead. + var cursor := Vector2(650.0, -300.0) + mouse.aim_world = cursor + mouse.firing = true + mouse._process(0.0) + controller._process(0.0) + for ship in [left_ship, right_ship]: + if _weapon(ship).fire_target.distance_to(cursor) > 0.5: + return _fail("the mouse aimed a hull at %s, the cursor was at %s" \ + % [_weapon(ship).fire_target, cursor]) + if _lands_at(left_ship, cursor).distance_to(_lands_at(right_ship, cursor)) > 1.0: + return _fail("the two hulls' fire does not meet at the cursor") + print("holding the button aims every hull at the cursor's own position") + + # --- E. and releasing stops all of them --------------------- + mouse.firing = false + mouse._process(0.0) + controller._process(0.0) + for ship in [left_ship, right_ship]: + if _weapon(ship).is_firing(): + return _fail("releasing the button left a gun firing") + print("releasing stops every gun") + + # --- C. a stick still resolves to a point ------------------- + var reach: float = controller.guide_range() + if reach <= 0.0: + return _fail("the fleet reports no gun range") + var pushed := Vector2(0.0, -1.0) + var resolved: Vector2 = controller.stick_point(pushed) + var wanted: Vector2 = fleet.get_centroid() + pushed * reach + if resolved.distance_to(wanted) > 1.0: + return _fail("a stick pushed up resolved to %s, expected %s" % [resolved, wanted]) + controller.aim(pushed) + for ship in [left_ship, right_ship]: + if not _weapon(ship).is_firing(): + return _fail("a pushed stick did not fire a gun") + if _weapon(ship).fire_target.distance_to(wanted) > 1.0: + return _fail("a pushed stick aimed a hull somewhere other than the point it names") + print("a pushed stick names the point at the guns' reach (%.0f) along it" % reach) + + # --- F. reach is the gun's, not the aim's ------------------- + var far_point: Vector2 = fleet.get_centroid() + Vector2(0.0, -reach * 3.0) + controller.aim_at(far_point) + var to_far: Vector2 = (far_point - left_ship.global_position).normalized() + if _weapon(left_ship).firing_heading().distance_to(to_far) > 0.01: + return _fail("a point past the guns' reach bent the heading to %s, expected %s" \ + % [_weapon(left_ship).firing_heading(), to_far]) + print("a point beyond reach is still fired toward - the bolt falls short, the aim does not bend") + + # --- D. the assist still works, per hull -------------------- + # Each hull asks its own question now - "is anything within MY + # reach, near MY heading to the point" - so the case worth + # checking is one where the two hulls answer differently. The + # right hull is moved out past its own gun range of the enemy, so + # it cannot be assisted onto it however the angles fall. + _clear() + right_ship.global_position = Vector2(reach * 1.5, 0.0) + var target_point := Vector2(0.0, -300.0) + var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate() + enemy.global_position = left_ship.global_position \ + + (target_point - left_ship.global_position).normalized().rotated( + deg_to_rad(15.0)) * 150.0 + main.add_child(enemy) + enemy.speed = 0.0 + if right_ship.global_position.distance_to(enemy.global_position) <= reach: + return _fail("the test failed to put the far hull out of range of the enemy") + controller.aim_at(target_point) + var to_enemy: Vector2 = (enemy.global_position - left_ship.global_position).normalized() + if _weapon(left_ship).firing_heading().distance_to(to_enemy) > 0.02: + return _fail("the assist no longer bends a shot onto an enemy near the aim (heading %s, enemy %s)" \ + % [_weapon(left_ship).firing_heading(), to_enemy]) + var right_wanted: Vector2 = (target_point - right_ship.global_position).normalized() + if _weapon(right_ship).firing_heading().distance_to(right_wanted) > 0.02: + return _fail("a hull out of range of the enemy was still bent onto it - the assist is not per hull") + print("the assist still snaps onto an enemy near the aim, and only for the hull that can reach it") + enemy.free() + + print("TEST_AIM_CONVERGES: PASS") + quit(0) + return true + return false diff --git a/tests/test_aim_converges.gd.uid b/tests/test_aim_converges.gd.uid new file mode 100644 index 0000000..ac65e4a --- /dev/null +++ b/tests/test_aim_converges.gd.uid @@ -0,0 +1 @@ +uid://crknehe6qim5k diff --git a/tests/test_definitions_intact.gd b/tests/test_definitions_intact.gd index 9adf52f..f8518e2 100644 --- a/tests/test_definitions_intact.gd +++ b/tests/test_definitions_intact.gd @@ -3,128 +3,79 @@ extends SceneTree # Headless test: run with # godot --headless -s res://tests/test_definitions_intact.gd # -# A guard against a failure mode that has now cost this project twice, and -# both times silently. +# A guard against a failure mode that has now cost this project three times, +# and every time silently. # -# Opening the project in the Godot editor re-saves .tres files, and its -# writer DROPS properties it considers default-valued. An empty-looking +# Opening the project in the Godot editor re-saves .tres files, and its writer +# DROPS properties it considers default-valued. An empty-looking # PackedStringArray is the usual casualty: GasBurnerBuildingDefinition lost # `capabilities` and `collected_outputs` once (which took the gas burner's # whole conversion with it), and InterceptorVesselDefinition later lost -# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` - -# which cost the starting vessel its cargo hold, so nothing could be mined. +# `capabilities`, `hull_points`, `build_time`, `icon` and `glow_color` - which +# cost the starting vessel its cargo hold, so nothing could be mined. # # Neither showed up as an error. The resource still loaded; it was just # quietly less than it had been, and the failure surfaced somewhere else # entirely - a full suite reporting ten unrelated tests broken. # -# So this asserts the SHAPE of the catalog rather than any one field: every -# definition must carry the things that make it what it is, and a capability -# a definition claims must have a component behind it. Anything the editor -# eats fails here first, next to the thing that was eaten. +# The checks themselves have moved to core/CatalogCheck.gd, because #173 was +# the same damage a THIRD time and reached a shipped web build: a fleet that +# could not store any resources. A test only guards the tree at the moment +# somebody runs it, and an export is taken from the tree at whatever moment +# the editor last touched it. The same function now runs at startup in every +# build (GameState._ready) and here, so the two can never drift apart and a +# damaged build says so on its own. # # Covers: -# A. every vessel is whole - name, cost, icon, hull, and stats -# B. a vessel with cargo declares it and has a capacity to go with it -# C. every building is whole, and its menu wiring is complete -# D. a converter building keeps the fields its conversion needs -# E. no definition has lost its component list +# A. the catalogue is whole - every vessel and building carries the things +# that make it what it is, and the paired facts agree +# B. the shipped build asks the same question, from the same code, so this +# test is not a promise the game does not keep -var failures: Array = [] - - -func _fail(message: String) -> String: - failures.append(message) - return message - - -# get() answers null for a property a definition's script never declared, -# and null has no is_empty(). These keep a missing field from looking like -# an empty one - and from taking the rest of the sweep down with it. -func _text(definition: Resource, field: String) -> String: - var value: Variant = definition.get(field) - return "" if value == null else str(value) - - -func _list(definition: Resource, field: String) -> Array: - var value: Variant = definition.get(field) - if value == null: - return [] - return Array(value.keys() if value is Dictionary else value) - - -func _check_vessels() -> void: - for definition in VesselDefinitions.all(): - var id: String = String(definition.definition_id) - # --- A. whole --------------------------------------------------- - if String(definition.display_name).strip_edges() == "": - _fail("vessel %s has no display name" % id) - if definition.base_stats.is_empty(): - _fail("vessel %s has no stats at all" % id) - # Only the hulls that actually join the fleet: a repair drone is a - # logistics unit driven by GameState, with nothing bolted to it. - if definition.fleet_eligible and definition.component_scenes.is_empty(): - _fail("vessel %s flies in the fleet with no components - an empty hull" % id) - if definition.hull_points.is_empty(): - _fail("vessel %s has no hull points - it would draw as the fallback triangle" % id) - # --- B. cargo -------------------------------------------------- - var carries: bool = float(definition.base_stats.get("cargo_capacity", 0.0)) > 0.0 - var claims: bool = "cargo" in definition.capabilities - if carries != claims: - _fail("vessel %s: cargo_capacity %s but capabilities %s - a hold nothing can fill, or the reverse" \ - % [id, definition.base_stats.get("cargo_capacity", 0.0), definition.capabilities]) - if "weapon" in definition.capabilities \ - and float(definition.base_stats.get("fire_range", 0.0)) <= 0.0: - _fail("vessel %s claims a weapon and has no range" % id) - - -func _check_buildings() -> void: - for definition in BuildingDefinitions.all(): - var id: String = String(definition.definition_id) - # --- C. whole -------------------------------------------------- - if String(definition.display_name).strip_edges() == "": - _fail("building %s has no display name" % id) - if String(definition.description).strip_edges() == "": - _fail("building %s has no description - its info popup would be blank" % id) - if int(definition.cost) <= 0: - _fail("building %s is free" % id) - if int(definition.max_hp) <= 0: - _fail("building %s has no hit points" % id) - # Space-placed structures only: an extractor is a marker on a planet - # rather than a thing standing in the world, and has no scene. - if definition.placement_mode == "space" and definition.visual_scene == null: - _fail("building %s stands in space with nothing to draw" % id) - for field in ["menu_button_name", "menu_label_name", "menu_cost_name"]: - if str(definition.get(field)).strip_edges() == "": - _fail("building %s has no %s - the build menu could not place it" % [id, field]) - if definition.capabilities.is_empty(): - _fail("building %s declares no capabilities - it would do nothing at all" % id) - # --- D. converters --------------------------------------------- - # These live on the LOGISTICS profile, a sub-resource of the - # definition, not on the definition itself. Asking the definition - # for them answers null and the whole check quietly never fires - - # which is how the first draft of this test passed while the gas - # burner sat there with its collected_outputs missing. - var logistics: Resource = definition.logistics - if logistics != null and _text(logistics, "conversion_input") != "": - if _text(logistics, "conversion_output") == "": - _fail("building %s converts something into nothing" % id) - if _list(logistics, "collected_outputs").is_empty(): - _fail("building %s converts but lists no collected outputs - what it makes would never be fetched" % id) - if _list(logistics, "input_buffers").is_empty(): - _fail("building %s converts with no input buffer" % id) +var stage: int = 0 func _initialize() -> void: - _check_vessels() - _check_buildings() - if not failures.is_empty(): - for message in failures: + # --- A. nothing has been eaten --------------------------------------- + var faults: PackedStringArray = CatalogCheck.faults() + if not faults.is_empty(): + for message in faults: print(" - %s" % message) - print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" % failures.size()) + print("TEST_DEFINITIONS_INTACT: FAIL (%d definition(s) came back incomplete)" \ + % faults.size()) quit(1) return print("%d vessels and %d buildings, all intact" \ % [VesselDefinitions.all().size(), BuildingDefinitions.all().size()]) + + +func _process(_delta: float) -> bool: + if stage != 0: + return false + stage = 1 + + # --- B. and the build itself checked, from the same code ------------- + # The whole point of #173 is that the suite is not where this damage gets + # noticed. GameState runs the check on startup and keeps what it found, so + # a build with an eaten .tres reports it instead of quietly failing to + # mine - on the web, into the on-page error log. + var game_state: Node = root.get_node("GameState") + if not (game_state.catalog_faults is PackedStringArray): + return _fail("GameState does not record what the startup check found") + if not game_state.catalog_faults.is_empty(): + return _fail("the running build reports %d catalog fault(s) this test did not" \ + % game_state.catalog_faults.size()) + var source: String = FileAccess.get_file_as_string("res://core/GameState.gd") + if "CATALOG_CHECK.faults()" not in source or "push_error" not in source: + return _fail("GameState no longer runs the catalog check at startup, so a damaged build would ship silently again") + print("the running build ran the same check on startup and found nothing") + print("TEST_DEFINITIONS_INTACT: PASS") quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_DEFINITIONS_INTACT: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_desktop_combat.gd b/tests/test_desktop_combat.gd index 907edee..ebeb791 100644 --- a/tests/test_desktop_combat.gd +++ b/tests/test_desktop_combat.gd @@ -83,16 +83,23 @@ func _process(delta: float) -> bool: print("weapon 1 is the fleet's guns, weapon 2 is the gas bomb, %d in total" \ % mouse.weapon_count()) - # --- F. inert where a touchscreen exists ----------------------- - # Checked against the real capability rather than forced, so this - # reports what will actually happen on the machine it runs on. - print("touchscreen present: %s -> mouse combat enabled: %s" \ - % [DisplayServer.is_touchscreen_available(), mouse.is_enabled()]) + # --- F. inert while the player is using touch ------------------ + # #171: the gate used to be "does this machine have a + # touchscreen", which is a capability rather than a fact and is + # answered wrongly by every browser on a touch-capable laptop. It + # is now the last input actually used - which, unlike the display + # query, can be driven here, so both branches are exercised + # instead of whichever one this machine happens to report. if mouse.pointer_mode != mouse.PointerMode.AUTO: return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested") - if mouse.is_enabled() == DisplayServer.is_touchscreen_available(): - return _fail("mouse combat is not gated on the absence of a touchscreen") - print("gated correctly on the display's own answer") + game_state.note_touch_input() + if mouse.is_enabled(): + return _fail("mouse combat is armed while the player is using touch - a tap would fire the guns") + game_state.last_touch_msec = -1 + game_state.note_pointer_input() + if not mouse.is_enabled(): + return _fail("mouse combat is off for a player using a mouse") + print("gated on the last input used: off under touch, on under a mouse") # Everything below is about the AIMING, not the guard. Headless # reports a touchscreen, so the mode is forced on here - otherwise diff --git a/tests/test_desktop_hides_touch_controls.gd b/tests/test_desktop_hides_touch_controls.gd index 2204226..0a259d2 100644 --- a/tests/test_desktop_hides_touch_controls.gd +++ b/tests/test_desktop_hides_touch_controls.gd @@ -4,35 +4,51 @@ extends SceneTree # godot --headless -s res://tests/test_desktop_hides_touch_controls.gd # Forgejo #85: on a computer the on-screen sticks and the pivot pair are dead # weight - WASD steers, the mouse aims, Q/E pivot. +# Forgejo #171: "the web export version should auto detect keyboard mouse or +# screen and remove the touch screen controls if not needed." +# +# #85 asked DisplayServer.is_touchscreen_available() and, if it said yes, kept +# the controls for ever. In a browser that query says yes on any machine whose +# panel or pointer reports touch support, so the web build put thumb sticks +# over a screen being played with a mouse - and the decision could not be +# revisited, because it was made once. +# +# The rule is now the last input the player ACTUALLY used, which is a fact +# rather than a capability, and it is two-way. That also removes the reason +# the old rule had to be so cautious: nothing can strand a tablet with no +# controls, because the gesture that needs them is the gesture that brings +# them back. # # Covers: # A. they all start visible, so this is testing a change and not a default -# B. using a keyboard hides all four at once on a machine with no -# touchscreen -# C. a touch device keeps every one of them, even with a keyboard attached - -# a finger is still a way to play it -# -# The touchscreen answer is read from DisplayServer, and headless does not -# answer the same way everywhere: Godot on Android says yes, Godot on Windows -# says no. So B is exercised by driving the handler directly under a stubbed -# answer, and C is skipped - not failed - wherever there is no touchscreen to -# exercise it with. That is the whole behaviour: "no touchscreen + keyboard -# used". +# B. a keyboard takes all four away, together +# C. a touch brings all four back, together - on any device, whatever the +# display claims +# D. a mouse takes them away again, so the detection is not one-shot +# E. the manufactured event is not believed: Godot makes a mouse click out +# of every touch, and one arriving straight after a touch must not read +# as "this player has a mouse" +# F. the two consumers agree - the same reading gates mouse aiming, so the +# sticks and the guns can never disagree about what is being held const CONTROLS: Array = ["UI/Joystick", "UI/FireStick", "UI/PivotCWButton", "UI/PivotCCWButton"] +var game_state: Node var main: Node var controller: Node +var mouse: Node var stage: int = 0 var elapsed: float = 0.0 func _initialize() -> void: - root.get_node("GameState").reset_progression() + game_state = root.get_node("GameState") + game_state.reset_progression() main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main controller = main.get_node("GameController") if main.has_node("GameController") else main + mouse = main.get_node_or_null("MouseCombatController") func _all_visible() -> bool: @@ -49,6 +65,13 @@ func _any_visible() -> bool: return false +# A touch far enough in the past that the grace window has expired. Ageing the +# stamp rather than sleeping for a second: the window is what is under test, +# not the wall clock. +func _forget_touch() -> void: + game_state.last_touch_msec = -1 + + func _process(delta: float) -> bool: elapsed += delta if stage != 0: @@ -62,43 +85,54 @@ func _process(delta: float) -> bool: return _fail("a touch control was already hidden before any input") print("all four touch controls start visible") - # --- C. a touchscreen keeps them ---------------------------------- - # Only where the machine actually reports one: there the real code path - # is the touch-device one and can be driven as-is. Where it reports none - # there is nothing to drive, and the static check at the end of B still - # guards the touchscreen condition itself, so skip rather than fail. - if DisplayServer.is_touchscreen_available(): - controller.keyboard_seen = false - controller._note_keyboard() - if not _all_visible(): - return _fail("a touch device hid its controls when a keyboard was used") - print("touch device keeps all four even after keyboard input") - else: - print("SKIP case C: this environment reports no touchscreen") - - # --- B. no touchscreen: they all go ------------------------------- - # The guard is the FIRST thing _note_keyboard checks, so the branch is - # reproduced here rather than faked: hide exactly what the function - # hides, then assert the function agrees. - for path in CONTROLS: - main.get_node(path).visible = false + # --- B. a keyboard takes them away -------------------------------- + _forget_touch() + controller._note_keyboard() if _any_visible(): - return _fail("could not stage the hidden state") - print("with no touchscreen all four are hidden together") + return _fail("using a keyboard left a touch control on screen") + if game_state.pointer_is_touch: + return _fail("a keyboard did not change the reading to \"not touch\"") + print("using a keyboard hides all four together") - # The four are hidden as one list, so a control added to that list later - # is covered without editing this test. Guard the list itself instead. - var source: FileAccess = FileAccess.open("res://controllers/GameController.gd", FileAccess.READ) - if source == null: - return _fail("could not read GameController.gd") - var text: String = source.get_as_text() - source.close() - for control_name in ["joystick", "fire_stick", "pivot_cw_button", "pivot_ccw_button"]: - if control_name not in text.split("func _note_keyboard()")[1].split("func ")[0]: - return _fail("_note_keyboard no longer hides %s" % control_name) - if "is_touchscreen_available" not in text.split("func _note_keyboard()")[1].split("func ")[0]: - return _fail("_note_keyboard no longer checks for a touchscreen before hiding anything") - print("_note_keyboard hides all four, and only where there is no touchscreen") + # --- C. and a touch brings them back ------------------------------ + # The case the old one-shot rule could not do at all, and the reason the + # new rule is safe to apply on every platform rather than only where the + # display says there is no touchscreen. + controller._note_touch() + if not _all_visible(): + return _fail("touching the screen did not bring the touch controls back") + if not game_state.pointer_is_touch: + return _fail("a touch did not change the reading to \"touch\"") + print("a touch brings all four back") + + # --- E. the manufactured click is not believed -------------------- + # Godot turns every touch into a click (emulate_mouse_from_touch), so the + # click below is what a phone produces one frame after the touch above. + # Believing it would take the controls away from a player holding the + # device in their hands. + controller._note_keyboard() + if not _all_visible(): + return _fail("a click arriving right after a touch took the controls away - that click is the touch") + print("a click in the shadow of a touch is ignored") + + # --- D. a real mouse, later, does take them away ------------------ + _forget_touch() + controller._note_keyboard() + if _any_visible(): + return _fail("a mouse used well after the last touch did not hide the controls") + print("a mouse used later hides them again - the detection is not one-shot") + + # --- F. and the guns read the same answer ------------------------- + if mouse == null: + return _fail("no MouseCombatController in the scene") + if mouse.pointer_mode != mouse.PointerMode.AUTO: + return _fail("the scene ships with an overridden pointer mode, so what a real device does is not what was tested") + if not mouse.is_enabled(): + return _fail("the controls are hidden for a mouse but mouse aiming is off") + controller._note_touch() + if mouse.is_enabled(): + return _fail("a touch put the thumb controls back but left mouse firing armed - a tap would fire the guns") + print("mouse aiming follows the same reading the controls do") print("TEST_DESKTOP_HIDES_TOUCH_CONTROLS: PASS") quit(0) diff --git a/tests/test_dialog_typing.gd b/tests/test_dialog_typing.gd index 25e9b6c..3e242ed 100644 --- a/tests/test_dialog_typing.gd +++ b/tests/test_dialog_typing.gd @@ -21,6 +21,10 @@ extends SceneTree # E. it types while the game is stopped, being the one node still running # F. the sci-fi part: the panel is drawn differently while decoding, and # settles when the line is complete +# G. #168 - a KEY does exactly what a click does, both halves of it, since +# the game is played on a desktop with WASD and a mouse and pressing +# space at a dialog reached nothing at all. Modifiers alone and held-key +# echoes are not presses, or a line would be run out by a held key. var game_state: Node var scene: Node @@ -53,6 +57,14 @@ func _click() -> void: narration._unhandled_input(press) +func _key(keycode: int, echo: bool = false) -> void: + var press: InputEventKey = InputEventKey.new() + press.keycode = keycode + press.pressed = true + press.echo = echo + narration._unhandled_input(press) + + func _process(delta: float) -> bool: elapsed += delta match stage: @@ -139,6 +151,38 @@ func _process(delta: float) -> bool: return _fail("dismissing landed on \"%s\"" % narration.current) print("the press after that moves on to \"%s\"" % narration.current) + # --- G. #168: a key is a press too ----------------------- + narration.clear() + narration.say(first_line, "TEST") + narration.say("Second.", "TEST") + if not narration.is_typing(): + return _fail("the fresh line was not arriving") + # Not everything that reaches the banner is a press. + _key(KEY_SHIFT) + _key(KEY_SPACE, true) + if not narration.is_typing(): + return _fail("a modifier or a held-key echo finished the line") + # Still arriving: the key shows the whole of it, and no more. + _key(KEY_SPACE) + if narration.is_typing(): + return _fail("a key did not finish a line that was still arriving") + if narration.current != first_line: + return _fail("a key on an arriving line dismissed it - the next line would be skipped unread") + # Whole: the key moves on, the same as a click. + _key(KEY_SPACE) + if narration.current != "Second.": + return _fail("a key on a finished line landed on \"%s\"" % narration.current) + # Any key, not one chosen key - the player has no reason to guess. + _key(KEY_W) + if narration.is_typing(): + return _fail("only some keys finish a line") + _key(KEY_ENTER) + if narration.is_speaking(): + return _fail("a key on the last line did not close the dialog") + if paused: + return _fail("keying through every line left the game stopped") + print("space, W and enter all finish then advance a line; shift and echoes do not") + print("TEST_DIALOG_TYPING: PASS") quit(0) return true diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index c2ccebd..20a7b12 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -33,6 +33,8 @@ extends SceneTree # acknowledging it is what starts the world # Q. #133 - the handover runs straight into the first quest # R. #139 - no belt rock is laid down inside the visible screen +# S. #170 - the prologue opens on the PICTURE: the convoy is flying and the +# game is running for a few seconds before the guide says anything var game_state: Node var scene: Node @@ -97,10 +99,43 @@ func _process(delta: float) -> bool: if not director.running: return _fail("the director did not start on a new game") + # --- S. #170: it opens on the picture, not the caption --- + # The scene is a third of a second old here, well inside the + # silence, so the convoy should be flying and nothing should be + # on the banner. + if narration.is_speaking(): + return _fail("the guide was already talking %.2fs in - #170 asks for a few seconds first" \ + % director.beat_time) + if paused: + return _fail("the prologue opened stopped, with nothing on the banner to stop it") + # Hand-ticked from here so the silence is MEASURED rather than + # waited out, and so the tree's own clock cannot race it. + director.set_process(false) + var ticks: int = 0 + while not narration.is_speaking() and ticks < 1200: + director._process(1.0 / 60.0) + ticks += 1 + if narration.is_speaking(): + break + if director.beat_time > director.OPENING_SILENCE + 0.05: + return _fail("the clock passed the %.1fs silence at %.2fs and the guide still had not spoken" \ + % [director.OPENING_SILENCE, director.beat_time]) + if not narration.is_speaking(): + return _fail("the guide never spoke at all") + if director.beat_time < director.OPENING_SILENCE: + return _fail("the guide spoke at %.2fs, inside the %.1fs silence" \ + % [director.beat_time, director.OPENING_SILENCE]) + print("the prologue flies for %.1fs before the guide calls in" % director.beat_time) + director.set_process(true) + # --- N. a dialog waits for the reader -------------------- var first_line: String = narration.label.text if first_line.strip_edges() == "": - return _fail("the prologue opened with an empty banner") + return _fail("the guide spoke with an empty banner") + # The beat clock as the line went up. Nothing may move it on while + # the line is unread (#128) - the tree is stopped, and this test + # stops hand-ticking the director for exactly that reason. + var clock_at_line: float = director.beat_time # Ten seconds of clock, with nothing pressed. A timer-driven # banner would have moved on several times over. for i in range(600): @@ -112,10 +147,10 @@ func _process(delta: float) -> bool: # --- P. and the game is STOPPED behind it (#128) --------- if not paused: return _fail("the prologue opened on a line with the game still running") - if director.beat_time > 0.01: + if absf(director.beat_time - clock_at_line) > 0.01: return _fail("the director's clock ran on %.2fs behind an unread line" \ - % director.beat_time) - print("the prologue opens stopped: beat clock at %.2fs" % director.beat_time) + % absf(director.beat_time - clock_at_line)) + print("the line stopped the game: beat clock held at %.2fs" % director.beat_time) var click: InputEventMouseButton = InputEventMouseButton.new() click.button_index = MOUSE_BUTTON_LEFT diff --git a/tests/test_keyboard_input.gd b/tests/test_keyboard_input.gd index d6e97b7..271cd78 100644 --- a/tests/test_keyboard_input.gd +++ b/tests/test_keyboard_input.gd @@ -75,15 +75,21 @@ func _process(delta: float) -> bool: _key(k, false) print("all %d key combinations produced the right heading" % cases.size()) - # The stick must survive on a touch device. - var stick_kept: bool = joystick.visible - print("stick still visible after keyboard use: %s (touchscreen=%s)" % [ - stick_kept, DisplayServer.is_touchscreen_available() - ]) - if DisplayServer.is_touchscreen_available() and not stick_kept: - print("TEST_KEYBOARD_INPUT: FAIL (took the stick away on a touch device)") + # #171: the stick follows the last input actually used, rather + # than the display's claim about what could arrive. Using the + # keyboard takes it away - on any device - and a touch brings it + # straight back, which is what keeps that safe. + if joystick.visible: + print("TEST_KEYBOARD_INPUT: FAIL (the stick survived keyboard use)") quit(1) return true + controller._note_touch() + if not joystick.visible: + print("TEST_KEYBOARD_INPUT: FAIL (a touch did not bring the stick back)") + quit(1) + return true + print("keyboard use hides the stick; a touch brings it back") + root.get_node("GameState").last_touch_msec = -1 # --- holding a key flies the fleet ---------------------------- start_centroid = fleet.get_centroid() diff --git a/tests/test_main_menu.gd b/tests/test_main_menu.gd index bf9a6e5..d12b011 100644 --- a/tests/test_main_menu.gd +++ b/tests/test_main_menu.gd @@ -8,6 +8,13 @@ extends SceneTree # 2. there is no Quit entry in any build - a web build has nothing to quit # to, and Android's back gesture already does it # 3. Play resets GameState to a fresh game AND reaches Main.tscn +# 4. #169 - a press puts a loading screen up FIRST and swaps the scene +# afterwards. A button that does nothing for several seconds reads as a +# button that was not pressed, which is how the web build reads; the +# order is the whole fix, so it is the thing asserted. +# 5. #169 - a second press while a load is running is ignored, or an +# impatient double tap builds the world twice (on the web a button fires +# on PRESS, so that tap costs nothing to make) var menu: Node var game_state: Node @@ -46,17 +53,37 @@ func _process(delta: float) -> bool: return _fail("the menu still carries a Quit entry") _press(menu.editor_button) + + # --- 4. the cover goes up before anything blocks -------------- + if menu.loading_path != "res://surface/PlanetSetupEditor.tscn": + return _fail("the press did not start a load (loading_path %s)" % menu.loading_path) + if menu.loading_screen == null or not menu.loading_screen.is_inside_tree(): + return _fail("the press started a load with no loading screen up") + if current_scene != menu: + return _fail("the scene swapped inside the press, so nothing was ever shown") + print("pressing an entry raises the loading screen and leaves the menu up") + + # --- 5. and a second press changes nothing -------------------- + var screen: Node = menu.loading_screen + _press(menu.editor_button) + _press(menu.play_button) + if menu.loading_screen != screen \ + or menu.loading_path != "res://surface/PlanetSetupEditor.tscn": + return _fail("a second press while loading started another scene") + print("further presses while a load is running are ignored") + elapsed = 0.0 stage = 1 return false 1: - # _start awaits a frame so its status text paints before the - # blocking load, so the change takes a couple of frames. - if elapsed < 1.0: - return false + # Waited on rather than timed: the load is spread over frames now, + # and how many it takes is a property of the machine. var on_editor: bool = current_scene != null and current_scene.name == "PlanetSetupEditor" if not on_editor: - return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn") + if elapsed < 30.0: + return false + return _fail("pressing Map Editor did not reach PlanetSetupEditor.tscn in 30s") + print("the load finished and the scene swapped") # Return to the menu the same way the player would, then test Play. menu = load("res://ui/MainMenu.tscn").instantiate() @@ -80,9 +107,9 @@ func _process(delta: float) -> bool: stage = 3 return false 3: - if elapsed < 1.0: - return false var on_main: bool = current_scene != null and current_scene.name == "Main" + if not on_main and elapsed < 30.0: + return false # A normal game now starts with NOTHING banked - the opening # mining run is the tutorial, and 500 free ore skipped it. var reset_ok: bool = game_state.rocks == 0 and game_state.buildings.is_empty() \ diff --git a/tests/test_quest_chain.gd b/tests/test_quest_chain.gd index f14cd62..e764373 100644 --- a/tests/test_quest_chain.gd +++ b/tests/test_quest_chain.gd @@ -39,6 +39,9 @@ extends SceneTree # is announced down there, not on the way back up, and the objective is # reachable from a button there as well # K. landing is recorded, both the planet and the kind of world it was +# L. #167 - a TEACHING quest handed out with its goal already met is +# credited without a word, while a STORY quest in the same position is +# still read out const QUESTS = preload("res://core/Quests.gd") @@ -316,6 +319,61 @@ func _catalogue_cases() -> bool: return _fail("there is still an active quest after the chain ended") game_state._advance_quests() print("the chain walks to the end and stops there") + + # --- L. #167: nothing is announced twice about a job already done ---- + # The chain is handed out in a fixed order, so by the time some of its + # teaching quests come up the player has long since done the thing - + # banked the ore, put the extractor down. Announcing one of those stops + # the game to set a task, then stops it again to congratulate them on a + # task they never did. + # + # The story quests are the exception, and the flag has to actually + # separate the two: a chain where everything is important skips nothing, + # and one where nothing is skips the plot. + var important: int = 0 + for i in range(QUESTS.count()): + if QUESTS.is_important(QUESTS.at(i)): + important += 1 + if important == 0: + return _fail("no quest carries the story, so every already-met quest would be silent") + if important == QUESTS.count(): + return _fail("every quest is important, so nothing is ever skipped") + for story in ["mothership", "planetfall"]: + if not QUESTS.is_important(QUESTS.by_id(story)): + return _fail("the %s quest is not marked important - #167 names it as story" % story) + print("%d of %d quests carry the story, the rest are teaching" \ + % [important, QUESTS.count()]) + + game_state.reset_new_game() + game_state.intro_active = false + # Enough ore that the first two quests are both already satisfied the + # moment they are handed out. One is story, one is teaching. + game_state.bank_inventory.deposit(GameState.RESOURCE_ORE, 500) + if not QUESTS.is_important(QUESTS.at(0)) or QUESTS.is_important(QUESTS.at(1)): + return _fail("this case needs a story quest followed by a teaching one, and the chain opens %s then %s" \ + % [QUESTS.at(0).id, QUESTS.at(1).id]) + game_state._advance_quests() + if game_state.quest_index != 0 or game_state.quest_messages.is_empty(): + return _fail("a story quest whose goal was already met was skipped - the plot went with it") + game_state.take_quest_messages() + game_state._advance_quests() + if game_state.quest_index != 1: + return _fail("the story quest did not close on the frame after it was announced") + game_state.take_quest_messages() + + var skipped: String = String(QUESTS.at(1).id) + game_state._advance_quests() + if game_state.quest_index != 2: + return _fail("the already-met %s quest was announced instead of being banked" % skipped) + if not game_state.quest_messages.is_empty(): + return _fail("the skipped %s quest still said \"%s\"" \ + % [skipped, game_state.quest_messages[0].line]) + # ...and it was CREDITED, not dropped. quest_index is what the objectives + # panel counts its DONE list off, so the player still sees it ticked. + if String(game_state.quest_at(game_state.quest_index - 1).id) != skipped: + return _fail("the skipped quest is not the last one in the done list") + print("\"%s\" was already done when it was handed out: credited, and not a word said" \ + % QUESTS.by_id(skipped).title) return false diff --git a/ui/LoadingScreen.gd b/ui/LoadingScreen.gd new file mode 100644 index 0000000..81890ec --- /dev/null +++ b/ui/LoadingScreen.gd @@ -0,0 +1,126 @@ +class_name LoadingScreen +extends Control + +# Forgejo #169: "create a loading screen after clicking on the main menu +# buttons." +# +# Pressing Play used to do nothing visible for as long as the scene took to +# build - several seconds in a browser - and a button that does nothing reads +# as a button that was not pressed, so the player presses it again. The whole +# job of this is to answer the press immediately: a cover over the menu, the +# name of what is being opened, and a bar that moves. +# +# Two things make the answer immediate rather than merely eventual: +# +# 1. It goes up BEFORE the load starts, and the load waits for it to have +# been on screen for a frame or two (see `frames_shown` and +# MainMenu._process). A screen raised in the same frame as a blocking +# load is a screen nobody ever sees. +# 2. The bar reports nothing it does not know. The load is one blocking call +# (see MainMenu._process for why it is not a threaded one), so there is +# no progress to report and the bar does not pretend there is: it is a +# sweep, which says "working" without claiming to say how far. +# +# A Control built in code rather than a scene: it is one panel of geometry +# with nothing to author, and building it here keeps MainMenu.tscn - which the +# player's own editor touches - out of the change. + +const BACKDROP: Color = Color(0.01, 0.02, 0.06, 0.94) +const GLOW: Color = Color(0.42, 0.86, 0.95) +const TEXT: Color = Color(0.82, 0.94, 1.0) +const MUTED: Color = Color(0.52, 0.66, 0.76) +const TITLE_SIZE: int = 44 +const SUBTITLE_SIZE: int = 24 +const BAR_WIDTH: float = 520.0 +const BAR_HEIGHT: float = 16.0 +const BAR_GAP: float = 46.0 +# The travelling band inside the bar, and how much of the bar it covers. +const SWEEP_SECONDS: float = 1.2 +const SWEEP_WIDTH: float = 0.22 + +var title_label: Label +var subtitle_label: Label +# How many frames this has been up and running. MainMenu waits for this before +# it starts the blocking part - see the note above. Counted in _process rather +# than in _draw so the count still advances under the headless renderer, where +# nothing paints and a test would otherwise wait for ever. +var frames_shown: int = 0 + +var _elapsed: float = 0.0 + + +func _ready() -> void: + # Covers whatever is behind it and swallows nothing: the menu's buttons + # check for a modal themselves (see IconButton._blocked_by_modal), and + # MainMenu refuses a second press while a load is running anyway. + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_preset(Control.PRESET_FULL_RECT) + title_label = _make_label("LOADING", TITLE_SIZE, TEXT) + subtitle_label = _make_label("", SUBTITLE_SIZE, MUTED) + resized.connect(_layout) + _layout() + + +func _make_label(text_value: String, font_size: int, colour: Color) -> Label: + var label := Label.new() + label.text = text_value + label.mouse_filter = Control.MOUSE_FILTER_IGNORE + label.add_theme_font_size_override("font_size", font_size) + label.add_theme_color_override("font_color", colour) + label.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.06, 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) + return label + + +# What is being opened, in the player's words rather than a file name. +func begin(what: String) -> void: + if subtitle_label != null: + subtitle_label.text = what + + +func _bar_rect() -> Rect2: + var width: float = minf(BAR_WIDTH, maxf(size.x - 120.0, 40.0)) + return Rect2(Vector2((size.x - width) * 0.5, size.y * 0.5 + BAR_GAP), + Vector2(width, BAR_HEIGHT)) + + +func _layout() -> void: + if title_label == null: + return + title_label.position = Vector2(0.0, size.y * 0.5 - float(TITLE_SIZE) * 1.4) + title_label.size = Vector2(size.x, float(TITLE_SIZE) * 1.4) + subtitle_label.position = Vector2(0.0, size.y * 0.5) + subtitle_label.size = Vector2(size.x, float(SUBTITLE_SIZE) * 1.6) + queue_redraw() + + +func _process(delta: float) -> void: + _elapsed += delta + frames_shown += 1 + queue_redraw() + + +func _draw() -> void: + draw_rect(Rect2(Vector2.ZERO, size), BACKDROP) + + var bar: Rect2 = _bar_rect() + draw_rect(bar, Color(GLOW, 0.14)) + # A band travelling the length of the bar. It stops moving while the load + # blocks, which is honest - the whole engine has stopped - and it is the + # thing that says "working" on the frames either side of that. + var head: float = fposmod(_elapsed, SWEEP_SECONDS) / SWEEP_SECONDS + var band: float = bar.size.x * SWEEP_WIDTH + var x: float = bar.position.x + (bar.size.x + band) * head - band + var left: float = maxf(x, bar.position.x) + var right: float = minf(x + band, bar.end.x) + if right > left: + draw_rect(Rect2(Vector2(left, bar.position.y), + Vector2(right - left, bar.size.y)), Color(GLOW, 0.8)) + + NeonDraw.draw_glowing_path(self, PackedVector2Array([ + bar.position, Vector2(bar.end.x, bar.position.y), bar.end, + Vector2(bar.position.x, bar.end.y), bar.position, + ]), Color(GLOW, 0.8), 0.7, 0.5) diff --git a/ui/LoadingScreen.gd.uid b/ui/LoadingScreen.gd.uid new file mode 100644 index 0000000..47fce49 --- /dev/null +++ b/ui/LoadingScreen.gd.uid @@ -0,0 +1 @@ +uid://dneh3kfa08bv8 diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index 3f6ed71..00301c5 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -32,6 +32,25 @@ const STATUS_HEIGHT: float = 70.0 @onready var dev_label: Label = $UI/DevModeLabel @onready var status_label: Label = $UI/StatusLabel +# #169: what is being opened, and the cover that says so. Empty path means +# nothing is loading, which is also what stops a second press starting a +# second scene - on the web a button fires on PRESS (see IconButton), and an +# impatient double tap on Play would otherwise build the world twice. +const LOADING_SCREEN = preload("res://ui/LoadingScreen.gd") +# How the player is told what they are waiting for. Keyed by scene path so a +# new entry cannot go in without a name to put on the screen. +const SCENE_NAMES: Dictionary = { + "res://world/Main.tscn": "Entering the convoy lane", + "res://surface/PlanetSetupEditor.tscn": "Opening the map editor", +} +# The load only starts once the cover has been up for a frame or two. One +# would do; two is cheap and removes any argument about whether the first one +# reached the screen. +const COVER_FRAMES: int = 2 + +var loading_path: String = "" +var loading_screen: Control = null + func _ready() -> void: # #148: the theme starts here, at the first screen, and keeps playing @@ -93,6 +112,11 @@ func _layout_stack() -> void: func _on_play_pressed() -> void: + # Checked here as well as in _start: this one has a side effect, and a + # second press while the first is still loading must not wipe the game + # that first press already set up (#169). + if loading_path != "": + return GameState.reset_new_game() _start("res://world/Main.tscn") @@ -102,21 +126,71 @@ func _on_play_pressed() -> void: # there was no way to tell which part failed. Split out, each step can say so # - and a scene change that fails otherwise leaves the menu up, which looks # exactly like an unpressed button. +# +# #169: and now it does not look like anything else either. The press raises a +# loading screen straight away and the load itself happens over the following +# frames, driven from _process below, so the wait is something the player is +# watching rather than a menu that has stopped responding. func _start(scene_path: String) -> void: + if loading_path != "": + return + loading_path = scene_path + status_label.text = "" + loading_screen = LOADING_SCREEN.new() + $UI.add_child(loading_screen) + loading_screen.begin(String(SCENE_NAMES.get(scene_path, scene_path.get_file()))) + + +# The load itself is still the ordinary blocking one, deliberately. +# +# ResourceLoader.load_threaded_request() was the obvious alternative and it +# does not work here: asked for these scenes it comes back FAILED, with the +# text-scene parser reporting errors ("Parse Error ... Rock.tscn:9", "Could +# not resolve external class member PROFILES") that the same files do not +# produce through load(). The project's scripts and scenes reach each other +# through global class names and preloads, and that resolution is not +# available off the loading thread. A progress bar is not worth a menu that +# sometimes refuses to start the game - and the web build could not have used +# the threaded path anyway, since it ships with thread support off. +# +# So what the cover buys is honest and worth having on its own: the press is +# answered instantly, the player is told what is opening, and the frame that +# blocks happens with something on the screen rather than behind a menu that +# has stopped responding. +func _process(_delta: float) -> void: + if loading_path == "": + return + # Nothing blocking until the cover is actually up - see LoadingScreen's + # own note. This is the whole reason the load waits for a frame at all. + if loading_screen == null or loading_screen.frames_shown < COVER_FRAMES: + return + var scene_path: String = loading_path + loading_path = "" var packed: PackedScene = load(scene_path) if packed == null: - status_label.text = "Could not load %s" % scene_path.get_file() + _abandon_loading("Could not load %s" % scene_path.get_file()) return var scene: Node = packed.instantiate() if scene == null: - status_label.text = "Could not build %s" % scene_path.get_file() + _abandon_loading("Could not build %s" % scene_path.get_file()) return get_tree().root.add_child(scene) get_tree().current_scene = scene queue_free() +# A failed load must leave a working menu behind, not a cover over a screen +# with nothing coming. +func _abandon_loading(message: String) -> void: + if loading_screen != null: + loading_screen.queue_free() + loading_screen = null + status_label.text = message + + func _on_dev_mode_pressed() -> void: + if loading_path != "": + return # Guarded as well as hidden. Visibility is presentation; this is the rule. if not is_developer_build(): status_label.text = "Dev mode only runs from Godot" diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index f9aa22f..24dfc19 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -35,6 +35,10 @@ extends Control # is not a flourish: without it, a player who clicks quickly through two # lines never sees the second one at all, and the whole point of #127 was # that lines wait for the reader. +# +# #168 says the same rule out loud - still arriving, show it all; already +# whole, move on - and extends it to the KEYBOARD, which had no way in at +# all. See _is_advance_press. signal dismissed @@ -263,13 +267,35 @@ func _process(delta: float) -> void: queue_redraw() +# #168: what counts as "go on". A tap, a left click, or a key. +# +# The rule the ticket asks for was already the rule for a click (#149) - a +# press finishes a line still arriving, a press on a finished line moves to +# the next - but the game is played on a desktop with WASD and a mouse, and +# a keyboard press reached nothing at all. Sitting behind a dialog pressing +# space is the most obvious thing a player can do, so the keyboard now goes +# through the same one door every other press does (dismiss()). +# +# Modifiers alone are not a press: holding shift before typing must not eat +# a line. Neither is an auto-repeat echo - a held key would otherwise run +# the whole queue out in a second. +func _is_advance_press(event: InputEvent) -> bool: + if event is InputEventScreenTouch: + return event.pressed + if event is InputEventMouseButton: + return event.pressed and event.button_index == MOUSE_BUTTON_LEFT + if event is InputEventKey: + if not event.pressed or event.echo: + return false + return not (event.keycode in [KEY_SHIFT, KEY_CTRL, KEY_ALT, KEY_META, + KEY_CAPSLOCK, KEY_NUMLOCK, KEY_SCROLLLOCK]) + return false + + func _unhandled_input(event: InputEvent) -> void: if current == "": return - var pressed: bool = (event is InputEventScreenTouch and event.pressed) \ - or (event is InputEventMouseButton and event.pressed \ - and event.button_index == MOUSE_BUTTON_LEFT) - if not pressed: + if not _is_advance_press(event): return # A dismissed line eats the press: it must not also be steering the # fleet or firing something on the way through. diff --git a/ui/QuestPanel.gd b/ui/QuestPanel.gd index 95dc285..11d48c1 100644 --- a/ui/QuestPanel.gd +++ b/ui/QuestPanel.gd @@ -134,7 +134,7 @@ func _refresh() -> void: if quest.is_empty(): heading_label.text = "NOTHING OUTSTANDING" quest_label.text = "The list has run out." - objective_label.text = "The search does not stop because there is nothing left to tick off." + objective_label.text = "The search goes on, even with nothing left to tick off." count_label.text = "" progress = Vector2i(1, 1) else: diff --git a/vessels/VesselWeaponComponent.gd b/vessels/VesselWeaponComponent.gd index 2e2c89b..e621e01 100644 --- a/vessels/VesselWeaponComponent.gd +++ b/vessels/VesselWeaponComponent.gd @@ -10,16 +10,31 @@ extends Node # to aim, and nothing to decide. Firing is now an action like any other, and # the right-hand stick is what performs it. # -# `fire_direction` is set every frame by FireStickController from the stick's -# own reading, and cleared the moment it is released. Zero means "not firing", -# which is the resting state. +# #172: it is aimed at a POINT, not along a heading. +# +# Every gun in the fleet used to be handed the SAME direction, so a fleet +# firing at something threw a sheet of parallel bolts past either side of it +# and only the middle hull could ever hit. Handed a point instead, each hull +# works out its own heading to that point, and the fleet's fire converges +# where the player is actually pointing - which is what having several ships +# should be worth. +# +# The point is set every frame by FireStickController, from the mouse's own +# world position or from where the fire stick is pushed, and cleared the +# moment the trigger is let go. No point means not firing, which is the +# resting state. var capability_id: String = "weapon" var fire_range: float var fire_cooldown: float var projectile_damage: int var fire_timer: float = 0.0 -var fire_direction: Vector2 = Vector2.ZERO +# Where this gun is pointed, in WORLD space, and whether it is pointed at all. +# A point rather than a Vector2.ZERO sentinel: the origin is a perfectly +# ordinary place to aim at, and a fleet that happened to be near it would +# stop firing. +var fire_target: Vector2 = Vector2.ZERO +var has_fire_target: bool = false var projectile_scene: PackedScene = preload("res://vessels/Projectile.tscn") # By path, not by class name - see the note at the top of MuzzleSparkVFX. const MUZZLE_SPARK = preload("res://vfx/MuzzleSparkVFX.gd") @@ -77,13 +92,48 @@ static func assisted_heading(tree: SceneTree, origin: Vector2, direction: Vector return heading if nearest == Vector2.ZERO else nearest.normalized() -# Called by FireStickController. A zero vector stops the gun. +# Where this gun is firing FROM. The hull it is bolted to. +func muzzle() -> Vector2: + var ship: Node2D = get_parent() as Node2D + return ship.global_position if ship != null else Vector2.ZERO + + +# Called by FireStickController every frame the trigger is held. +func aim_at(point: Vector2) -> void: + fire_target = point + has_fire_target = true + + +func hold_fire() -> void: + has_fire_target = false + + +# For anything that knows a DISPLACEMENT rather than a place: an outpost +# garrison has already measured the vector to the enemy it picked (see +# VesselGarrisonComponent._shoot), and a lone stationed vessel has no fleet +# to converge with anyway. Zero still means stop. func set_fire_direction(direction: Vector2) -> void: - fire_direction = direction if direction.length() > 0.001 else Vector2.ZERO + if direction.length() <= 0.001: + hold_fire() + return + aim_at(muzzle() + direction) func is_firing() -> bool: - return fire_direction != Vector2.ZERO + return has_fire_target + + +# The heading a shot from THIS hull will actually take: from the muzzle to the +# aimed point, bent onto an enemy sitting inside the assist cone. Zero when the +# gun is not firing, or when the point is on top of the ship - there is no +# heading to a place you are already at. +func firing_heading() -> Vector2: + if not has_fire_target: + return Vector2.ZERO + var to_point: Vector2 = fire_target - muzzle() + if to_point.length() <= 0.001: + return Vector2.ZERO + return assisted_heading(get_tree(), muzzle(), to_point, fire_range) func tick(delta: float) -> void: @@ -91,7 +141,12 @@ func tick(delta: float) -> void: # the stick in a new direction is not punished by a timer that only # started when the stick moved. fire_timer -= delta - if fire_direction == Vector2.ZERO or fire_timer > 0.0: + if not has_fire_target or fire_timer > 0.0: + return + # #172: worked out per HULL, from this ship to the aimed point, which is + # what makes the fleet's fire converge rather than run parallel. + var heading: Vector2 = firing_heading() + if heading == Vector2.ZERO: return var ship: Node2D = get_parent() var projectile: Node2D = projectile_scene.instantiate() @@ -101,10 +156,10 @@ func tick(delta: float) -> void: # same range - one number, one meaning. projectile.set_range(fire_range) ship.get_tree().root.add_child(projectile) - # Aimed by DIRECTION, not at a target: launch() takes a displacement, so - # the gun's own range decides how far the shot is sent. - var heading: Vector2 = assisted_heading(ship.get_tree(), ship.global_position, - fire_direction, fire_range) + # The bolt is sent the gun's own range along that heading, whether the + # aimed point is nearer or further: range is a property of the gun, not of + # where the player pointed. A point beyond reach is fired toward and falls + # short; a point inside it is flown through. projectile.launch(heading * fire_range) # #87: sparks off the hull that fired, so a shot is visibly attached to # the vessel it came from. diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index 4f47fd8..773a574 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -26,6 +26,15 @@ signal finished # Beats, in order. The names are the whole outline. enum Beat { BRIEF, RUN, AMBUSH, CLEAR, GAUNTLET, BREACH, VORTEX, AFTERMATH } +# #170: how long the prologue runs before anybody says anything. +# +# The first line used to be up before the scene had finished arriving: the +# game opened on a dialog, which since #128 means it opened STOPPED, over a +# convoy the player had not yet seen and a starfield still being drawn. The +# opening beat of a story should be the picture, not the caption. So the +# convoy flies for a moment first, and the guide calls in over a scene that is +# already moving. +const OPENING_SILENCE: float = 3.0 # 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 @@ -77,6 +86,10 @@ const BREACH_LENS_SCALE: float = 7.0 # up to the breach; after it there is nobody left to talk to, so the closing # lines are the ship's own log - which is the point being made. const SHIP_LOG: String = "KESTREL - LOG" +# By path rather than by class name, for the load-order reason documented in +# PlanetSetups and elsewhere. Only for DEFAULT_SPEAKER, so a queued line names +# the same speaker an immediate one would. +const NARRATION = preload("res://ui/NarrationBanner.gd") 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), @@ -101,6 +114,11 @@ var vortex_time: float = 0.0 var survivor: Node2D = null var breach: Node2D = null var _done: bool = false +# #170: lines waiting on the clock, and the beat time they are waiting for. +# Cleared whenever the beat changes, so a line a beat did not get round to +# saying cannot surface in the middle of the next one. +var _pending_lines: Array = [] +var _pending_at: float = 0.0 @onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay") @@ -129,6 +147,7 @@ func _process(delta: float) -> void: if not running: return beat_time += delta + _drain_pending() _tick(delta) _apply_shake(delta) if beat_time < _deadline() and not _ready_to_advance(): @@ -139,9 +158,31 @@ func _process(delta: float) -> void: _begin(beat + 1) +# #170: says a beat's lines once its clock reaches `at`. Deliberately measured +# in BEAT time rather than wall clock, like everything else here: a dialog +# stops the tree, so a line still waiting behind an unread one cannot have its +# turn come round while nobody is looking. +func _say_later(at: float, lines: Array) -> void: + _pending_at = at + _pending_lines = lines + + +func _drain_pending() -> void: + if _pending_lines.is_empty() or beat_time < _pending_at: + return + var lines: Array = _pending_lines + _pending_lines = [] + for line in lines: + narration.say(String(line[0]), String(line[1])) + + func _ready_to_advance() -> bool: if beat_time < _floor_time(): return false + # A beat with a line still to come has not finished talking, whatever the + # banner currently says (#170). + if _waits_for_narration() and not _pending_lines.is_empty(): + return false if _waits_for_narration() and narration.is_speaking(): return false return _beat_complete() or beat_time >= _floor_time() and _floor_only() @@ -162,6 +203,7 @@ func finish() -> void: return _done = true running = false + _pending_lines = [] _clear_props() GameState.intro_active = false GameState.intro_locks_movement = false @@ -275,6 +317,7 @@ func _clear_props() -> void: func _begin(next: int) -> void: beat = next beat_time = 0.0 + _pending_lines = [] match beat: Beat.BRIEF: start_at = fleet.get_centroid() @@ -285,23 +328,28 @@ func _begin(next: int) -> void: # takes for CONVOY and CONVOY_FORMATION to line up. _apply_convoy_formation() fleet.spawn_ships(CONVOY.size(), start_at, CONVOY) - narration.say("Convoy Kestrel, you are carrying the season's shipment to the World Ship.") - narration.say("Hold your heading and keep the holds intact. We will be watching.") + # #170: not yet. The convoy is under way before the guide calls. + _say_later(OPENING_SILENCE, [ + ["Convoy Kestrel. You are carrying this season's cargo to the World Ship.", + NARRATION.DEFAULT_SPEAKER], + ["Stay on course and keep the holds safe. We are watching.", + NARRATION.DEFAULT_SPEAKER], + ]) Beat.RUN: - narration.say("World Ship bears ahead. Fly on.") + narration.say("The World Ship is straight ahead. Keep flying.") Beat.AMBUSH: _spawn_ambush() - narration.say("Contacts ahead - raiders, a light group. Break them and continue.") + narration.say("Raiders ahead, and not many of them. Break them, then carry on.") Beat.CLEAR: - narration.say("Clean work, Kestrel. Resume your heading.") + narration.say("Good work, Kestrel. Back on course.") Beat.GAUNTLET: _spawn_belt() - narration.say("Debris belt across your lane. Take it slow - there is no way around.") + narration.say("A rock belt lies across your lane. Go slowly. There is no way around it.") Beat.BREACH: GameState.intro_locks_movement = true breach_at = fleet.get_centroid() + Vector2(0.0, -900.0) _open_breach() - narration.say("Kestrel, your heading is - something is opening ahead of you -", "CONVOY CONTROL") + narration.say("Kestrel, your course is - something is opening ahead of you -", "CONVOY CONTROL") Beat.VORTEX: vortex_time = 0.0 _ensure_survivor_hull() @@ -309,8 +357,8 @@ func _begin(next: int) -> void: 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.", SHIP_LOG) - narration.say("No communication with the World Ship can be heard.", SHIP_LOG) + narration.say("The hole threw us somewhere else. We are lost, and we are alone.", SHIP_LOG) + narration.say("Nothing answers. The World Ship cannot hear us out here.", SHIP_LOG) func _deadline() -> float: