Files
mtgodot-poc/project/ui/mobile/mobile_ui_root.gd
T

454 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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