diff --git a/BuildController.gd b/BuildController.gd index a134678..ab878fc 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -95,6 +95,12 @@ const BUILDING_INFO: Dictionary = { var pending_build_type: String = "" # Which crafter the craft menu is showing (see _on_action_pressed). var craft_building_id: int = 0 +# The craft panel refreshes every frame while it is open (so a delivery +# arriving visibly works the queue down). That overwrote any message the +# moment after it was set, which made a working button look dead - the store +# button in particular. Messages therefore have to outlive a refresh. +var craft_message: String = "" +var craft_message_until: int = 0 var current_building: Node2D = null var pending_building: Node2D = null @@ -149,14 +155,25 @@ func _unhandled_input(event: InputEvent) -> void: # consumes touches through the GUI system swallows them before they get # here, which is what made dragging a formation slot silently do nothing. if skill_menu.visible: - if event is InputEventScreenTouch and skill_tree.handle_touch(event): - get_viewport().set_input_as_handled() - return - if not formation_menu.visible: - return - if event is InputEventScreenTouch and formation_editor.handle_touch(event): + if event is InputEventScreenTouch: + skill_tree.handle_touch(event) + # Handled either way. A touch that lands on the panel but misses a + # node is still a touch ON THE PANEL - letting it through moved the + # fleet and pinch-zoomed the world behind the menu, because + # GameController sits on the root node and sees whatever this leaves + # unhandled. get_viewport().set_input_as_handled() - elif event is InputEventScreenDrag and formation_editor.handle_drag(event): + return + if formation_menu.visible: + if event is InputEventScreenTouch: + formation_editor.handle_touch(event) + elif event is InputEventScreenDrag: + formation_editor.handle_drag(event) + get_viewport().set_input_as_handled() + return + if build_menu.visible or craft_menu.visible or world_map.visible: + # These panels have no raw-input handler of their own, but the world + # behind them must still not react. get_viewport().set_input_as_handled() @@ -450,6 +467,12 @@ func _on_unlock_pressed() -> void: _refresh_skill_menu(id) +func _say(message: String) -> void: + craft_message = message + craft_message_until = Time.get_ticks_msec() + 3000 + _refresh_craft_status() + + func _refresh_craft_status() -> void: # "Here" is the ore delivered to THIS crafter, not the bank: since #16 a # building builds out of its own stock, and showing the bank would say @@ -468,7 +491,10 @@ func _refresh_craft_status() -> void: craft_transporter_button.queue_redraw() craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo - craft_store_button.enabled = GameState.fleet_cargo > 0 + # Deliberately left ENABLED with an empty hold: a greyed-out button that + # cannot say why is exactly the dead-button problem this panel already + # had. Pressing it explains instead. + craft_store_button.enabled = true craft_store_button.queue_redraw() # A full fleet cannot take another vessel, so the order would be refused. @@ -481,6 +507,11 @@ func _refresh_craft_status() -> void: craft_interceptor_button.queue_redraw() # One statement of the fleet count, either way round: the capacity line # replaces the plain readout rather than being bolted in front of it. + if craft_message != "": + if Time.get_ticks_msec() < craft_message_until: + craft_status.text = "%s\n%s" % [craft_message, craft_status.text] + else: + craft_message = "" if room: craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] else: @@ -497,7 +528,7 @@ func _on_craft_interceptor() -> void: if fleet.ships.size() + GameState.ships_owed \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ >= GameState.max_fleet_size(): - craft_status.text = "Fleet is full (%d slots)" % GameState.max_fleet_size() + _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) return # No affordability check: an order the crafter cannot pay for yet is # QUEUED, and completes by itself once ore is delivered here (#16). @@ -519,10 +550,11 @@ func _on_load_ore() -> void: func _on_store_ore() -> void: if craft_building_id == 0: return + if GameState.fleet_cargo <= 0: + _say("The fleet is carrying nothing - load ore at the mothership first") + return var moved: int = GameState.unload_into_building(craft_building_id) - _refresh_craft_status() - if moved > 0: - craft_status.text = "Stored %d ore here %s" % [moved, craft_status.text] + _say("Stored %d ore here" % moved) func _on_craft_transporter() -> void: @@ -531,10 +563,10 @@ func _on_craft_transporter() -> void: if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): # Two different refusals, and the player needs to know WHICH. if not GameState.has_transport_hub(): - craft_status.text = "Build a Transport Hub first - a transporter needs somewhere to be stationed" + _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") else: - craft_status.text = "All %d berths are taken - build another hub, or research Freight Yards" % \ - GameState.transport_capacity() + _say("All %d berths are taken - build another hub, or research Freight Yards" % + GameState.transport_capacity()) return _refresh_craft_status() diff --git a/IconButton.gd b/IconButton.gd index 2fdc552..7dd8ea3 100644 --- a/IconButton.gd +++ b/IconButton.gd @@ -50,7 +50,25 @@ func _ready() -> void: queue_redraw() +func _blocked_by_modal() -> bool: + # A panel is open and this button is not part of it, so it is sitting + # BEHIND the panel and must not react. + # + # The panels cannot simply swallow GUI input with a mouse_filter of STOP: + # the skill tree and the formation editor are driven from raw + # _unhandled_input (see SkillTreeView), and a Control that consumes + # touches through the GUI system swallows them before they ever get + # there. So the panels stay transparent to input and the buttons check + # instead. + for menu in get_tree().get_nodes_in_group("modal_menus"): + if menu.visible and not menu.is_ancestor_of(self): + return true + return false + + func _gui_input(event: InputEvent) -> void: + if _blocked_by_modal(): + return if event is InputEventScreenTouch: _handle_press(event.pressed, event.position) accept_event() diff --git a/Main.tscn b/Main.tscn index f9242fe..88b0a2a 100644 --- a/Main.tscn +++ b/Main.tscn @@ -203,7 +203,7 @@ offset_bottom = -24 script = ExtResource("10") icon = 6 -[node name="WorldMap" type="Control" parent="UI"] +[node name="WorldMap" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -237,7 +237,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="BuildMenu" type="Control" parent="UI"] +[node name="BuildMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -489,7 +489,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="InfoPopup" type="Control" parent="UI/BuildMenu"] +[node name="InfoPopup" type="Control" parent="UI/BuildMenu" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -576,7 +576,7 @@ script = ExtResource("10") icon = 1 glow_color = Color(0, 0.62, 0.7, 1) -[node name="CraftMenu" type="Control" parent="UI"] +[node name="CraftMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false anchor_right = 1.0 anchor_bottom = 1.0 @@ -700,7 +700,7 @@ offset_bottom = 172 script = ExtResource("10") icon = 1 -[node name="FormationMenu" type="Control" parent="UI"] +[node name="FormationMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2 anchor_right = 1.0 @@ -784,7 +784,7 @@ grow_horizontal = 2 grow_vertical = 2 color = Color(0, 0, 0, 0) -[node name="SkillMenu" type="Control" parent="UI"] +[node name="SkillMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2 anchor_right = 1.0 @@ -809,48 +809,53 @@ horizontal_alignment = 1 [node name="Tree" type="Control" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 +anchor_bottom = 1.0 offset_left = 24.0 offset_top = 210.0 offset_right = -24.0 -offset_bottom = 760.0 +offset_bottom = -620.0 script = ExtResource("21") [node name="DetailLabel" type="Label" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 offset_left = 32.0 -offset_top = 780.0 +offset_top = -610.0 offset_right = -32.0 -offset_bottom = 926.0 -theme_override_font_sizes/font_size = 40 +offset_bottom = -462.0 +theme_override_font_sizes/font_size = 34 autowrap_mode = 2 -text = "" horizontal_alignment = 1 -vertical_alignment = 1 +text = "" [node name="CostNumber" type="Control" parent="UI/SkillMenu"] mouse_filter = 2 anchor_left = 0.5 +anchor_top = 1.0 anchor_right = 0.5 +anchor_bottom = 1.0 offset_left = -64.0 -offset_top = 936.0 +offset_top = -456.0 offset_right = 76.0 -offset_bottom = 1000.0 +offset_bottom = -392.0 script = ExtResource("19") -amount = 0 digit_height = 52.0 [node name="StatusLabel" type="Label" parent="UI/SkillMenu"] mouse_filter = 2 anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 offset_left = 32.0 -offset_top = 1006.0 +offset_top = -386.0 offset_right = -32.0 -offset_bottom = 1070.0 -theme_override_font_sizes/font_size = 36 +offset_bottom = -318.0 +theme_override_font_sizes/font_size = 32 autowrap_mode = 2 -text = "" horizontal_alignment = 1 +text = "" [node name="UnlockButton" type="Control" parent="UI/SkillMenu"] anchor_left = 0.5 diff --git a/tests/_craft_menu_preview.gd.uid b/tests/_craft_menu_preview.gd.uid new file mode 100644 index 0000000..365ad46 --- /dev/null +++ b/tests/_craft_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://dlhupvrh333dd diff --git a/tests/_skill_menu_preview.gd b/tests/_skill_menu_preview.gd index 4f74dbc..6a876b1 100644 --- a/tests/_skill_menu_preview.gd +++ b/tests/_skill_menu_preview.gd @@ -25,3 +25,7 @@ func _ready() -> void: bc._refresh_craft_status() else: bc._refresh_load_ore() + # Select a node so the description, price and status all have text - + # an empty description cannot collide with anything. + bc.skill_tree.selected_id = "freight" + bc._refresh_skill_menu("freight") diff --git a/tests/test_hub_capacity.gd.uid b/tests/test_hub_capacity.gd.uid new file mode 100644 index 0000000..abfc3d4 --- /dev/null +++ b/tests/test_hub_capacity.gd.uid @@ -0,0 +1 @@ +uid://dumdbx4iggydw diff --git a/tests/test_modal_blocks_input.gd b/tests/test_modal_blocks_input.gd new file mode 100644 index 0000000..19c3e78 --- /dev/null +++ b/tests/test_modal_blocks_input.gd @@ -0,0 +1,138 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_modal_blocks_input.gd +# With a panel open, nothing behind it may react to a touch. +# +# Two separate leaks, because there are two separate input paths: +# +# 1. GUI clicks. The skill and formation panels cannot block these the +# normal way (mouse_filter = STOP), because the skill tree and the +# formation editor are driven from raw _unhandled_input, and a Control +# that consumes touches through the GUI system swallows them before +# they ever get there. So the panels stay transparent and the buttons +# check whether they are behind one. +# 2. Raw input. A touch landing on the panel but missing a skill node was +# left unhandled, so it fell through to GameController - which sits on +# the ROOT node and therefore sees everything its children ignore - and +# moved the fleet or pinch-zoomed the world behind the menu. +# +# Verifies: +# 1. a button outside an open panel does not fire +# 2. a button INSIDE the open panel still does (or the panel is unusable) +# 3. with every panel closed, that same outside button fires normally, so +# a pass cannot just mean "the button never works" +# 4. a touch on an open panel does not reach the world behind it + +var scene: Node +var game_state: Node +var fleet: Node +var skill_menu: Control +var map_button: Control +var skill_close: Control +var stage: int = 0 +var elapsed: float = 0.0 +var outside_fired: bool = false +var inside_fired: bool = false + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + fleet = scene.get_node("Fleet") + skill_menu = scene.get_node("UI/SkillMenu") + map_button = scene.get_node("UI/MapButton") + skill_close = scene.get_node("UI/SkillMenu/CloseButton") + map_button.pressed.connect(func(): outside_fired = true) + skill_close.pressed.connect(func(): inside_fired = true) + + +func _tap(button: Control) -> void: + # Straight at the button's own _gui_input, which is where a real tap + # lands once Godot has routed it. + var centre: Vector2 = button.size / 2.0 + for pressed in [true, false]: + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.pressed = pressed + ev.position = centre + button._gui_input(ev) + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.2: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + + # --- 3. baseline: it works with nothing open ---------------- + outside_fired = false + _tap(map_button) + if not outside_fired: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (the button never fires even with no panel open - the test proves nothing)") + quit(1) + return true + print("no panel open -> outside button fires") + # That tap actually OPENED the world map, which is itself a modal + # panel - leave it open and it correctly blocks the research + # panel's own buttons, which is a true result but not the one + # being measured here. + scene.get_node("UI/WorldMap").visible = false + + skill_menu.visible = true + elapsed = 0.0 + stage = 1 + return false + 1: + if elapsed < 0.2: + return false + # --- 1. behind an open panel -------------------------------- + outside_fired = false + _tap(map_button) + if outside_fired: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (a button behind the open research panel still fired)") + quit(1) + return true + print("research panel open -> button behind it is inert") + + # --- 2. inside it ------------------------------------------ + inside_fired = false + _tap(skill_close) + if not inside_fired: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (the panel's own close button stopped working)") + quit(1) + return true + print("panel's own button still works") + elapsed = 0.0 + stage = 2 + return false + 2: + if elapsed < 0.2: + return false + # --- 4. raw input must not reach the world ------------------ + skill_menu.visible = true + var before: Vector2 = fleet.fleet_target + var controller: Node = scene.get_node("BuildController") + var ev: InputEventScreenTouch = InputEventScreenTouch.new() + ev.pressed = true + ev.position = Vector2(360.0, 900.0) + controller._unhandled_input(ev) + if not root.get_viewport().is_input_handled(): + print("TEST_MODAL_BLOCKS_INPUT: FAIL (a touch on the open panel was left unhandled - it falls through to the world behind)") + quit(1) + return true + if fleet.fleet_target != before: + print("TEST_MODAL_BLOCKS_INPUT: FAIL (the fleet moved from a touch on the panel)") + quit(1) + return true + print("touch on the panel consumed, fleet target unchanged") + print("TEST_MODAL_BLOCKS_INPUT: PASS") + quit(0) + return true + return false