diff --git a/tests/test_dialog_releases_joysticks.gd b/tests/test_dialog_releases_joysticks.gd new file mode 100644 index 0000000..c1c1fdb --- /dev/null +++ b/tests/test_dialog_releases_joysticks.gd @@ -0,0 +1,87 @@ +extends SceneTree + +# Forgejo #182: opening a dialog releases both touch joysticks before the +# world is paused. A finger-up event during the pause cannot reach their +# ordinary input path, so waiting for it would resume stale movement/fire. + +var state: Node +var main: Node +var fleet: Node +var narration: Control +var movement: Control +var firing: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + state = root.get_node("GameState") + state.reset_new_game() + state.intro_active = false + state.reset_quests() + main = load("res://world/Main.tscn").instantiate() + root.add_child(main) + current_scene = main + fleet = main.get_node("Fleet") + narration = main.get_node("UI/NarrationBanner") + movement = main.get_node("UI/Joystick") + firing = main.get_node("UI/FireStick") + + +func _hold(stick: Control, index: int, direction: Vector2) -> void: + var centre: Vector2 = stick.global_position + stick.size * 0.5 + var press := InputEventScreenTouch.new() + press.index = index + press.pressed = true + press.position = centre + stick.handle_touch(press) + var drag := InputEventScreenDrag.new() + drag.index = index + drag.position = centre + direction.normalized() * stick.max_radius + stick.handle_drag(drag) + + +func _process(delta: float) -> bool: + elapsed += delta + if stage != 0 or elapsed < 0.3: + return false + stage = 1 + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + _hold(movement, 21, Vector2.RIGHT) + _hold(firing, 22, Vector2.UP) + main._process(0.0) + main.get_node("FireStickController")._process(0.0) + if not movement.is_active() or not firing.is_active(): + return _fail("the test could not hold both joysticks") + if movement.direction == Vector2.ZERO or firing.direction == Vector2.ZERO: + return _fail("a held joystick reported no direction") + if fleet.steer_direction == Vector2.ZERO: + return _fail("the movement joystick had not begun steering") + + narration.say("Hands clear.", "TEST") + if not paused: + return _fail("the dialog did not pause the game") + for stick in [movement, firing]: + if stick.is_active() or stick.direction != Vector2.ZERO: + return _fail("%s remained held behind the dialog" % stick.name) + if stick.base_pos.distance_to(stick.home_pos()) > 0.01 \ + or stick.knob_pos.distance_to(stick.home_pos()) > 0.01: + return _fail("%s did not visually recenter" % stick.name) + if fleet.steer_direction != Vector2.ZERO: + return _fail("releasing the movement joystick did not stop the fleet") + + narration.clear() + main.get_node("FireStickController")._process(0.0) + if main.get_node("FireStickController").is_firing(): + return _fail("fire resumed from stale joystick input after the dialog") + print("dialog released and recentered movement and fire joysticks before pausing") + print("TEST_DIALOG_RELEASES_JOYSTICKS: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_DIALOG_RELEASES_JOYSTICKS: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_energy_propulser_dash.gd.uid b/tests/test_energy_propulser_dash.gd.uid new file mode 100644 index 0000000..058c8a8 --- /dev/null +++ b/tests/test_energy_propulser_dash.gd.uid @@ -0,0 +1 @@ +uid://bxn8xvfo3v7v0 diff --git a/tests/test_intro_scene.gd b/tests/test_intro_scene.gd index 20a7b12..b4c20fa 100644 --- a/tests/test_intro_scene.gd +++ b/tests/test_intro_scene.gd @@ -35,6 +35,8 @@ extends SceneTree # 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 +# T. #183 - absorption whites out the full screen, teleports only once the +# cover is opaque, then reveals the fleet at the normal spawn var game_state: Node var scene: Node @@ -45,6 +47,7 @@ var stage: int = 0 var elapsed: float = 0.0 var convoy_size: int = 0 var skipped_props: Array = [] +var whiteout_mid_checked: bool = false func _initialize() -> void: @@ -367,6 +370,14 @@ func _process(delta: float) -> bool: # --- H. the vortex -------------------------------------- director._begin(director.Beat.VORTEX) + var fade: ColorRect = scene.get_node("FadeOverlay/FadeRect") + var viewport_size: Vector2 = scene.get_viewport().get_visible_rect().size + if fade.size.distance_to(viewport_size) > 1.0: + return _fail("whiteout is %s, viewport is %s" % [fade.size, viewport_size]) + if fade.color.r < 0.99 or fade.color.g < 0.99 or fade.color.b < 0.99: + return _fail("the vortex transition is not white") + if fade.color.a > 0.01: + return _fail("the whiteout began fully opaque instead of fading in") elapsed = 0.0 stage = 3 return false @@ -374,6 +385,15 @@ func _process(delta: float) -> bool: # Left to the director's own clock rather than ticked by hand: # the vortex is a timed animation and driving it twice would not # be the thing that ships. + if not whiteout_mid_checked and elapsed >= director.WHITEOUT_IN_SECONDS * 0.5: + whiteout_mid_checked = true + var mid_fade: ColorRect = scene.get_node("FadeOverlay/FadeRect") + if mid_fade.color.a <= 0.05 or mid_fade.color.a >= 0.95: + return _fail("whiteout did not gradually fade in (alpha %.2f)" % mid_fade.color.a) + if director.whiteout_teleported: + return _fail("fleet teleported before the screen was fully white") + if fleet.get_formation_origin().distance_to(director.DEFAULT_SPAWN) <= 1.0: + return _fail("fleet reached default spawn before full white") if elapsed < 4.2: return false var alive: int = 0 @@ -386,6 +406,15 @@ func _process(delta: float) -> bool: return _fail("the survivor is a %s, not the default interceptor" \ % fleet.ships[0].vessel_type) print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1)) + if not director.whiteout_teleported: + return _fail("the screen reached white without teleporting the fleet") + if fleet.get_formation_origin().distance_to(director.DEFAULT_SPAWN) > 1.0: + return _fail("whiteout revealed the fleet at %s instead of default spawn %s" \ + % [fleet.get_formation_origin(), director.DEFAULT_SPAWN]) + var revealing: ColorRect = scene.get_node("FadeOverlay/FadeRect") + if revealing.color.a >= 0.99: + return _fail("screen stayed fully white after teleport") + print("whiteout hid the teleport to default spawn, then began revealing it") # --- I. the handover ------------------------------------ director.finish() @@ -426,6 +455,9 @@ func _process(delta: float) -> bool: return _fail("the breach lens is still over the screen after the handover") if is_instance_valid(director) and director.breach != null: return _fail("the black hole outlived the prologue") + var finished_fade: ColorRect = scene.get_node("FadeOverlay/FadeRect") + if finished_fade.color.a > 0.001 or finished_fade.color.r > 0.001: + return _fail("intro left the shared fade overlay tinted white") print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back") _drop_scene() elapsed = 0.0 diff --git a/ui/NarrationBanner.gd b/ui/NarrationBanner.gd index f3e65f3..e166303 100644 --- a/ui/NarrationBanner.gd +++ b/ui/NarrationBanner.gd @@ -240,12 +240,26 @@ func _advance() -> void: label.visible_characters = 0 speaker_label.text = speaker visible = true + _release_touch_joysticks() Sfx.play(get_tree(), Sfx.DIALOG) _layout() queue_redraw() _sync_pause() +# A touch release delivered while the tree is paused never reaches an +# ordinary joystick. Neutralise every spring-loaded stick immediately before +# applying the pause, so neither movement nor firing resumes from stale input +# when the dialog closes (#182). +func _release_touch_joysticks() -> void: + var tree: SceneTree = get_tree() + if tree == null: + return + for joystick in tree.get_nodes_in_group("touch_joysticks"): + if is_instance_valid(joystick) and joystick.has_method("release_input"): + joystick.release_input() + + func is_typing() -> bool: return current != "" and revealed < float(current.length()) diff --git a/ui/TouchJoystick.gd b/ui/TouchJoystick.gd index 82ee19b..78056a6 100644 --- a/ui/TouchJoystick.gd +++ b/ui/TouchJoystick.gd @@ -40,6 +40,9 @@ var knob_pos: Vector2 = Vector2.ZERO func _ready() -> void: + # Dialogs release every spring-loaded stick through one compositional + # contract, without NarrationBanner knowing scene paths or joystick roles. + add_to_group("touch_joysticks") resized.connect(_relayout) _relayout() @@ -79,6 +82,15 @@ func handle_touch(event: InputEventScreenTouch) -> bool: if event.index != active_touch_index: return false + release_input() + return true + + +# Returns this spring-loaded control to its neutral state. Public and +# idempotent so a dialog can safely release movement and fire sticks before +# pausing their controllers (#182), even when only one is currently held. +func release_input() -> void: + var was_active: bool = is_active() or direction != Vector2.ZERO active_touch_index = -1 # Back to the middle of the rect, BASE included. This used to reset the # knob onto base_pos and leave the base wherever the finger had landed, so @@ -88,9 +100,9 @@ func handle_touch(event: InputEventScreenTouch) -> bool: knob_pos = base_pos direction = Vector2.ZERO queue_redraw() - direction_changed.emit(direction) - released.emit() - return true + if was_active: + direction_changed.emit(direction) + released.emit() func handle_drag(event: InputEventScreenDrag) -> bool: diff --git a/vessels/Fleet.gd b/vessels/Fleet.gd index 982a142..4b252c5 100644 --- a/vessels/Fleet.gd +++ b/vessels/Fleet.gd @@ -215,6 +215,26 @@ func move_to(world_pos: Vector2) -> void: _assign_slot_targets() +# Moves the complete formation immediately while preserving every authored +# slot. Unlike move_to(), this is a scene transition hidden behind a full +# screen cover, not a travel order ships should visibly fly toward (#183). +func teleport_to(world_pos: Vector2) -> void: + var displacement: Vector2 = world_pos - get_formation_origin() + for ship in ships: + if not is_instance_valid(ship): + continue + ship.position += displacement + ship.move_target += displacement + ship.velocity = Vector2.ZERO + ship.fleet_velocity = Vector2.ZERO + steer_direction = Vector2.ZERO + dash_remaining = 0.0 + dash_direction = Vector2.ZERO + fleet_target = world_pos + anchor = world_pos + GameState.fleet_position = get_centroid() + + func move_in_direction(direction: Vector2) -> void: # Joystick steering. Sets the heading; _process advances the anchor. # diff --git a/world/IntroDirector.gd b/world/IntroDirector.gd index 773a574..84382e1 100644 --- a/world/IntroDirector.gd +++ b/world/IntroDirector.gd @@ -79,6 +79,11 @@ const BELT_HALF_WIDTH: float = 2600.0 const BELT_DEPTH: float = 1400.0 const BREACH_PULL: float = 620.0 const SHAKE_MAX: float = 34.0 +const DEFAULT_SPAWN: Vector2 = Vector2.ZERO +# The cover reaches white after the convoy has visibly collapsed to its +# survivor, then reveals the ordinary game location with time to breathe. +const WHITEOUT_IN_SECONDS: float = 3.2 +const WHITEOUT_OUT_SECONDS: float = 2.0 # How far past the horizon the lens reaches. Generous: the bend is what # sells the hole, and it falls off to nothing on its own well before this. const BREACH_LENS_SCALE: float = 7.0 @@ -100,6 +105,7 @@ const VORTEX_COLORS: Array = [ @onready var narration: Control = get_node("../UI/NarrationBanner") @onready var asteroid_field: Node2D = get_node("../AsteroidField") @onready var enemy_spawner: Node2D = get_node("../EnemySpawner") +@onready var fade_rect: ColorRect = get_node_or_null("../FadeOverlay/FadeRect") var running: bool = false var beat: int = Beat.BRIEF @@ -119,6 +125,8 @@ var _done: bool = false # saying cannot surface in the middle of the next one. var _pending_lines: Array = [] var _pending_at: float = 0.0 +var whiteout_teleported: bool = false +var _whiteout_tween: Tween = null @onready var overlay: ColorRect = get_node_or_null("../UI/BreachOverlay") @@ -209,6 +217,7 @@ func finish() -> void: GameState.intro_locks_movement = false GameState.intro_fleet_size = 0 narration.clear() + _reset_whiteout() camera.offset = Vector2.ZERO enemy_spawner.set_process(true) asteroid_field.set_process(true) @@ -354,6 +363,7 @@ func _begin(next: int) -> void: vortex_time = 0.0 _ensure_survivor_hull() narration.clear() + _start_whiteout() Beat.AFTERMATH: GameState.intro_locks_movement = false shake = 0.0 @@ -434,7 +444,11 @@ func _tick(delta: float) -> void: vortex_time += delta shake = maxf(shake - delta * SHAKE_MAX * 0.5, SHAKE_MAX * 0.35) _drive_breach(1.0) - _whirl(delta) + # Once the opaque frame has moved the survivor to the real spawn, + # nothing from the old side may pull it back into the breach while the + # white cover clears. + if not whiteout_teleported: + _whirl(delta) queue_redraw() Beat.AFTERMATH: shake = maxf(shake - delta * SHAKE_MAX, 0.0) @@ -445,6 +459,50 @@ func _tick(delta: float) -> void: queue_redraw() +# The existing transition ColorRect already owns the topmost full-screen +# layer. Recolour it for this one transition, and sequence the teleport as a +# callback between fade-in and fade-out so no world-space jump can be seen. +func _start_whiteout() -> void: + whiteout_teleported = false + if fade_rect == null: + # Headless/minimal scenes may omit presentation, but the story still has + # to land at the same gameplay state. + _complete_whiteout() + return + if _whiteout_tween != null and _whiteout_tween.is_valid(): + _whiteout_tween.kill() + fade_rect.color = Color(1.0, 1.0, 1.0, 0.0) + _whiteout_tween = create_tween() + _whiteout_tween.tween_property(fade_rect, "color:a", 1.0, WHITEOUT_IN_SECONDS) + _whiteout_tween.tween_callback(_complete_whiteout) + _whiteout_tween.tween_property(fade_rect, "color:a", 0.0, WHITEOUT_OUT_SECONDS) + _whiteout_tween.tween_callback(_finish_whiteout) + + +func _complete_whiteout() -> void: + if whiteout_teleported or not running: + return + whiteout_teleported = true + fleet.teleport_to(DEFAULT_SPAWN) + camera.global_position = fleet.get_centroid() + camera.offset = Vector2.ZERO + + +func _finish_whiteout() -> void: + _whiteout_tween = null + if fade_rect != null: + # Restore the shared rect's normal transparent-black resting state so a + # later planet transition remains black rather than reusing intro white. + fade_rect.color = Color(0.0, 0.0, 0.0, 0.0) + + +func _reset_whiteout() -> void: + if _whiteout_tween != null and _whiteout_tween.is_valid(): + _whiteout_tween.kill() + _whiteout_tween = null + _finish_whiteout() + + func _apply_shake(delta: float) -> void: if shake <= 0.01: if camera.offset != Vector2.ZERO: