# MobileUiRoot —— the mobile presentation root. # # It owns only mobile layout, routing and touch surfaces. Existing feature # controllers are injected after GameScene has created them and remain the # source of truth for server-backed state. extends CanvasLayer const MobileHud := preload("res://ui/mobile/mobile_hud.gd") const MobileInputOverlay := preload("res://ui/mobile/mobile_input_overlay.gd") const MobileMenuDrawer := preload("res://ui/mobile/mobile_menu_drawer.gd") const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd") const DESIGN_SIZE := Vector2(844, 390) var world: Node var player: Node3D var client: Node var hud_view: Control var input_overlay: Control var menu_drawer: Control var _avatar_panel: Panel var _toast: Label var _bindings := {} var _content_root: Control var _orientation_guard: ColorRect var _orientation_label: Label var _last_viewport_size := Vector2.ZERO var _last_safe_rect := Rect2(-1, -1, 0, 0) var ui: Node func setup(metin_world: Node, local_player: Node3D, m2client: Node = null) -> void: world = metin_world player = local_player client = m2client layer = 20 _build() _layout_root() set_process(true) if client: hud_view.bind_client(client) func bind_controls(bindings: Dictionary) -> void: _bindings = bindings client = bindings.get("client", client) ui = bindings.get("ui", ui) if ui and ui.has_method("set_mobile_mode"): ui.set_mobile_mode(true) if hud_view: hud_view.bind_client(client) if input_overlay: input_overlay.bind_controls(bindings.get("player_controller"), bindings.get("net_play"), bindings.get("quickbar"), bindings.get("ground_items")) if bindings.get("party_ui") and bindings["party_ui"].has_method("set_mobile_mode"): bindings["party_ui"].set_mobile_mode(true) if bindings.get("quickbar") and bindings["quickbar"].has_method("set_mobile_mode"): bindings["quickbar"].set_mobile_mode(true) if bindings.get("skills") and bindings["skills"].has_method("set_mobile_quickbar"): bindings["skills"].set_mobile_quickbar(bindings.get("quickbar")) if bindings.get("inventory") and bindings["inventory"].has_method("set_mobile_mode"): bindings["inventory"].set_mobile_mode(true) if bindings.get("quest_log") and bindings["quest_log"].has_method("set_mobile_mode"): bindings["quest_log"].set_mobile_mode(true) if bindings.get("chat") and bindings["chat"].has_method("set_mobile_mode"): bindings["chat"].set_mobile_mode(true) for key in ["char_status_ui", "skills", "friend_ui", "guild_ui", "shop_ui", "exchange_ui", "safebox_ui", "mall_ui", "cube_ui", "private_shop_ui", "refine_ui", "love_ui", "system_option_ui", "game_option_ui", "system_menu_ui"]: var feature: Node = bindings.get(key) if feature and feature.has_method("set_mobile_mode"): feature.set_mobile_mode(true) _register_mobile_windows() var minimap: Node = bindings.get("minimap") if minimap and minimap.has_method("mount_mobile") and hud_view.has_method("get_minimap_host"): minimap.mount_mobile(hud_view.get_minimap_host()) _wire_signals() _connect_lifecycle() # NetPlay/HUD compatibility surface ------------------------------------- func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: if hud_view: hud_view.set_vitals(hp, max_hp, sp, max_sp) func set_exp(xp: int, next_xp: int) -> void: if hud_view: hud_view.set_exp(xp, next_xp) func set_level(value: int) -> void: if hud_view: hud_view.set_level(value) func set_energy(value: int, max_value: int = 100) -> void: if hud_view: hud_view.set_energy(value, max_value) func set_stamina(value: int, max_value: int) -> void: if hud_view: hud_view.set_stamina(value, max_value) func set_affects(values: Array) -> void: if hud_view: hud_view.set_affects(values) func set_target(name: String, hp_pct: int) -> void: if hud_view: hud_view.set_target(name, hp_pct) func clear_target() -> void: if hud_view: hud_view.clear_target() func set_channel(value: int) -> void: if hud_view: hud_view.set_channel(value) func set_dungeon_destination(active: bool, world_pos: Vector3) -> void: if hud_view: hud_view.set_dungeon_destination(active, world_pos) func cancel_touch_state() -> void: if input_overlay and input_overlay.has_method("cancel_all"): input_overlay.cancel_all() static func requires_landscape(viewport_size: Vector2) -> bool: return viewport_size.y > viewport_size.x and viewport_size.x > 0.0 func _process(_delta: float) -> void: _layout_root() _refresh_context_action() func _layout_root() -> void: if _content_root == null or get_viewport() == null: return var viewport_size := get_viewport().get_visible_rect().size if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: return var safe := _safe_viewport_rect(viewport_size) var portrait := requires_landscape(viewport_size) var geometry_changed := not viewport_size.is_equal_approx(_last_viewport_size) \ or not safe.is_equal_approx(_last_safe_rect) if _orientation_guard: _orientation_guard.visible = portrait if _content_root: _content_root.visible = not portrait _last_viewport_size = viewport_size _last_safe_rect = safe if portrait: # The project requests landscape on mobile. The guard is still needed # for rotation races and desktop previews so controls never collapse into # a portrait layout for one or two frames. cancel_touch_state() return if not geometry_changed: return var fit := minf(safe.size.x / DESIGN_SIZE.x, safe.size.y / DESIGN_SIZE.y) var configured := float(ProjectSettings.get_setting("mt/ui/mobile_scale", 1.0)) fit = maxf(0.1, fit * maxf(0.5, configured)) _content_root.size = DESIGN_SIZE _content_root.scale = Vector2.ONE * fit _content_root.position = safe.position + (safe.size - DESIGN_SIZE * fit) * 0.5 func _safe_viewport_rect(viewport_size: Vector2) -> Rect2: return MobileWindowHost.safe_rect_for(viewport_size, DisplayServer.get_display_safe_area()) func _refresh_context_action() -> void: var ground: Node = _bindings.get("ground_items") if ground == null or not ground.has_method("has_nearby_item"): return var available := bool(ground.has_nearby_item()) if hud_view and hud_view.has_method("set_context_action"): hud_view.set_context_action("拾取", available) func _build() -> void: _content_root = Control.new() _content_root.set_anchors_preset(Control.PRESET_TOP_LEFT) _content_root.size = DESIGN_SIZE _content_root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_content_root) hud_view = MobileHud.new() hud_view.setup() _content_root.add_child(hud_view) input_overlay = MobileInputOverlay.new() input_overlay.setup() _content_root.add_child(input_overlay) menu_drawer = MobileMenuDrawer.new() menu_drawer.setup() _content_root.add_child(menu_drawer) hud_view.avatar_pressed.connect(_on_avatar_pressed) hud_view.inventory_pressed.connect(_on_inventory_pressed) hud_view.minimap_pressed.connect(_on_minimap_pressed) hud_view.menu_pressed.connect(_on_menu_pressed) hud_view.quest_pressed.connect(_on_quest_pressed) hud_view.party_member_pressed.connect(_on_party_member_pressed) hud_view.context_action_pressed.connect(_on_context_action_pressed) menu_drawer.item_selected.connect(_on_menu_item) _build_avatar_panel(_content_root) _build_orientation_guard() func _build_orientation_guard() -> void: _orientation_guard = ColorRect.new() _orientation_guard.name = "LandscapeRequired" _orientation_guard.color = Color(0.015, 0.025, 0.045, 0.98) _orientation_guard.set_anchors_preset(Control.PRESET_FULL_RECT) _orientation_guard.mouse_filter = Control.MOUSE_FILTER_STOP _orientation_guard.visible = false _orientation_guard.z_index = 200 add_child(_orientation_guard) _orientation_label = Label.new() _orientation_label.text = "请横屏使用" _orientation_label.set_anchors_preset(Control.PRESET_CENTER) _orientation_label.position = Vector2(-120, -20) _orientation_label.size = Vector2(240, 40) _orientation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _orientation_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _orientation_label.add_theme_font_size_override("font_size", 20) _orientation_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) _orientation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE _orientation_guard.add_child(_orientation_label) func _wire_signals() -> void: # Calling bind_controls twice is safe during a preview rebuild; signals are # connected once because this method is guarded by metadata. if get_meta("mobile_signals_wired", false): return set_meta("mobile_signals_wired", true) var np: Node = _bindings.get("net_play") if np and np.has_signal("cannot_act"): np.cannot_act.connect(func(code: String): _show_message(code)) var quickbar: Node = _bindings.get("quickbar") if quickbar and quickbar.has_signal("skill_rejected"): quickbar.skill_rejected.connect(func(_sid: int, code: String): _show_message(code)) func _connect_lifecycle() -> void: var life: Node = _bindings.get("lifecycle") if life == null or get_meta("lifecycle_wired", false): return set_meta("lifecycle_wired", true) if life.has_signal("back_requested"): life.back_requested.connect(_on_back_requested) if life.has_signal("resumed"): life.resumed.connect(func(): cancel_touch_state()) func _on_back_requested() -> void: if ui and ui.has_method("close_top") and bool(ui.close_top()): return if menu_drawer and menu_drawer.has_method("is_open") and menu_drawer.is_open(): menu_drawer.close() return if _avatar_panel and _avatar_panel.visible: _avatar_panel.visible = false return cancel_touch_state() func _register_mobile_windows() -> void: if ui == null or not ui.has_method("track_mobile_window"): return _register_window(_bindings.get("quest_log"), "任务日志") _register_window(_bindings.get("friend_ui"), "好友") _register_window(_bindings.get("guild_ui"), "公会") _register_window(_bindings.get("chat"), "聊天") _register_window(_bindings.get("shop_ui"), "商店") _register_window(_bindings.get("exchange_ui"), "交易") _register_window(_bindings.get("safebox_ui"), "仓库") _register_window(_bindings.get("mall_ui"), "商城仓库") _register_window(_bindings.get("cube_ui"), "制作") _register_window(_bindings.get("private_shop_ui"), "私人商店") _register_window(_bindings.get("refine_ui"), "精炼") func _register_window(target: Node, title: String) -> void: if target == null: return var close_cb := Callable(target, "close") if target.has_method("close") else Callable() if target.has_method("get_mobile_windows"): for root in target.get_mobile_windows(): if root is Control: ui.track_mobile_window(root, title, close_cb) elif target.has_method("get_mobile_window"): var root: Control = target.get_mobile_window() if root: ui.track_mobile_window(root, title, close_cb) func _build_avatar_panel(parent: Control) -> void: _avatar_panel = Panel.new() _avatar_panel.position = Vector2(112, 18) _avatar_panel.size = Vector2(170, 218) _avatar_panel.visible = false _avatar_panel.mouse_filter = Control.MOUSE_FILTER_STOP _avatar_panel.add_theme_stylebox_override("panel", _panel_style()) parent.add_child(_avatar_panel) var title := Label.new() title.text = "角色" title.position = Vector2(14, 10) title.add_theme_font_size_override("font_size", 16) title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) _avatar_panel.add_child(title) var close := TouchButton.new() close.setup("×", Color(0.6, 0.75, 0.96, 0.9)) close.set_rect_style() close.position = Vector2(132, 5) close.size = Vector2(30, 28) close.pressed.connect(func(): _avatar_panel.visible = false) _avatar_panel.add_child(close) for data in [["角色 / 装备", "status"], ["技能", "skills"], ["坐骑", "mount"], ["表情", "emoticon"]]: var button := TouchButton.new() button.setup(String(data[0]), Color(0.45, 0.7, 0.95, 0.9)) button.set_rect_style() button.position = Vector2(12, 42 + (data[1] as String).length() * 0) button.size = Vector2(146, 34) button.position.y = 42 + _avatar_panel.get_child_count() * 0 var action := String(data[1]) button.pressed.connect(func(): _open_avatar_feature(action)) _avatar_panel.add_child(button) # Reposition after the title/close children have been added. for i in range(4): var button := _avatar_panel.get_child(2 + i) as TouchButton if button: button.position = Vector2(12, 44 + i * 39) func _on_avatar_pressed() -> void: _avatar_panel.visible = not _avatar_panel.visible func _open_avatar_feature(action: String) -> void: _avatar_panel.visible = false var cs: Node = _bindings.get("char_status_ui") var skills: Node = _bindings.get("skills") match action: "status": if cs and cs.has_method("open"): cs.open("STATUS") "emoticon": if cs and cs.has_method("open"): cs.open("EMOTICON") "skills", "mount": if skills: if skills.has_method("set_job"): skills.set_job(_job_name()) if skills.has_method("open"): skills.open() func _on_inventory_pressed() -> void: var inventory: Node = _bindings.get("inventory") if inventory and inventory.has_method("toggle"): inventory.toggle() func _on_minimap_pressed() -> void: var atlas: Node = _bindings.get("atlas_ui") if atlas and atlas.has_method("open"): atlas.open() func _on_menu_pressed() -> void: if menu_drawer: menu_drawer.toggle() func _on_quest_pressed() -> void: var quests: Node = _bindings.get("quest_log") if quests and quests.has_method("toggle"): quests.toggle() func _on_party_member_pressed(vid: int) -> void: if client and vid > 0 and client.has_method("set_target"): client.set_target(vid) func _on_context_action_pressed() -> void: var ground: Node = _bindings.get("ground_items") if ground and ground.has_method("try_pickup") and int(ground.try_pickup()) != 0: return _show_message("附近没有可拾取的物品") func _on_menu_item(category: String, item: String) -> void: if menu_drawer: menu_drawer.close() var target: Node match category: "社交": match item: "聊天": target = _bindings.get("chat"); _call_toggle(target, "toggle_log") "好友": target = _bindings.get("friend_ui"); _call_toggle(target) "公会": target = _bindings.get("guild_ui"); _call_toggle(target) "情侣": target = _bindings.get("love_ui"); _call_toggle(target) _: _show_message("观战功能将在进入观战状态后可用") "成长": if item == "龙魂": _show_message("龙魂功能当前未开放") else: _show_message("请靠近对应 NPC 后使用:" + item) "活动": if item == "钓鱼": var np: Node = _bindings.get("net_play") if np and np.has_method("activate_fishing"): _show_message(String(np.activate_fishing())) else: _show_message("请从任务或活动区域进入副本") "商业": if item == "商城": if client and client.has_method("say"): client.say(0, "/in_game_mall") else: _show_message("请靠近对应 NPC 或摊位后使用:" + item) "系统": _system_action(item) func _system_action(item: String) -> void: var system_menu: Node = _bindings.get("system_menu_ui") var sys_opt: Node = _bindings.get("system_option_ui") var game_opt: Node = _bindings.get("game_option_ui") match item: "系统设置": _call_open(sys_opt) "游戏设置": _call_open(game_opt) "帮助": if system_menu and system_menu.has_method("open_help"): system_menu.open_help() "选择角色": if client and client.has_method("say"): client.say(0, "/phase_select") "登出": if client and client.has_method("say"): client.say(0, "/logout") "退出游戏": get_tree().quit() func _call_toggle(target: Node, method := "toggle") -> void: if target and target.has_method(method): target.call(method) func _call_open(target: Node) -> void: if target and target.has_method("open"): target.open() func _job_name() -> String: if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): return "WARRIOR" var race := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3 return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][race] func _show_message(message: String) -> void: if _toast == null: _toast = Label.new() _toast.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) _toast.position = Vector2(-170, -62) _toast.size = Vector2(340, 34) _toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _toast.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _toast.add_theme_font_size_override("font_size", 12) _toast.add_theme_color_override("font_color", Color(1.0, 0.86, 0.58)) _toast.mouse_filter = Control.MOUSE_FILTER_IGNORE _content_root.add_child(_toast) _toast.text = message _toast.visible = true var tween := create_tween() tween.tween_interval(1.8) tween.tween_callback(func(): if _toast: _toast.visible = false) func _panel_style() -> StyleBoxFlat: var style := StyleBoxFlat.new() style.bg_color = Color(0.04, 0.065, 0.1, 0.96) style.border_color = Color(0.55, 0.7, 0.92, 0.9) style.set_border_width_all(1) style.set_corner_radius_all(10) return style