diff --git a/controllers/GameController.gd b/controllers/GameController.gd index e893ffb..28999cd 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -123,6 +123,30 @@ func _held_pivot_direction() -> float: return clampf(dir, -1.0, 1.0) +# #153: "after a dialog popup the godot input system is lost on keypress. If +# I had a key held it keeps moving in the key direction." +# +# It was not lost - it was never delivered. A dialog stops the tree (#128), +# a paused node receives no input, and this controller is paused along with +# everything else. So a key RELEASED while the line was up never reached +# _handle_key, keys_down kept it, and the fleet flew on in that direction +# once the game restarted. +# +# NOTIFICATION_UNPAUSED arrives whatever a node's process mode is, which +# makes it the one moment where the question can be asked. The physical +# keyboard is the authority on what is actually held down; keys_down is only +# a record of what we were told, and it is the record that went stale. +func _notification(what: int) -> void: + if what == NOTIFICATION_UNPAUSED: + _forget_released_keys() + + +func _forget_released_keys() -> void: + for key in keys_down.keys(): + if not Input.is_key_pressed(key): + keys_down.erase(key) + + func keyboard_direction() -> Vector2: var dir: Vector2 = Vector2.ZERO for key in KEY_DIRECTIONS: diff --git a/surface/PlanetSurface.tscn b/surface/PlanetSurface.tscn index 12785b2..7e3285a 100644 --- a/surface/PlanetSurface.tscn +++ b/surface/PlanetSurface.tscn @@ -117,16 +117,6 @@ horizontal_alignment = 1 text = "" script = ExtResource("15") -[node name="NarrationBanner" type="Control" parent="UI"] -visible = false -mouse_filter = 2 -anchor_right = 1.0 -offset_left = 60.0 -offset_right = -60.0 -offset_top = 300.0 -offset_bottom = 428.0 -script = ExtResource("banner") - [node name="ExitButton" type="Control" parent="UI"] anchor_top = 1.0 anchor_bottom = 1.0 @@ -320,6 +310,16 @@ offset_right = -252 offset_top = -130 offset_bottom = -26 +[node name="NarrationBanner" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 60.0 +offset_right = -60.0 +offset_top = 300.0 +offset_bottom = 428.0 +script = ExtResource("banner") + [node name="FadeOverlay" type="CanvasLayer" parent="."] layer = 10 diff --git a/tests/test_dialog_pause.gd b/tests/test_dialog_pause.gd index b819438..17eb57f 100644 --- a/tests/test_dialog_pause.gd +++ b/tests/test_dialog_pause.gd @@ -152,6 +152,14 @@ func _process(delta: float) -> bool: print("the wave clock did not advance") # --- D. the press acknowledges, and does nothing else ---- + # #149: a line arrives a character at a time, and a press on one + # still arriving finishes it rather than dismissing it. The + # banner types while the game is stopped - it is the one node + # still running - so a second of frozen world is more than + # enough for this line to have finished on its own. Finished + # explicitly here so the press below is unambiguously the + # acknowledging one; the typing itself is test_dialog_typing's. + narration.finish_typing() _press_button() if presses != 0: return _fail("the build button fired while a dialog was up") @@ -166,6 +174,7 @@ func _process(delta: float) -> bool: return _fail("the game restarted with a line still on screen") print("a second queued line keeps the game stopped") + narration.finish_typing() _press_button() if presses != 0: return _fail("the build button fired on the acknowledging press") diff --git a/tests/test_dialog_typing.gd b/tests/test_dialog_typing.gd new file mode 100644 index 0000000..25e9b6c --- /dev/null +++ b/tests/test_dialog_typing.gd @@ -0,0 +1,145 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_dialog_typing.gd +# Forgejo #149: "les dialogues doivent arriver petit a petit, avec un VFX +# associe a des dialogues de science fiction" - dialogs should arrive bit by +# bit, with an effect that reads as science fiction. +# +# The failure worth guarding against is not "it does not type" - it is the +# one that comes with typing: a player clicking at their usual pace skips +# the second line entirely, having never seen a word of it. #127 exists to +# stop exactly that, so a press on a line still arriving has to finish it +# rather than dismiss it. +# +# Covers: +# A. a line arrives a character at a time rather than all at once +# B. the text itself is set whole and REVEALED - growing the string would +# re-wrap the line every frame and make words jump between rows +# C. a press on a line still arriving finishes it and does not dismiss it +# D. a press on a finished line dismisses it, as it always did +# 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 + +var game_state: Node +var scene: Node +var narration: Control +var stage: int = 0 +var elapsed: float = 0.0 +var first_line: String = "" +var seen: Array = [] + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_DIALOG_TYPING: FAIL (%s)" % message) + quit(1) + return true + + +func _click() -> void: + var press: InputEventMouseButton = InputEventMouseButton.new() + press.button_index = MOUSE_BUTTON_LEFT + press.pressed = true + narration._unhandled_input(press) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + narration = scene.get_node("UI/NarrationBanner") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + first_line = "Convoy Kestrel, this is a long enough line that decoding it takes a moment." + narration.say(first_line, "TEST") + narration.say("And a second one behind it.", "TEST") + + # --- A and B. it arrives, and the text is whole ---------- + if not narration.is_typing(): + return _fail("the line was complete the instant it was said") + if narration.label.text != first_line: + return _fail("the label holds a partial string - the line will re-wrap as it types") + if narration.label.visible_characters < 0: + return _fail("the whole line is visible already") + seen.append(narration.label.visible_characters) + + # Hand-ticked, so the reveal is measured rather than raced. The + # banner processes while the tree is stopped (#128), which is E. + if not paused: + return _fail("a dialog did not stop the game") + for i in range(12): + narration._process(1.0 / 60.0) + seen.append(narration.label.visible_characters) + var rising: int = 0 + for i in range(1, seen.size()): + if int(seen[i]) > int(seen[i - 1]): + rising += 1 + if rising < 6: + return _fail("the reveal advanced on only %d of %d frames" % [rising, seen.size() - 1]) + if int(seen[-1]) >= first_line.length(): + return _fail("the whole line decoded within 12 frames - that is not arriving") + print("typed while the game was stopped: %d -> %d of %d characters over 12 frames" \ + % [seen[0], seen[-1], first_line.length()]) + + # --- F. and it looks like it is working ------------------ + # is_typing() is the flag _draw() branches on for the sweep, the + # write-head caret and the frame's charge, so its transition IS + # the effect changing. Checked rather than calling _draw() by + # hand, which Godot refuses outside a paint. + if not narration.is_typing(): + return _fail("the test's own ticking finished the line") + narration.queue_redraw() + narration.finish_typing() + if narration.is_typing(): + return _fail("finish_typing() left the line still arriving") + if narration.label.visible_characters != -1: + return _fail("a finished line is still hiding characters") + if narration.SWEEP_SECONDS <= 0.0 or narration.CHARS_PER_SECOND <= 0.0: + return _fail("the decode has no rate to run at") + print("decoding while arriving, settled when it lands (%.0f chars/s, %.1fs sweep)" \ + % [narration.CHARS_PER_SECOND, narration.SWEEP_SECONDS]) + + # --- C. a press finishes rather than dismisses ----------- + narration.say(first_line, "TEST") + # say() queues behind the current line, so start it cleanly. + narration.clear() + narration.say(first_line, "TEST") + narration.say("Second.", "TEST") + if not narration.is_typing(): + return _fail("the fresh line was not arriving") + _click() + if narration.is_typing(): + return _fail("a press did not finish a line that was still arriving") + if narration.current != first_line: + return _fail("a press on an arriving line dismissed it - the next line would be skipped unread") + print("a press on an arriving line finishes it, and does not move on") + + # --- D. and the next press does move on ------------------ + _click() + if narration.current == first_line: + return _fail("a press on a finished line did not dismiss it") + if narration.current != "Second.": + return _fail("dismissing landed on \"%s\"" % narration.current) + print("the press after that moves on to \"%s\"" % narration.current) + + print("TEST_DIALOG_TYPING: PASS") + quit(0) + return true + return false diff --git a/tests/test_dialog_typing.gd.uid b/tests/test_dialog_typing.gd.uid new file mode 100644 index 0000000..f4affc0 --- /dev/null +++ b/tests/test_dialog_typing.gd.uid @@ -0,0 +1 @@ +uid://cwr86i1ak3xdx diff --git a/tests/test_input_after_dialog.gd b/tests/test_input_after_dialog.gd new file mode 100644 index 0000000..801c380 --- /dev/null +++ b/tests/test_input_after_dialog.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_input_after_dialog.gd +# Forgejo #153: "after a dialog popup the godot input system is lost on +# keypress. If I had a key held it keeps moving in the key direction." +# +# Nothing was lost. A dialog stops the tree (#128) and a paused node receives +# no input, so a key RELEASED while the line was up never reached the +# controller - keys_down kept it, and the fleet flew on in that direction +# once the game restarted. The keyboard is the authority on what is held; +# keys_down is only a record of what we were told, and it went stale. +# +# Covers: +# A. holding a key steers, as it always did +# B. a release that arrives normally is honoured +# C. the bug: a key held when a dialog opens, released while it is up - +# the release never arrives, and the fleet must still stop +# D. and it is the pause LIFTING that does it, not the dismissal itself, +# so anything else that stops the tree is covered too +# +# Forgejo #154 rides along here, being the same scene: a dialog has to be +# drawn over every panel, and it was drawn under the skill tree. + +const KEY_UP: Key = KEY_W + +var game_state: Node +var scene: Node +var controller: Node +var narration: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.reset_new_game() + game_state.intro_active = false + game_state.reset_quests() + + +func _fail(message: String) -> bool: + print("TEST_INPUT_AFTER_DIALOG: FAIL (%s)" % message) + quit(1) + return true + + +func _key(keycode: Key, pressed: bool) -> void: + var event: InputEventKey = InputEventKey.new() + event.keycode = keycode + event.pressed = pressed + controller._handle_key(event) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + controller = scene + narration = scene.get_node("UI/NarrationBanner") + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.3: + return false + + # --- A. holding a key steers ---------------------------- + _key(KEY_UP, true) + if controller.keyboard_direction() == Vector2.ZERO: + return _fail("holding a key steers nowhere") + print("held key steers %s" % controller.keyboard_direction()) + + # --- B. and a release that arrives is honoured ---------- + _key(KEY_UP, false) + if controller.keyboard_direction() != Vector2.ZERO: + return _fail("a delivered release did not stop the steering") + print("a delivered release stops it") + + # --- C. the bug ------------------------------------------ + # Held, then a dialog opens. The player lets go while reading; + # that release is never delivered, because this node is paused. + _key(KEY_UP, true) + narration.say("Let go while you read this.", "TEST") + if not paused: + return _fail("the dialog did not stop the game, so this proves nothing") + if controller.keyboard_direction() == Vector2.ZERO: + return _fail("the key was already forgotten before the dialog closed") + # ...the release happens here, and reaches nobody. + narration.finish_typing() + if not narration.dismiss(): + return _fail("the dialog would not close") + if paused: + return _fail("closing the dialog left the game stopped") + # NOTIFICATION_UNPAUSED is delivered as part of lifting the + # pause, so by now the stale key must be gone. + if controller.keyboard_direction() != Vector2.ZERO: + return _fail("the fleet is still steering %s after a release that was never delivered" \ + % controller.keyboard_direction()) + if controller.keys_down.has(KEY_UP): + return _fail("the key is still recorded as held") + print("a release swallowed by the pause no longer strands the fleet") + + # --- D. by the pause lifting, not by the dismissal ------- + _key(KEY_UP, true) + paused = true + paused = false + if controller.keyboard_direction() != Vector2.ZERO: + return _fail("stopping and restarting the tree left a stale key held") + print("any pause, not just a dialog, reconciles what is held") + + # --- #154. a dialog draws over every panel --------------- + var ui: Node = scene.get_node("UI") + var banner_at: int = narration.get_index() + var panels: Array = [] + for menu in get_nodes_in_group("modal_menus"): + if ui.is_ancestor_of(menu): + panels.append(menu) + if panels.is_empty(): + return _fail("no modal panels found to be drawn over") + for menu in panels: + if menu.get_index() > banner_at: + return _fail("%s is a later child than the dialog, so it draws on top of it" \ + % menu.name) + if banner_at != ui.get_child_count() - 1: + return _fail("the dialog is child %d of %d - something still draws over it" \ + % [banner_at, ui.get_child_count()]) + print("the dialog is the last child of the UI layer, over all %d panels" % panels.size()) + + print("TEST_INPUT_AFTER_DIALOG: PASS") + quit(0) + return true + return false diff --git a/tests/test_input_after_dialog.gd.uid b/tests/test_input_after_dialog.gd.uid new file mode 100644 index 0000000..29f19f1 --- /dev/null +++ b/tests/test_input_after_dialog.gd.uid @@ -0,0 +1 @@ +uid://ce1elyyw2ds53 diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index 9ea23de..f9aa22f 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -26,6 +26,15 @@ extends Control # 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. +# +# #149: a line ARRIVES rather than appearing. It is typed out at a readable +# rate, with a scan sweep running down the panel while it does - a +# transmission being decoded rather than a caption being switched on. +# +# A press during the typing FINISHES it instead of dismissing the line. That +# 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. signal dismissed @@ -46,6 +55,14 @@ const SPEAKER_FONT_SIZE: int = 20 const SPEAKER_PAD: float = 12.0 const SPEAKER_INSET: float = 46.0 const DEFAULT_SPEAKER: String = "CONVOY CONTROL" +# #149: how fast a line decodes. Fast enough not to be a wait, slow enough +# to read as arriving - about three and a half words a second. +const CHARS_PER_SECOND: float = 42.0 +# The scan sweep's travel time, top to bottom of the panel. +const SWEEP_SECONDS: float = 0.9 +const SWEEP_COLOR: Color = Color(0.55, 0.95, 1.0) +# Faint horizontal rules over the whole panel, the way a screen has them. +const SCANLINE_GAP: float = 7.0 var label: Label var speaker_label: Label @@ -53,6 +70,8 @@ var queued: Array = [] var current: String = "" var speaker: String = "" var shown_for: float = 0.0 +# How much of the current line has been decoded, in characters. +var revealed: 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. @@ -184,6 +203,12 @@ func is_speaking() -> bool: func dismiss() -> bool: if current == "": return false + # #149: still arriving, so this press is "show me the rest" rather than + # "I have read it". It still counts as handled - the press must not fall + # through and steer the fleet either way. + if is_typing(): + finish_typing() + return true dismissed.emit() _advance() return true @@ -191,6 +216,7 @@ func dismiss() -> bool: func _advance() -> void: shown_for = 0.0 + revealed = 0.0 if queued.is_empty(): current = "" speaker = "" @@ -200,7 +226,12 @@ func _advance() -> void: var next: Dictionary = queued.pop_front() current = String(next.line) speaker = String(next.speaker) + revealed = 0.0 label.text = current + # The full text is set and then hidden a character at a time, rather than + # the text growing. Growing it would re-wrap the line on nearly every + # frame, so words would jump between rows as they were typed. + label.visible_characters = 0 speaker_label.text = speaker visible = true _layout() @@ -208,12 +239,27 @@ func _advance() -> void: _sync_pause() +func is_typing() -> bool: + return current != "" and revealed < float(current.length()) + + +# Shows the whole line at once. What a press does while it is still arriving. +func finish_typing() -> void: + revealed = float(current.length()) + if label != null: + label.visible_characters = -1 + queue_redraw() + + func _process(delta: float) -> void: if current == "": return - # Only for the fade-in and the prompt's blink. Nothing here closes the - # line - that is the player's job (#127). + # The fade-in, the prompt's blink, and the decode. Nothing here closes + # the line - that is the player's job (#127). shown_for += delta + if is_typing(): + revealed = minf(revealed + delta * CHARS_PER_SECOND, float(current.length())) + label.visible_characters = -1 if not is_typing() else int(revealed) queue_redraw() @@ -277,9 +323,41 @@ func _draw() -> void: # bottom edge read as an underline attached to the last line rather than # as part of the frame. + # #149: the panel reads as a screen decoding something. + # + # Faint rules across the whole of it, always - a surface with a raster + # rather than a sheet of glass - and a bright sweep running down it while + # the line is still arriving, which stops when the decode does. The + # sweep is the thing that says "this is happening now"; without it a + # half-typed line just looks like a short line. + var inner: Rect2 = Rect2(Vector2(INSET, CUT * 0.5), + Vector2(maxf(size.x - INSET * 2.0, 0.0), maxf(size.y - CUT, 0.0))) + var rule: float = inner.position.y + while rule < inner.end.y: + draw_line(Vector2(inner.position.x, rule), Vector2(inner.end.x, rule), + Color(SWEEP_COLOR, 0.05 * alpha), 1.0) + rule += SCANLINE_GAP + + if is_typing(): + var sweep: float = fposmod(shown_for, SWEEP_SECONDS) / SWEEP_SECONDS + var y: float = lerpf(inner.position.y, inner.end.y, sweep) + # Brightest at the head with a short trail above it, so it reads as + # travelling rather than as a line that moved. + for step in range(6): + var trail: float = y - float(step) * 3.0 + if trail < inner.position.y: + break + draw_line(Vector2(inner.position.x, trail), Vector2(inner.end.x, trail), + Color(SWEEP_COLOR, alpha * 0.30 * (1.0 - float(step) / 6.0)), 1.0) + # And the frame carries the same charge while it is working. + NeonDraw.draw_glowing_line(self, Vector2(INSET, y), Vector2(size.x - INSET, y), + Color(SWEEP_COLOR, alpha * 0.5), 0.7, 0.25) + # A blinking caret at the bottom right - the one thing that says a click # is what moves this on. - var blink: float = 0.45 + 0.55 * absf(sin(shown_for * 3.0)) + # Solid while decoding - there is nothing to prompt for yet - and + # blinking once the line is complete and waiting on the reader. + var blink: float = 1.0 if is_typing() else 0.45 + 0.55 * absf(sin(shown_for * 3.0)) var tip: Vector2 = Vector2(size.x - CUT - 6.0, size.y - INSET - 12.0) NeonDraw.draw_glowing_path(self, PackedVector2Array([ tip + Vector2(-11.0, -7.0), tip, tip + Vector2(-11.0, 7.0), diff --git a/world/Main.tscn b/world/Main.tscn index 73e1cfe..c6ea6ab 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -258,16 +258,6 @@ anchor_bottom = 1.0 color = Color(1, 1, 1, 1) script = ExtResource("41") -[node name="NarrationBanner" type="Control" parent="UI"] -visible = false -mouse_filter = 2 -anchor_right = 1.0 -offset_left = 60.0 -offset_right = -60.0 -offset_top = 300.0 -offset_bottom = 428.0 -script = ExtResource("36") - [node name="WaveAlert" type="Control" parent="UI"] visible = false mouse_filter = 2 @@ -2486,3 +2476,13 @@ offset_bottom = 136.0 script = ExtResource("10") icon = 1 glow_color = Color(0.55, 0.6, 1, 1) +[node name="NarrationBanner" type="Control" parent="UI"] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 60.0 +offset_right = -60.0 +offset_top = 300.0 +offset_bottom = 428.0 +script = ExtResource("36") +