diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 829d4d3..7282508 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -343,6 +343,14 @@ func _unhandled_input(event: InputEvent) -> void: skill_tree.handle_touch(event) elif event is InputEventScreenDrag: skill_tree.handle_drag(event) + elif event is InputEventMouseButton: + # #93: a click is not a touch. The project no longer emulates one + # from the other, so without this branch the whole menu is inert + # under a mouse - nothing selects, nothing scrolls, and there is + # no other way to research anything on a desktop. + skill_tree.handle_mouse_button(event) + elif event is InputEventMouseMotion: + skill_tree.handle_mouse_motion(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 diff --git a/tests/test_skill_menu.gd b/tests/test_skill_menu.gd new file mode 100644 index 0000000..bfd714e --- /dev/null +++ b/tests/test_skill_menu.gd @@ -0,0 +1,181 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_skill_menu.gd +# Forgejo #93, both halves of it. +# +# A mouse could not drive the research menu at all. Input reaches the tree +# through BuildController._unhandled_input as raw events (a Control that +# eats them through the GUI system would swallow the drag - see +# SkillTreeView's header), and the tree only ever read touch events. That +# was invisible for as long as the project set emulate_touch_from_mouse, +# which handed it a synthetic touch per click; with that setting gone, the +# menu is inert under a mouse and there is no other way to research +# anything on a desktop. +# +# The panel also still had a hole in it. The buy button below the readout +# went away in #83, and nothing moved into the space, so ~180px of empty +# panel sat under the text while the tree canvas above it stayed cramped. +# +# Covers: +# A. a left click selects a node, through the real routing in +# BuildController rather than by calling the view directly +# B. a second click on the SAME node researches it, which is #83's +# confirm gesture reached with a mouse +# C. holding the left button and moving scrolls the canvas, and moving +# with no button held does NOT - a hovering pointer must not drag the +# tree out from under the reader +# D. the wheel scrolls, in both directions +# E. the readout sits at the bottom of the panel and the tree canvas fills +# everything above it, so neither the dead band nor the cramped tree +# comes back + +const GAP_TOLERANCE: float = 48.0 + +var game_state: Node +var scene: Node +var controller: Node +var skill_menu: Control +var tree_view: Control +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.reset_progression() + game_state.reset_formation() + game_state.rocks = 500 + + scene = load("res://world/Main.tscn").instantiate() + root.add_child(scene) + controller = scene.get_node("BuildController") + skill_menu = scene.get_node("UI/SkillMenu") + tree_view = scene.get_node("UI/SkillMenu/Tree") + + +func _click(at: Vector2, pressed: bool) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.position = at + # Through the controller, not straight into the view: the missing piece + # was the ROUTING, so a test that called tree_view.handle_mouse_button() + # itself would have passed against the reported bug. + controller._unhandled_input(event) + + +func _wheel(button: int) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = button + event.pressed = true + event.position = tree_view.global_position + tree_view.size * 0.5 + controller._unhandled_input(event) + + +func _move(relative: Vector2) -> void: + var event: InputEventMouseMotion = InputEventMouseMotion.new() + event.relative = relative + event.position = tree_view.global_position + tree_view.size * 0.5 + controller._unhandled_input(event) + + +func _node_at(id: String) -> Vector2: + return tree_view.global_position + tree_view.node_screen_pos(game_state.skill_node(id)) + + +func _process(delta: float) -> bool: + elapsed += delta + if stage == 0: + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + scene.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0)) + elapsed = 0.0 + stage = 1 + return false + if stage != 1 or elapsed < 0.2: + return false + stage = 2 + + var mothership: Node2D = null + for building in scene.get_node("BuildingField").spawned: + if is_instance_valid(building) and building.building_type == GameState.BUILDING_MOTHERSHIP: + mothership = building + controller.current_building = mothership + controller._on_action_pressed() + if not skill_menu.visible: + return _fail("the mothership did not open the research menu") + + # --- A. a click selects -------------------------------------------- + var shipyards: Vector2 = _node_at("shipyards") + _click(shipyards, true) + if tree_view.selected_id != "shipyards": + return _fail("a left click on a node selected \"%s\" instead of it" % tree_view.selected_id) + _click(shipyards, false) + print("a left click selects the node under it") + + # --- B. a second click researches ---------------------------------- + if game_state.is_unlocked("shipyards"): + return _fail("shipyards was already researched, so the confirm proves nothing") + _click(shipyards, true) + _click(shipyards, false) + if not game_state.is_unlocked("shipyards"): + return _fail("a second click on the selected node did not research it") + print("a second click on the same node researches it") + + # --- C. held drag scrolls, a bare hover does not -------------------- + var before: float = tree_view.scroll_y + _move(Vector2(0.0, 120.0)) + if not is_equal_approx(tree_view.scroll_y, before): + return _fail("the tree scrolled under a hovering pointer with no button held") + _click(_node_at("shipyards"), true) + _move(Vector2(0.0, 120.0)) + var dragged: float = tree_view.scroll_y + if dragged >= before: + return _fail("holding the button and moving did not scroll the tree (%f -> %f)" \ + % [before, dragged]) + _click(_node_at("shipyards"), false) + _move(Vector2(0.0, 120.0)) + if not is_equal_approx(tree_view.scroll_y, dragged): + return _fail("the tree kept scrolling after the button was released") + print("drag scrolls only while the button is held: %.0f -> %.0f" % [before, dragged]) + + # --- D. the wheel scrolls ------------------------------------------ + _wheel(MOUSE_BUTTON_WHEEL_UP) + var wheeled: float = tree_view.scroll_y + if wheeled >= dragged: + return _fail("the wheel did not scroll the tree upward") + _wheel(MOUSE_BUTTON_WHEEL_DOWN) + if tree_view.scroll_y <= wheeled: + return _fail("the wheel did not scroll back down") + print("the wheel scrolls both ways") + + # --- E. the panel has no dead band at the bottom -------------------- + var detail: Control = scene.get_node("UI/SkillMenu/DetailLabel") + var status: Control = scene.get_node("UI/SkillMenu/StatusLabel") + var tree_bottom: float = tree_view.position.y + tree_view.size.y + var readout_gap: float = detail.position.y - tree_bottom + var floor_gap: float = skill_menu.size.y - (status.position.y + status.size.y) + print("tree %.0fpx tall, %.0fpx to the readout, %.0fpx of panel below it" \ + % [tree_view.size.y, readout_gap, floor_gap]) + if tree_view.size.y <= 0.0: + return _fail("the tree canvas has no height") + if readout_gap < 0.0: + return _fail("the tree canvas overlaps the readout below it") + if readout_gap > GAP_TOLERANCE: + return _fail("%.0fpx of unused panel between the tree and the readout" % readout_gap) + if floor_gap > GAP_TOLERANCE: + return _fail("%.0fpx of dead panel below the readout - the removed buy button's hole" \ + % floor_gap) + + print("TEST_SKILL_MENU: PASS") + quit(0) + return true + + +func _fail(message: String) -> bool: + print("TEST_SKILL_MENU: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_skill_menu.gd.uid b/tests/test_skill_menu.gd.uid new file mode 100644 index 0000000..d259371 --- /dev/null +++ b/tests/test_skill_menu.gd.uid @@ -0,0 +1 @@ +uid://daagdu7tybuva diff --git a/ui/SkillTreeView.gd b/ui/SkillTreeView.gd index b1992d6..2f4230f 100644 --- a/ui/SkillTreeView.gd +++ b/ui/SkillTreeView.gd @@ -13,6 +13,14 @@ extends Control # before the menu can act on them, which is the bug that made the formation # editor's drag silently do nothing. Everything in this menu must stay # mouse_filter = IGNORE. +# +# #93: a mouse is a first-class way to drive this menu, not something +# emulation turns into touches. The project used to set +# emulate_touch_from_mouse, which made every click arrive here as an +# InputEventScreenTouch for free; with that off, a menu that only reads +# touch events is simply dead under a mouse - which is what the report was. +# So the press and the pan are the behaviour, and touch and mouse are two +# ways in to them. signal node_picked(id: String) @@ -29,6 +37,9 @@ const LOCKED_PULSE: float = 0.5 @export var node_radius: float = 46.0 @export var row_spacing: float = 250.0 +# One wheel notch: about a third of the default row spacing, so a notch is a +# clear step without skipping a whole depth of the tree. +const WHEEL_STEP: float = 90.0 # #83: a second tap on the node already selected buys it. The separate buy # button is gone - it sat at the bottom of the panel, a long way from the node @@ -93,13 +104,43 @@ func _state_color(node: Dictionary) -> Color: # Returns true when the press landed on a node, so the caller knows the # touch was consumed. func handle_touch(event: InputEventScreenTouch) -> bool: + return handle_press(event.pressed, event.position) + + +func handle_drag(event: InputEventScreenDrag) -> bool: + return handle_pan(event.relative) + + +# A left click is the same gesture as a tap. Other buttons are left alone so +# they stay available for whatever else wants them. +func handle_mouse_button(event: InputEventMouseButton) -> bool: + if event.button_index == MOUSE_BUTTON_WHEEL_UP and event.pressed: + return handle_pan(Vector2(0.0, WHEEL_STEP)) + if event.button_index == MOUSE_BUTTON_WHEEL_DOWN and event.pressed: + return handle_pan(Vector2(0.0, -WHEEL_STEP)) + if event.button_index != MOUSE_BUTTON_LEFT: + return false + return handle_press(event.pressed, event.position) + + +# Only while the button is held. A touch drag cannot arrive without a finger +# on the glass, but the mouse moves across the menu the whole time it is +# open, and panning the tree under a hovering pointer would make the menu +# impossible to read. +func handle_mouse_motion(event: InputEventMouseMotion) -> bool: + if not _dragging: + return false + return handle_pan(event.relative) + + +func handle_press(pressed: bool, at: Vector2) -> bool: if not visible: return false - if not event.pressed: + if not pressed: _dragging = false return true _dragging = true - var local: Vector2 = event.position - global_position + var local: Vector2 = at - global_position for node in GameState.SKILL_TREE: if local.distance_to(node_screen_pos(node)) <= node_radius * 1.25: var confirming: bool = selected_id == node.id @@ -112,12 +153,12 @@ func handle_touch(event: InputEventScreenTouch) -> bool: return false -func handle_drag(event: InputEventScreenDrag) -> bool: +func handle_pan(relative: Vector2) -> bool: if not visible: return false # Dragging a finger upward reveals higher research; dragging downward # returns to the root. Clamp hard so the canvas never leaves an empty gap. - scroll_y = clampf(scroll_y - event.relative.y, 0.0, _max_scroll()) + scroll_y = clampf(scroll_y - relative.y, 0.0, _max_scroll()) queue_redraw() return true diff --git a/world/Main.tscn b/world/Main.tscn index 301a200..7f9d017 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -2087,7 +2087,7 @@ anchor_bottom = 1.0 offset_left = 24.0 offset_top = 296.0 offset_right = -24.0 -offset_bottom = -434.0 +offset_bottom = -280.0 script = ExtResource("21") [node name="DetailLabel" type="Label" parent="UI/SkillMenu"] @@ -2096,9 +2096,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -426.0 +offset_top = -272.0 offset_right = -32.0 -offset_bottom = -306.0 +offset_bottom = -152.0 theme_override_font_sizes/font_size = 34 autowrap_mode = 2 horizontal_alignment = 1 @@ -2111,9 +2111,9 @@ anchor_top = 1.0 anchor_right = 0.5 anchor_bottom = 1.0 offset_left = -64.0 -offset_top = -300.0 +offset_top = -146.0 offset_right = 76.0 -offset_bottom = -236.0 +offset_bottom = -82.0 script = ExtResource("19") digit_height = 52.0 @@ -2123,9 +2123,9 @@ anchor_right = 1.0 anchor_top = 1.0 anchor_bottom = 1.0 offset_left = 32.0 -offset_top = -230.0 +offset_top = -76.0 offset_right = -32.0 -offset_bottom = -178.0 +offset_bottom = -24.0 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 horizontal_alignment = 1