diff --git a/scripts/game/flight_hud.gd b/scripts/game/flight_hud.gd index 504d57d..5fc05ac 100644 --- a/scripts/game/flight_hud.gd +++ b/scripts/game/flight_hud.gd @@ -8,6 +8,7 @@ const RENDER_LAYERS := preload("res://scripts/ui/render_layers.gd") @onready var inventory_button: Button = $HUDRoot/ShipEquipmentPanel/Contents/InventoryButton @onready var inventory_panel: Control = $HUDRoot/ShipInventoryPanel +var _window_manager: Node var _energy_bank_button: Button var _debug_expanded := false var _ability_buttons: Dictionary = {} @@ -86,6 +87,7 @@ func _create_window(node_name: String, title: String, dimensions: Vector2) -> Pa contents.add_theme_constant_override("separation", 12) window.add_child(contents) var header := HBoxContainer.new() + header.name = "Header" contents.add_child(header) var label := Label.new() label.text = title @@ -100,6 +102,7 @@ func _create_window(node_name: String, title: String, dimensions: Vector2) -> Pa close.pressed.connect(window.hide) header.add_child(close) window.hide() + _window_manager.call("register_window", window, window, header) return window func _toggle_window(window: PanelContainer) -> void: @@ -125,10 +128,15 @@ func _ready() -> void: debug_info.add_theme_font_size_override("font_size", 10) # The display font has unusually tall vertical metrics; keep debug rows compact. debug_info.add_theme_constant_override("line_spacing", mini(0, 13 - ceili(debug_info.get_theme_font("font").get_height(10)))) + _window_manager = Node.new() + _window_manager.name = "FloatingWindows" + _window_manager.set_script(preload("res://scripts/ui/floating_window_manager.gd")) + $HUDRoot.add_child(_window_manager) + _window_manager.connect("interaction_started", player.get_node("WeaponController").call.bind("block_ui_mouse_input")) _create_sidebar() _inventory_clip = Control.new() _inventory_clip.name = "InventoryDrawer" - _inventory_clip.clip_contents = true + _inventory_clip.clip_contents = false _inventory_clip.mouse_filter = Control.MOUSE_FILTER_IGNORE $HUDRoot.add_child(_inventory_clip) inventory_panel.reparent(_inventory_clip, false) @@ -137,6 +145,7 @@ func _ready() -> void: inventory_panel.size = Vector2(560, inventory_panel.get_combined_minimum_size().y) inventory_panel.scale = Vector2.ONE * EQUIPMENT_UI_SCALE inventory_panel.set("drawer_width", inventory_panel.size.x * EQUIPMENT_UI_SCALE) + _window_manager.call("register_window", _inventory_clip, inventory_panel, inventory_panel.get_node("Contents/Header")) $HUDRoot.resized.connect(func() -> void: _layout_sidebar.call_deferred()) _create_ability_bar() debug_toggle.pressed.connect(_toggle_debug) @@ -229,6 +238,7 @@ func _create_sidebar() -> void: _cockpit.set("player", player) _cockpit.set("game", get_parent()) $HUDRoot.add_child(_cockpit) + _window_manager.call("register_window", _sidebar, _sidebar, equipment.get_node("Contents/Title")) _create_navigation() _layout_debug() @@ -260,10 +270,10 @@ func _toggle_sidebar() -> void: _sidebar.show() else: close_inventory() - _sidebar_tween = create_tween() - _sidebar_tween.tween_property(_sidebar, "position:x", $HUDRoot.size.x - _sidebar.size.x * EQUIPMENT_UI_SCALE - 8 if _sidebar_open else $HUDRoot.size.x, 0.25).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT if _sidebar_open else Tween.EASE_IN) - if not _sidebar_open: - _sidebar_tween.tween_callback(_sidebar.hide) + _sidebar.visible = _sidebar_open + if _sidebar_open: + _window_manager.call("focus_window", _sidebar) + _layout_sidebar() func _layout_sidebar() -> void: if not is_instance_valid(_sidebar) or not is_instance_valid(_cockpit): @@ -283,7 +293,10 @@ func _layout_sidebar() -> void: if is_instance_valid(_captain_skills_ui): _captain_skills_ui.call_deferred("_layout") _layout_debug() - _sidebar.position = Vector2($HUDRoot.size.x - _sidebar.size.x * EQUIPMENT_UI_SCALE - 8 if _sidebar_open else $HUDRoot.size.x, 136) + if not _sidebar.has_meta("floating_window_dragged"): + _sidebar.position = Vector2($HUDRoot.size.x - _sidebar.size.x * EQUIPMENT_UI_SCALE - 8, 136) + else: + _window_manager.call("clamp_window", _sidebar) _sidebar.visible = _sidebar_open _inventory_clip.size = inventory_panel.size * EQUIPMENT_UI_SCALE _layout_inventory() @@ -291,6 +304,9 @@ func _layout_sidebar() -> void: func _layout_inventory() -> void: if not is_instance_valid(_inventory_clip) or not is_instance_valid(_sidebar): return + if _inventory_clip.has_meta("floating_window_dragged"): + _window_manager.call("clamp_window", _inventory_clip) + return var equipment := _sidebar.get_node("Scroll/Contents/ShipEquipmentPanel") as Control _inventory_clip.position = Vector2(maxf(12.0, _sidebar.position.x - _inventory_clip.size.x - 8), minf(equipment.global_position.y, maxf(8.0, $HUDRoot.size.y - inventory_panel.size.y * EQUIPMENT_UI_SCALE - 8))) diff --git a/scripts/game/weapon_controller.gd b/scripts/game/weapon_controller.gd index a6c88ff..54079c3 100644 --- a/scripts/game/weapon_controller.gd +++ b/scripts/game/weapon_controller.gd @@ -39,6 +39,11 @@ func _ensure_input_action(action_name: StringName, button: MouseButton) -> void: if not InputMap.action_has_event(action_name, event): InputMap.action_add_event(action_name, event) +func block_ui_mouse_input() -> void: + _fire_buttons_down.fill(false) + _fire_just_pressed.fill(false) + _drag_fire_blocked = true + func _unhandled_input(event: InputEvent) -> void: if not event is InputEventMouseButton: return diff --git a/scripts/ui/captain_skills_ui.gd b/scripts/ui/captain_skills_ui.gd index 253d852..a81fd65 100644 --- a/scripts/ui/captain_skills_ui.gd +++ b/scripts/ui/captain_skills_ui.gd @@ -24,6 +24,7 @@ func _ready() -> void: add_child(_screen) _screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _screen.hide() + get_parent().get_node("HUDRoot/FloatingWindows").set("modal_overlay", _screen) var dim := ColorRect.new() dim.color = Color(0.015, 0.025, 0.045, 0.9) _screen.add_child(dim) @@ -99,6 +100,7 @@ func _build_management() -> void: child.queue_free() var choosing := not _captain.skill_offers.is_empty() var heading := HBoxContainer.new() + heading.name = "Header" _contents.add_child(heading) var title := _label("AWANS KAPITANA" if choosing else "UMIEJĘTNOŚCI KAPITANA", 25, Color("91dbea")) title.size_flags_horizontal = Control.SIZE_EXPAND_FILL @@ -143,6 +145,9 @@ func _build_management() -> void: if owned.get_child_count() == 0: _contents.add_child(_label("Nie masz jeszcze umiejętności. To początek Twojej specjalizacji.", 14, Color("91aabd"))) _build_loadout() + var manager := get_parent().get_node("HUDRoot/FloatingWindows") + manager.call("register_window", _panel, _panel, heading) + manager.call("focus_window", _panel) _layout.call_deferred() func _build_loadout() -> void: @@ -314,7 +319,10 @@ func _layout() -> void: var ui_scale := minf(1.0, minf((viewport_size.x - 32) / base_size.x, (viewport_size.y - 32) / base_size.y)) _panel.size = base_size _panel.scale = Vector2.ONE * ui_scale - _panel.position = (viewport_size - base_size * ui_scale) * 0.5 + if _panel.has_meta("floating_window_dragged"): + get_parent().get_node("HUDRoot/FloatingWindows").call("clamp_window", _panel) + else: + _panel.position = (viewport_size - base_size * ui_scale) * 0.5 var cockpit := get_parent().get("_cockpit") as Control if is_instance_valid(cockpit): _bar.position = cockpit.position + Vector2(0, -40) diff --git a/scripts/ui/floating_window_manager.gd b/scripts/ui/floating_window_manager.gd new file mode 100644 index 0000000..0af2dfe --- /dev/null +++ b/scripts/ui/floating_window_manager.gd @@ -0,0 +1,133 @@ +extends Node + +signal interaction_started + +const BASE_Z := 100 +const Z_STEP := 32 +var _windows: Array[Dictionary] = [] +var _active: Control +var _dragged: Control +var _drag_offset := Vector2.ZERO +var modal_overlay: Control + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + get_viewport().size_changed.connect(_clamp_all) + +func register_window(root: Control, panel: Control, header: Control) -> void: + for entry in _windows: + if entry["root"] == root: + entry["header"] = header + _prepare_header(header) + return + panel.mouse_filter = Control.MOUSE_FILTER_STOP + _prepare_header(header) + var original := panel.get_theme_stylebox("panel").duplicate() as StyleBoxFlat + _windows.append({"root": root, "panel": panel, "header": header, "style": original}) + root.visibility_changed.connect(func() -> void: + if root.is_visible_in_tree() and panel.is_visible_in_tree(): + focus_window.call_deferred(root) + elif _dragged == root: + _dragged = null + ) + _update_order() + +func _prepare_header(header: Control) -> void: + header.custom_minimum_size.y = maxf(header.custom_minimum_size.y, 28.0) + header.mouse_default_cursor_shape = Control.CURSOR_DRAG + header.tooltip_text = "Przeciągnij, aby przesunąć okno" + +func focus_window(root: Control) -> void: + if not is_instance_valid(root) or not root.is_visible_in_tree(): + return + for index in _windows.size(): + if _windows[index]["root"] == root: + var entry := _windows[index] + _windows.remove_at(index) + _windows.append(entry) + root.get_parent().move_child(root, root.get_parent().get_child_count() - 1) + _active = root + _update_order() + return + +func _update_order() -> void: + for index in _windows.size(): + var entry := _windows[index] + if not is_instance_valid(entry["root"]): + continue + var root: Control = entry["root"] + root.z_index = BASE_Z + index * Z_STEP + var panel: Control = entry["panel"] + var style: StyleBoxFlat = entry["style"] + if style != null: + var current := style.duplicate() as StyleBoxFlat + if root == _active: + current.border_color = Color("88c9e2") + current.shadow_color = Color(0, 0, 0, 0.4) + current.shadow_size = 8 + panel.add_theme_stylebox_override("panel", current) + +func window_at(point: Vector2) -> Control: + for index in range(_windows.size() - 1, -1, -1): + var entry := _windows[index] + var root: Control = entry["root"] + var panel: Control = entry["panel"] + if not is_instance_valid(root) or not is_instance_valid(panel) or not panel.is_visible_in_tree(): + continue + if is_instance_valid(modal_overlay) and modal_overlay.is_visible_in_tree() and not modal_overlay.is_ancestor_of(root): + continue + if panel.get_global_rect().has_point(point): + return root + return null + +func _header_at(root: Control, point: Vector2) -> bool: + for entry in _windows: + if entry["root"] != root: + continue + var header: Control = entry["header"] + if not is_instance_valid(header) or not header.get_global_rect().has_point(point): + return false + for button in header.find_children("*", "BaseButton", true, false): + if button.is_visible_in_tree() and button.get_global_rect().has_point(point): + return false + return true + return false + +func _input(event: InputEvent) -> void: + if event is InputEventMouseButton: + if event.button_index == MOUSE_BUTTON_LEFT and not event.pressed and is_instance_valid(_dragged): + _dragged = null + get_viewport().set_input_as_handled() + return + if not event.pressed or get_viewport().gui_is_dragging(): + return + var target := window_at(event.position) + if target == null: + return + focus_window(target) + interaction_started.emit() + if event.button_index == MOUSE_BUTTON_LEFT and _header_at(target, event.position): + _dragged = target + _drag_offset = event.position - target.global_position + target.set_meta("floating_window_dragged", true) + get_viewport().set_input_as_handled() + elif event is InputEventMouseMotion and is_instance_valid(_dragged): + if not _dragged.is_visible_in_tree(): + _dragged = null + return + _dragged.global_position = event.position - _drag_offset + clamp_window(_dragged) + get_viewport().set_input_as_handled() + +func clamp_window(root: Control) -> void: + if not is_instance_valid(root) or not root.is_inside_tree(): + return + var viewport_size := get_viewport().get_visible_rect().size + var dimensions := root.size * root.get_global_transform().get_scale().abs() + root.global_position = root.global_position.clamp(Vector2(8, 8), (viewport_size - dimensions - Vector2(8, 8)).max(Vector2(8, 8))) + +func _clamp_all() -> void: + for entry in _windows: + var root: Control = entry["root"] + if is_instance_valid(root) and root.has_meta("floating_window_dragged"): + clamp_window(root) diff --git a/scripts/ui/floating_window_manager.gd.uid b/scripts/ui/floating_window_manager.gd.uid new file mode 100644 index 0000000..82df2ac --- /dev/null +++ b/scripts/ui/floating_window_manager.gd.uid @@ -0,0 +1 @@ +uid://dg15v55ayqn22 diff --git a/scripts/ui/planet_services_menu.gd b/scripts/ui/planet_services_menu.gd index 3e68630..4af9b3d 100644 --- a/scripts/ui/planet_services_menu.gd +++ b/scripts/ui/planet_services_menu.gd @@ -17,6 +17,7 @@ var _missile_button: Button var _gamma_buy: Button func _ready() -> void: + get_parent().get_node("FloatingWindows").call("register_window", self, panel, $Center/Panel/Contents/Title) _gamma_buy = Button.new() _gamma_buy.name = "BuyIrradiatedAmmunition" _gamma_buy.custom_minimum_size.y = 36 @@ -69,6 +70,9 @@ func _process(_delta: float) -> void: _position_at_planet() func _position_at_planet() -> void: + if has_meta("floating_window_dragged"): + get_parent().get_node("FloatingWindows").call("clamp_window", self) + return if not is_instance_valid(planet): return var canvas := get_viewport().get_canvas_transform() diff --git a/scripts/ui/ship_inventory_panel.gd b/scripts/ui/ship_inventory_panel.gd index 18eee30..3167f59 100644 --- a/scripts/ui/ship_inventory_panel.gd +++ b/scripts/ui/ship_inventory_panel.gd @@ -108,15 +108,7 @@ func _add_resource_card(row: HBoxContainer, data: Array) -> void: func toggle_drawer() -> void: _set_drawer_open(not _drawer_open) -func close_drawer(immediately: bool = false) -> void: - if immediately: - if _slide_tween != null and _slide_tween.is_running(): - _slide_tween.kill() - _drawer_open = false - GameState.hud_panel_state["inventory_open"] = false - position.x = drawer_width - hide() - return +func close_drawer(_immediately: bool = false) -> void: _set_drawer_open(false) func _set_drawer_open(value: bool) -> void: @@ -124,10 +116,9 @@ func _set_drawer_open(value: bool) -> void: _slide_tween.kill() _drawer_open = value GameState.hud_panel_state["inventory_open"] = value - if value and not visible: - position = Vector2(drawer_width, 0) - show() - _slide_tween = create_tween() - _slide_tween.tween_property(self, "position:x", 0.0 if value else drawer_width, 0.25).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT if value else Tween.EASE_IN) - if not value: - _slide_tween.tween_callback(hide) + position = Vector2.ZERO + visible = value + if value: + var manager := get_parent().get_parent().get_node_or_null("FloatingWindows") + if manager != null: + manager.call("focus_window", get_parent()) diff --git a/tests/levels/asteroid_debris_geometry_test.gd.uid b/tests/levels/asteroid_debris_geometry_test.gd.uid new file mode 100644 index 0000000..0aeadce --- /dev/null +++ b/tests/levels/asteroid_debris_geometry_test.gd.uid @@ -0,0 +1 @@ +uid://bc7qva7gpdo01 diff --git a/tests/levels/exploration_death_restart_test.gd.uid b/tests/levels/exploration_death_restart_test.gd.uid new file mode 100644 index 0000000..8739cad --- /dev/null +++ b/tests/levels/exploration_death_restart_test.gd.uid @@ -0,0 +1 @@ +uid://d2cbqokcr60c5 diff --git a/tests/ships/floating_windows_test.gd b/tests/ships/floating_windows_test.gd new file mode 100644 index 0000000..37af6e2 --- /dev/null +++ b/tests/ships/floating_windows_test.gd @@ -0,0 +1,122 @@ +extends Node + +const GAME := preload("res://scenes/game/game.tscn") + +func motion(point: Vector2, relative: Vector2 = Vector2.ZERO) -> void: + var event := InputEventMouseMotion.new() + event.position = point + event.global_position = point + event.relative = relative + event.button_mask = MOUSE_BUTTON_MASK_LEFT if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) else 0 + get_viewport().push_input(event, true) + +func mouse(point: Vector2, pressed: bool) -> void: + var event := InputEventMouseButton.new() + event.position = point + event.global_position = point + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0 + get_viewport().push_input(event, true) + +func click(point: Vector2) -> void: + motion(point) + mouse(point, true) + mouse(point, false) + +func drag(header: Control, displacement: Vector2) -> void: + var origin := header.get_global_rect().position + Vector2(15, header.get_global_rect().size.y * 0.5) + motion(origin) + mouse(origin, true) + motion(origin + displacement, displacement) + mouse(origin + displacement, false) + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + GameState.start_new_run() + var game := GAME.instantiate() as Node2D + add_child(game) + await get_tree().process_frame + await get_tree().process_frame + game.set_physics_process(false) + var ship := game.get_node("PlayerShip") as CharacterBody2D + ship.set_physics_process(false) + ship.get_node("WeaponController").set_physics_process(false) + for enemy in get_tree().get_nodes_in_group("enemies"): + enemy.set_physics_process(false) + var hud := game.get_node("HUD") + var manager := hud.get_node("HUDRoot/FloatingWindows") + var craft: PanelContainer = hud.get("_crafting_window") + var inventory: Control = hud.get("inventory_panel") + var drawer: Control = hud.get("_inventory_clip") + var sidebar: Control = hud.get("_sidebar") + hud.call("_toggle_inventory") + craft.show() + await get_tree().process_frame + await get_tree().process_frame + # Put inventory directly behind crafting. Its salvage button occupies the same pixel. + drawer.set_meta("floating_window_dragged", true) + var recipe: Button = craft.get_node("Contents/CraftingPanel").get("_recipe_buttons")["parts"] + var point := recipe.get_global_rect().get_center() + var first_slot := inventory.get_node("Contents/CargoGrid").get_child(0) as Control + drawer.global_position += point - first_slot.get_global_rect().get_center() + manager.call("focus_window", drawer) + manager.call("focus_window", craft) + var stock := ship.get_node("ShipInventory") as ShipInventory + var item := ItemInstance.new() + item.definition = preload("res://resources/items/plasma_cannon.tres") + assert(stock.add_item(item)) + GameState.captain.minerals.merge({"iron": 2, "titanium": 2, "aluminium": 2}, true) + GameState.captain.emit_changed() + await get_tree().process_frame + assert(manager.call("window_at", point) == craft) + click(point) + assert(GameState.captain.parts == 1.0, "Top crafting button must receive the click") + assert(stock.cargo[0] == item, "Underlying inventory must not consume the click") + assert(craft.z_index > drawer.z_index) + assert(craft.get_index() > drawer.get_index()) + # Dragging a title moves its window; content clicks do not start a window drag. + var before := craft.global_position + drag(craft.get_node("Contents/Header"), Vector2(-80, -60)) + assert(craft.global_position.is_equal_approx(before + Vector2(-80, -60))) + await get_tree().process_frame + assert(craft.global_position.is_equal_approx(before + Vector2(-80, -60))) + # The exposed inventory header can activate and raise the lower window. + drawer.global_position = Vector2(70, 80) + manager.call("focus_window", craft) + var inv_header := inventory.get_node("Contents/Header") as Control + click(inv_header.get_global_rect().position + Vector2(10, 8)) + assert(drawer.z_index > craft.z_index and drawer.get_index() > craft.get_index()) + before = drawer.global_position + drag(inv_header, Vector2(100, 80)) + assert(drawer.global_position.is_equal_approx(before + Vector2(100, 80))) + hud.call("_layout_inventory") + assert(drawer.global_position.is_equal_approx(before + Vector2(100, 80))) + # Close buttons do not turn into drag handles; reopening preserves the moved position. + click(inventory.get_node("Contents/Header/CloseButton").get_global_rect().get_center()) + assert(not inventory.visible) + inventory.call("_set_drawer_open", true) + assert(drawer.global_position.is_equal_approx(before + Vector2(100, 80))) + # Sidebar dragging survives equipment relayout and viewport clamping. + manager.call("focus_window", sidebar) + before = sidebar.global_position + drag(sidebar.get_node("Scroll/Contents/ShipEquipmentPanel/Contents/Title"), Vector2(-100, -50)) + hud.call("_layout_sidebar") + assert(sidebar.global_position.is_equal_approx(before + Vector2(-100, -50))) + # The captain modal remains draggable while paused and blocks windows below it. + var skills: CanvasLayer = hud.get("_captain_skills_ui") + skills.call("open_management") + await get_tree().process_frame + await get_tree().process_frame + assert(get_tree().paused) + var panel: Control = skills.get("_panel") + before = panel.global_position + drag((skills.get("_contents") as Control).get_node("Header"), Vector2(50, 30)) + assert(panel.global_position.is_equal_approx(before + Vector2(50, 30))) + skills.call("_layout") + assert(panel.global_position.is_equal_approx(before + Vector2(50, 30))) + skills.call("_close") + assert(not get_tree().paused) + assert(not ship.get_node("WeaponController").get("_fire_buttons_down")[0]) + print("FLOATING_WINDOWS_TEST: PASS (real GUI click routing over inventory, matching z/tree order, title drag, focus/raise, close/reopen, relayout persistence, paused captain modal, gameplay input blocked)") + get_tree().quit() diff --git a/tests/ships/floating_windows_test.gd.uid b/tests/ships/floating_windows_test.gd.uid new file mode 100644 index 0000000..ba773ec --- /dev/null +++ b/tests/ships/floating_windows_test.gd.uid @@ -0,0 +1 @@ +uid://dus5cwp87y35y diff --git a/tests/ships/floating_windows_test.tscn b/tests/ships/floating_windows_test.tscn new file mode 100644 index 0000000..3223daa --- /dev/null +++ b/tests/ships/floating_windows_test.tscn @@ -0,0 +1,4 @@ +[gd_scene load_steps=2 format=3] +[ext_resource type="Script" path="res://tests/ships/floating_windows_test.gd" id="1"] +[node name="FloatingWindowsTest" type="Node"] +script = ExtResource("1")