454 lines
17 KiB
GDScript
454 lines
17 KiB
GDScript
# 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
|