diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 9fa9aec..9bb5ae9 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -325,6 +325,13 @@ func _ready() -> void: transfer_button.visible = false hangar_button.visible = false hangar_menu.visible = false + # #128: the prologue's "movement and nothing else" used to be applied by + # _process on the first frame. A dialog now stops the tree, and the + # prologue opens ON one - so that first frame never came and every action + # button was left on screen behind the opening line. Applied here as well, + # where it does not depend on the game running. + if GameState.intro_active: + _hide_all_actions() _layout_context_buttons() diff --git a/tests/test_dialog_pause.gd b/tests/test_dialog_pause.gd new file mode 100644 index 0000000..b819438 --- /dev/null +++ b/tests/test_dialog_pause.gd @@ -0,0 +1,208 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_dialog_pause.gd +# Forgejo #128: "When a dialog appears, no command should work and the game +# physics should be paused as long as the message is not acknowledged." +# +# Two claims, and they are checked separately because they can fail +# separately: the world must STOP, and the controls must not ANSWER. Presses +# are pushed through the viewport rather than handed to a handler directly - +# calling a handler by hand would prove nothing about whether the engine +# would have delivered the event at all, which is the whole question here. +# +# What a press does while a dialog is up is ACKNOWLEDGE it (#127) and +# nothing else. So "no command works" is not "the press vanishes": it is +# "the press reached the dialog and reached nothing else", and that is what +# is asserted below. +# +# Covers: +# A. a line goes up and the tree is paused +# B. the world is frozen: the fleet holds still under a movement order +# C. GameState's own clock is frozen too - the wave clock does not run +# D. no command works: a press on a HUD button, delivered the way a finger +# delivers one, acknowledges the dialog and does not fire the button +# E. queued lines hold the pause until the LAST one is acknowledged +# F. after the last one, the world runs and the same press does fire +# G. leaving the tree with a line up does not leave the game stopped with +# nothing left that could start it again + +var game_state: Node +var scene: Node +var fleet: Node2D +var narration: Control +var button: Control +var build_menu: Control +var presses: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var frozen_at: Vector2 = Vector2.ZERO +var frozen_wave_timer: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + # Not the prologue. The prologue has its own dialog coverage + # (test_intro_scene); this is about dialogs in the ordinary game, where + # there are commands to block in the first place. + game_state.intro_active = false + # And no quest chain. Quest briefings are dialogs too (#132), and this + # test is about what a dialog DOES - it needs to be the one deciding + # when one appears. + game_state.reset_quests() + game_state.rocks = 500 + + +func _fail(message: String) -> bool: + print("TEST_DIALOG_PAUSE: FAIL (%s)" % message) + quit(1) + return true + + +func _on_pressed() -> void: + presses += 1 + + +# A press delivered the way the player delivers one: through the viewport, at +# the button's own screen rect, so the engine's routing decides whether it +# arrives rather than the test deciding for it. +func _press_button() -> void: + var at: Vector2 = button.get_global_rect().get_center() + for is_down in [true, false]: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = is_down + event.position = at + event.global_position = at + # in_local_coords, deliberately. The other form runs the position + # through the window's screen transform, and headless has no window - + # the event then lands nowhere and the button looks broken. + root.push_input(event, true) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + fleet = scene.get_node("Fleet") + narration = scene.get_node("UI/NarrationBanner") + button = scene.get_node("UI/BuildButton") + build_menu = scene.get_node("UI/BuildMenu") + button.pressed.connect(_on_pressed) + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.4: + return false + if paused: + return _fail("the game was already stopped before any dialog") + # The button answers when nothing is in the way. Without this + # baseline, D below would prove only that the button was + # unreachable all along. + presses = 0 + _press_button() + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + if presses == 0: + return _fail("the build button does not answer a press even with no dialog up") + print("baseline: a press on the build button fires it") + # It opened the build menu, which is modal and would block every + # later press for a reason that has nothing to do with #128. + build_menu.visible = false + + # --- A. a line stops the game ---------------------------- + narration.say("Hold position.", "TEST") + narration.say("And stand by.", "TEST") + if not paused: + return _fail("a dialog went up and the game kept running") + print("dialog up: the tree is paused") + + frozen_at = fleet.get_centroid() + frozen_wave_timer = game_state.wave_timer + # Ordered somewhere it is nowhere near, so even a little drift + # would show up. steer_direction persists, so this order stands + # for the whole frozen second below. + fleet.move_in_direction(Vector2.RIGHT) + presses = 0 + elapsed = 0.0 + stage = 3 + return false + 3: + # --- B and C. a whole second of frozen world ------------- + if elapsed < 1.0: + return false + var moved: float = fleet.get_centroid().distance_to(frozen_at) + if moved > 0.5: + return _fail("the fleet drifted %.1fpx while a dialog was up" % moved) + print("fleet held still for 1s under a movement order (%.2fpx)" % moved) + var ran: float = absf(game_state.wave_timer - frozen_wave_timer) + if ran > 0.001: + return _fail("the wave clock ran on %.2fs while a dialog was up" % ran) + print("the wave clock did not advance") + + # --- D. the press acknowledges, and does nothing else ---- + _press_button() + if presses != 0: + return _fail("the build button fired while a dialog was up") + if build_menu.visible: + return _fail("the build menu opened from a press meant for the dialog") + print("a press while the dialog was up did not reach the button") + + # --- E. the queued line keeps the game stopped ----------- + if not narration.is_speaking(): + return _fail("one press acknowledged both queued lines") + if not paused: + return _fail("the game restarted with a line still on screen") + print("a second queued line keeps the game stopped") + + _press_button() + if presses != 0: + return _fail("the build button fired on the acknowledging press") + if narration.is_speaking(): + return _fail("the banner is still speaking after the last line") + if paused: + return _fail("the last line was acknowledged and the game is still stopped") + print("last line acknowledged: the game is running again") + + frozen_at = fleet.get_centroid() + presses = 0 + elapsed = 0.0 + stage = 4 + return false + 4: + # --- F. and the world answers again ---------------------- + if elapsed < 0.4: + return false + if fleet.get_centroid().distance_to(frozen_at) < 1.0: + return _fail("the fleet is still frozen after the dialog closed") + _press_button() + if presses == 0: + return _fail("the build button stayed dead after the dialog closed") + build_menu.visible = false + print("the world moves again: the fleet flies and the button fires") + + # --- G. torn down mid-line ------------------------------ + narration.say("Interrupted.", "TEST") + if not paused: + return _fail("the dialog did not stop the game the second time") + scene.free() + scene = null + if paused: + return _fail("the scene was torn down mid-dialog and left the game stopped") + print("a scene torn down mid-dialog leaves the game running") + + print("TEST_DIALOG_PAUSE: PASS") + quit(0) + return true + return false diff --git a/tests/test_dialog_pause.gd.uid b/tests/test_dialog_pause.gd.uid new file mode 100644 index 0000000..302c40a --- /dev/null +++ b/tests/test_dialog_pause.gd.uid @@ -0,0 +1 @@ +uid://clgtbnc7pfxbd diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index 48d46b8..62edd44 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -29,6 +29,9 @@ extends SceneTree # N. #127 - a dialog stays up until it is dismissed: no timer closes it, # a click does, and the click is eaten rather than also steering # O. #127 - every dialog names its speaker +# P. #128 - the prologue opens STOPPED behind its first line, and +# acknowledging it is what starts the world +# Q. #133 - the handover runs straight into the first quest var game_state: Node var scene: Node @@ -105,6 +108,14 @@ func _process(delta: float) -> bool: return _fail("the line changed on its own after 10s - #127 asks for a click") if not narration.visible: return _fail("the banner closed itself") + # --- 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: + 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) + var click: InputEventMouseButton = InputEventMouseButton.new() click.button_index = MOUSE_BUTTON_LEFT click.pressed = true @@ -136,10 +147,13 @@ func _process(delta: float) -> bool: return _fail("the opening beat was over before the first line finished") if not narration.is_speaking(): return _fail("the opening beat is silent, so waiting for it proves nothing") - # A player who never clicks must not be stuck: the beat's own - # deadline is the way out. + # Every beat has room to run past its floor before its deadline, + # so a player who will not finish a beat's task is carried past + # it. Since #128 that deadline is game time and a dialog stops + # the clock, so it is a way past an unfinished BEAT, never past + # an unacknowledged line. if director._deadline() <= director._floor_time(): - return _fail("the opening beat has no deadline past its floor - a silent player would stall") + return _fail("the opening beat has no deadline past its floor - a stalled player would be stuck") print("opening beat still running at %.1fs, guide still talking" % director.beat_time) # --- C. the convoy -------------------------------------- @@ -220,6 +234,13 @@ func _process(delta: float) -> bool: return _fail("the ambush counts as cleared while its raiders are alive") for enemy in director.ambush: enemy.queue_free() + # #128: the beat announced itself, and an unacknowledged line + # stops the tree - including the director, whose advance is what + # stage 2 is waiting for. Acknowledged here, the way a player + # would, so the clock below is running. + narration.clear() + if paused: + return _fail("clearing the last line left the game stopped") elapsed = 0.0 stage = 2 return false @@ -335,8 +356,16 @@ func _process(delta: float) -> bool: scene.get_node("BuildController")._process(0.1) if _hidden("UI/BuildButton"): return _fail("the build button never came back") - if narration.is_speaking(): - return _fail("the guide is still talking after the handover") + # #133: the guide has nothing more to say - but the quest chain + # starts exactly here, so what is on the banner now is the first + # objective rather than any leftover of the prologue. + var quest: Dictionary = game_state.active_quest() + if quest.is_empty(): + return _fail("the handover left no first quest") + if narration.is_speaking() and narration.current != String(quest.brief): + return _fail("the banner is still on \"%s\" after the handover" \ + % narration.current) + print("the prologue hands over to the first quest: \"%s\"" % quest.title) if scene.get_node("UI/BreachOverlay").visible: return _fail("the breach lens is still over the screen after the handover") if is_instance_valid(director) and director.breach != null: diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index 6dab8a7..9ea23de 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -13,8 +13,19 @@ extends Control # # The beats that are about the guide talking hold while this is speaking # (see IntroDirector._waits_for_narration), so dismissing a line is what -# advances the prologue - bounded by each beat's own deadline, so a player -# who never clicks is never stuck. +# advances the prologue. +# +# #128 makes that unconditional: while a line is up the WHOLE GAME is +# stopped - get_tree().paused, which freezes the fleet, the enemies, the +# production in GameState, and the director's own beat clock, and which +# stops input reaching any node that is not this one. So there is nothing +# to miss while reading and nothing to fumble a command into, and a beat's +# deadline can no longer run out from under an unacknowledged line: the +# player's click is the only thing that starts the world again. +# +# The pause is owned HERE rather than by each caller, because the thing that +# must not get out of step is "a line is showing" and "the game is stopped", +# and the only code that knows the first is this file. signal dismissed @@ -42,12 +53,53 @@ var queued: Array = [] var current: String = "" var speaker: String = "" var shown_for: float = 0.0 +# Whether the pause currently in force is OURS. Checked before lifting it, so +# a pause somebody else put on (a menu, a future one) is never lifted by a +# dialog closing. +var _holds_pause: bool = false func _ready() -> void: + # #128: the one node that keeps running while the game is stopped. Set + # here as well as in _ensure_label() because a banner that pauses the + # tree and then cannot process its own dismissal would wedge the game. + process_mode = Node.PROCESS_MODE_ALWAYS _ensure_label() +# The pause goes on when a line goes up and comes off when the last one is +# gone. Both are idempotent, so it does not matter how many times the state +# is re-derived - only that it is derived from one place (_sync_pause). +func _sync_pause() -> void: + var wanted: bool = current != "" + if wanted == _holds_pause: + return + var tree: SceneTree = get_tree() + if tree == null: + # Not in the tree: there is nothing to pause, and nothing we could + # have paused earlier is still ours to lift. + _holds_pause = false + return + if wanted: + tree.paused = true + _holds_pause = true + return + tree.paused = false + _holds_pause = false + + +# Leaving the tree with a line up - the scene swapping to a planet surface, +# a test dropping the scene - must not leave the game stopped with nothing +# left that could start it again. +func _exit_tree() -> void: + if not _holds_pause: + return + var tree: SceneTree = get_tree() + if tree != null: + tree.paused = false + _holds_pause = false + + # The director is a world node and this banner lives under the UI layer, # which readies afterwards - so the first line can arrive before _ready(). # Building on demand is simpler than making the director wait a frame, and @@ -55,6 +107,7 @@ func _ready() -> void: func _ensure_label() -> void: if label != null: return + process_mode = Node.PROCESS_MODE_ALWAYS mouse_filter = Control.MOUSE_FILTER_IGNORE visible = false label = Label.new() @@ -118,6 +171,7 @@ func clear() -> void: speaker = "" shown_for = 0.0 visible = false + _sync_pause() func is_speaking() -> bool: @@ -141,6 +195,7 @@ func _advance() -> void: current = "" speaker = "" visible = false + _sync_pause() return var next: Dictionary = queued.pop_front() current = String(next.line) @@ -150,6 +205,7 @@ func _advance() -> void: visible = true _layout() queue_redraw() + _sync_pause() func _process(delta: float) -> void: diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index 1155994..97c8420 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -15,6 +15,11 @@ extends Node2D # and the next one starts. skip() ends the whole thing at any point and # lands in the same state as playing it through, so there is one way for the # game to start rather than two that have to be kept in step. +# +# #128: those deadlines are wall clock only while the game is RUNNING. A +# dialog stops the tree, which stops this node too, so a beat's clock never +# ticks past an unread line - the deadline is the way out of a beat the +# player will not finish, not a way past a line they have not acknowledged. signal finished @@ -294,6 +299,11 @@ func _floor_time() -> float: # Beats where the guide is the point, and cutting the line off would be # cutting the beat off. The action beats are deliberately not here: a wave # arriving must not wait for a sentence to finish. +# +# Since #128 stops the whole tree while a line is up, this node is not +# running to ask the question in the first place - so this now says the same +# thing twice. Kept because it is the DIRECTOR's own reason for waiting, and +# it should not quietly depend on how dialogs happen to be implemented. func _waits_for_narration() -> bool: return beat == Beat.BRIEF or beat == Beat.CLEAR or beat == Beat.AFTERMATH diff --git a/world/WorldMap.gd b/world/WorldMap.gd index 6714532..d694157 100644 --- a/world/WorldMap.gd +++ b/world/WorldMap.gd @@ -19,12 +19,21 @@ func _ready() -> void: open_button.pressed.connect(open_map) recenter_button.pressed.connect(recenter_view) close_button.pressed.connect(close_map) + # #128: a dialog stops the tree, and the prologue opens on one - so + # _process's first frame never comes and the button below would sit on + # screen behind the opening line. Applied once here too, where it does + # not depend on the game running. + _apply_intro_lock() func _process(_delta: float) -> void: - # #122: the prologue leaves movement and nothing else, and the map is a - # button like any other. Owned here rather than by BuildController, which - # hides the rest - this button belongs to the map. + _apply_intro_lock() + + +# #122: the prologue leaves movement and nothing else, and the map is a +# button like any other. Owned here rather than by BuildController, which +# hides the rest - this button belongs to the map. +func _apply_intro_lock() -> void: if GameState.intro_active: if open_button.visible: open_button.visible = false