# UiManager (P1) —— 窗口栈:打开 / 关闭 / 置顶 / ESC 关顶层 / modal 变暗 / 标题栏拖动。 # # var ui := preload("res://ui/ui_manager.gd").new() # add_child(ui) # ui.locale = func(k): return Locale.t(k) # var w := ui.open_script("res://../assets/uiscript/uiscript/systemdialog.py", assets_root) # w.nodes["close_button"].pressed.connect(func(): ui.close(w.root)) # # open_script 返回 { root: Control, nodes: {name: Control} }(同 UiBuild.build)。 extends CanvasLayer const UiScript = preload("res://ui/uiscript.gd") const UiBuild = preload("res://ui/ui_build.gd") const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.gd") signal window_opened(root: Control) signal window_closed(root: Control) var locale: Callable var screen := Vector2i(1920, 1080) var mobile_mode := false var mobile_screen := Vector2i(844, 390) var _root: Control # 全屏容器 var _dim: ColorRect # modal 变暗层 var _mobile_blocker: ColorRect var _stack: Array[Control] = [] var _mobile_external: Array[Dictionary] = [] var _mobile_modals: Array[Node] = [] var _mobile_saved: Dictionary = {} var _mobile_close_callbacks: Dictionary = {} var _last_mobile_area := Rect2(-1, -1, 0, 0) var _drag_win: Control = null var _drag_from := Vector2.ZERO func _ready() -> void: layer = 10 _root = Control.new() _root.set_anchors_preset(Control.PRESET_FULL_RECT) _root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_root) _dim = ColorRect.new() _dim.color = Color(0, 0, 0, 0.45) _dim.set_anchors_preset(Control.PRESET_FULL_RECT) _dim.visible = false _root.add_child(_dim) _mobile_blocker = ColorRect.new() _mobile_blocker.name = "MobileWindowBlocker" _mobile_blocker.color = Color(0, 0, 0, 0) _mobile_blocker.set_anchors_preset(Control.PRESET_FULL_RECT) _mobile_blocker.mouse_filter = Control.MOUSE_FILTER_STOP _mobile_blocker.visible = false _root.add_child(_mobile_blocker) set_process_input(true) set_process_unhandled_input(true) set_process(true) ## Select the presentation policy without changing any feature controller. ## Mobile windows are rebuilt with the landscape design resolution and hosted ## inside the safe area; desktop keeps the historical 1920x1080 parser space. func set_mobile_mode(enabled: bool) -> void: mobile_mode = enabled if enabled: screen = mobile_screen layer = 30 _last_mobile_area = Rect2(-1, -1, 0, 0) else: screen = Vector2i(1920, 1080) layer = 10 _last_mobile_area = Rect2(-1, -1, 0, 0) if not enabled: for record in _mobile_external.duplicate(): var root: Control = record.get("root") if root and is_instance_valid(root): _dismiss_external(root) _restack() func track_mobile_window(root: Control, title := "", close_cb := Callable()) -> void: if root == null: return for record in _mobile_external: if record.get("root") == root: record["title"] = title record["close_cb"] = close_cb return _mobile_external.append({"root": root, "title": title, "close_cb": close_cb}) if mobile_mode and root.visible: _sync_mobile_windows() ## Register a native Window/ConfirmationDialog that is created outside a ## feature's hosted Control. It remains owned by the feature, but the mobile ## manager can block world input and make Android back press its cancel action ## first. This avoids moving native dialogs into a Control tree. func track_mobile_modal(popup: Node) -> void: if popup == null: return if not _mobile_modals.has(popup): _mobile_modals.append(popup) popup.set_meta("mobile_modal", true) MobileWindowHost.wire_buttons(popup) func reflow_mobile_window(root: Control) -> void: if not mobile_mode or root == null or not is_instance_valid(root): return if not _stack.has(root): return MobileWindowHost.install(root, _mobile_area(), _window_title(root), Callable(self, "_close_hosted_window").bind(root)) _restack() # --- open / close -------------------------------------------------------- func build_script(path: String, assets_root: String) -> Dictionary: var us := UiScript.new() us.screen = screen if locale.is_valid(): us.locale = locale var spec := us.parse_file(path) if spec.is_empty(): push_warning("UiManager: parse failed (%s): %s" % [path, us.last_error]) return {"root": Control.new(), "nodes": {}} return UiBuild.build(spec, assets_root) # 解析 + 构建 + 打开,返回 { root, nodes } func open_script(path: String, assets_root: String, modal := false) -> Dictionary: var r := build_script(path, assets_root) open(r.root, modal) return r func open(win: Control, modal := false) -> void: if win.get_parent() == null: _root.add_child(win) win.set_meta("modal", modal) if mobile_mode: MobileWindowHost.install(win, _mobile_area(), _window_title(win), Callable(self, "_close_hosted_window").bind(win)) else: _wire_drag(win) _stack.erase(win) _stack.append(win) _restack() window_opened.emit(win) func close(win: Control) -> void: if win == null: return if bool(win.get_meta("mobile_external", false)): var cb: Callable = _mobile_close_callbacks.get(win, Callable()) if cb.is_valid() and win.visible: cb.call() else: _dismiss_external(win) return _stack.erase(win) if win.get_parent(): win.get_parent().remove_child(win) win.queue_free() _restack() window_closed.emit(win) func close_top() -> bool: if _close_mobile_popup(): return true if _stack.is_empty(): return false var win := _stack[-1] if bool(win.get_meta("mobile_external", false)): var cb: Callable = _mobile_close_callbacks.get(win, Callable()) if cb.is_valid(): cb.call() else: _dismiss_external(win) else: close(win) return true func top() -> Control: return _stack[-1] if not _stack.is_empty() else null # wndMgr 的输入边界:鼠标不能从打开的窗口下方穿透到世界;模态确认框(LockWindow) # 只允许 ESC 回到窗口栈。键盘按 CWindowManager::RunKeyDown:非模态窗口不截键。 func blocks_game_input(event: InputEvent) -> bool: var popup := _mobile_modal_popup() if popup != null: return not (event is InputEventKey and event.keycode == KEY_ESCAPE) if _stack.is_empty(): return false var top_window: Control = _stack[-1] if top_window == null or not is_instance_valid(top_window) or not top_window.visible: return false if bool(top_window.get_meta("modal", false)): return not (event is InputEventKey and event.keycode == KEY_ESCAPE) if mobile_mode and event is InputEventScreenTouch: return true if mobile_mode and event is InputEventScreenDrag: return true if event is InputEventMouse: return top_window.get_global_rect().has_point(event.position) # 键盘:没有 LockWindow 时键先给 ActiveWindow(输入框——Godot 里由获得焦点的控件在 # gui_input 中自行吃掉),其余落到 game.py OnKeyDown,所以技能窗 / 背包开着时 # 快捷栏 1-4 / F1-F4、Space 普攻和开关窗键都照常生效。 return false func _input(event: InputEvent) -> void: # Keyboard events do not automatically enter Control.gui_input. Consume # blocked keys here, before PlayerController/GameCamera receive them through # _unhandled_input; mouse events remain available to child controls. if event is InputEventKey and blocks_game_input(event): get_viewport().set_input_as_handled() func _process(_delta: float) -> void: if mobile_mode: var area := _mobile_area() if not area.is_equal_approx(_last_mobile_area): _last_mobile_area = area _reflow_mobile_windows(area) _sync_mobile_windows() # Password/confirmation dialogs are created by feature controllers after # their window is hosted. Wire their buttons too, including dialogs that # appear as siblings under UiManager's root. MobileWindowHost.wire_buttons(_root) for win in _stack: if win and is_instance_valid(win): MobileWindowHost.wire_buttons(win) for popup in _mobile_modals.duplicate(): if popup == null or not is_instance_valid(popup): _mobile_modals.erase(popup) else: MobileWindowHost.wire_buttons(popup) func _reflow_mobile_windows(area: Rect2) -> void: # Safe areas can change while the logical viewport stays the same (for # example when a keyboard, call bar or system gesture inset appears). Keep # every already-open feature inside the new area instead of waiting for a # full window resize. for win in _stack.duplicate(): if win == null or not is_instance_valid(win) or not win.visible: continue MobileWindowHost.install(win, area, _window_title(win), Callable(self, "_close_hosted_window").bind(win)) func _sync_mobile_windows() -> void: for record in _mobile_external: var root: Control = record.get("root") if root == null or not is_instance_valid(root): continue if root.visible and not _stack.has(root): _present_external(root, String(record.get("title", "")), record.get("close_cb", Callable())) elif not root.visible and _stack.has(root): _dismiss_external(root) func _mobile_area() -> Rect2: return MobileWindowHost.safe_rect(get_viewport()) func _window_title(win: Control) -> String: if win == null: return "" var title := String(win.get_meta("mobile_title", "")) if title != "": return title return String(win.name) if win.name != "Control" else "功能" func _close_hosted_window(win: Control) -> void: close(win) func _present_external(root: Control, title: String, close_cb: Callable) -> void: if root == null or not is_instance_valid(root) or _stack.has(root): return var parent := root.get_parent() if parent == null: return var saved := { "parent": parent, "position": root.position, "size": root.size, "scale": root.scale, "pivot": root.pivot_offset, "anchor_left": root.anchor_left, "anchor_top": root.anchor_top, "anchor_right": root.anchor_right, "anchor_bottom": root.anchor_bottom, } _mobile_saved[root] = saved parent.remove_child(root) _root.add_child(root) root.set_meta("mobile_external", true) root.set_meta("modal", false) _mobile_close_callbacks[root] = close_cb MobileWindowHost.install(root, _mobile_area(), title, close_cb) _stack.append(root) _restack() func _dismiss_external(root: Control) -> void: if root == null: return _stack.erase(root) _mobile_close_callbacks.erase(root) MobileWindowHost.uninstall(root) var saved: Dictionary = _mobile_saved.get(root, {}) if root.get_parent() == _root: _root.remove_child(root) var parent: Node = saved.get("parent") if parent and is_instance_valid(parent): parent.add_child(root) if not saved.is_empty(): root.position = saved.get("position", root.position) root.size = saved.get("size", root.size) root.scale = saved.get("scale", Vector2.ONE) root.pivot_offset = saved.get("pivot", Vector2.ZERO) root.anchor_left = float(saved.get("anchor_left", root.anchor_left)) root.anchor_top = float(saved.get("anchor_top", root.anchor_top)) root.anchor_right = float(saved.get("anchor_right", root.anchor_right)) root.anchor_bottom = float(saved.get("anchor_bottom", root.anchor_bottom)) _mobile_saved.erase(root) root.set_meta("mobile_external", false) _restack() window_closed.emit(root) func _restack() -> void: var any_modal := false for i in _stack.size(): var w := _stack[i] w.move_to_front() if bool(w.get_meta("modal", false)): any_modal = true _dim.visible = any_modal _mobile_blocker.visible = mobile_mode and not _stack.is_empty() if _mobile_blocker: _root.move_child(_mobile_blocker, 0) if any_modal: # 变暗层紧贴在最顶层 modal 之下 _dim.move_to_front() _stack[-1].move_to_front() # --- ESC --------------------------------------------------------------- func _unhandled_input(e: InputEvent) -> void: if e is InputEventKey and e.pressed and e.keycode == KEY_ESCAPE: if _close_mobile_popup(): get_viewport().set_input_as_handled() return if close_top(): get_viewport().set_input_as_handled() func _mobile_modal_popup() -> Node: if not mobile_mode or _root == null: return null for popup in _mobile_modals: if popup and is_instance_valid(popup) and _node_visible(popup): return popup for node in _root.find_children("*", "", true, false): if (node is Window or bool(node.get_meta("mobile_modal", false))) and _node_visible(node): return node return null func _node_visible(node: Node) -> bool: if node is CanvasItem: return (node as CanvasItem).visible if node is Window: return (node as Window).visible return false func _close_mobile_popup() -> bool: var popup := _mobile_modal_popup() if popup == null: return false var close_cb: Variant = popup.get_meta("mobile_modal_close", Callable()) if close_cb is Callable and close_cb.is_valid(): close_cb.call() return true # ConfirmationDialog owners generally use the canceled signal to clear # pending state. Drive its cancel button when available; plain dialogs can # safely be hidden on back. if popup is ConfirmationDialog and popup.has_method("get_cancel_button"): var cancel = popup.call("get_cancel_button") if cancel is BaseButton and not cancel.disabled: cancel.pressed.emit() return true popup.hide() return true # --- 标题栏拖动 -------------------------------------------------------- func _wire_drag(win: Control) -> void: var handle := _find_drag_handle(win) if handle == null or handle.has_meta("_drag_wired"): return handle.set_meta("_drag_wired", true) handle.mouse_filter = Control.MOUSE_FILTER_STOP handle.gui_input.connect(func(ev: InputEvent): _on_handle_input(win, ev)) func _find_drag_handle(win: Control) -> Control: # 优先 titlebar;否则 board_with_titlebar 的顶部条;否则整个 window 根 for n in win.find_children("*", "", true, false): if n is Control and (n.get_meta("is_titlebar", false) or n.get_meta("titlebar_h", 0) > 0): return n return win func _on_handle_input(win: Control, ev: InputEvent) -> void: if ev is InputEventMouseButton and ev.button_index == MOUSE_BUTTON_LEFT: if ev.pressed: _drag_win = win _drag_from = win.get_global_mouse_position() - win.position open(win) # 置顶 else: _drag_win = null elif ev is InputEventMouseMotion and _drag_win == win: win.position = win.get_global_mouse_position() - _drag_from