feat: complete mobile UI implementation

This commit is contained in:
shenlei
2026-09-04 19:54:33 +09:00
parent 9a4a044da5
commit b19d5b5dd3
132 changed files with 6643 additions and 180 deletions
+46 -3
View File
@@ -35,6 +35,10 @@ var _map_origin := Vector2.ZERO
var _map_px := Vector2.ZERO
var _world_size_m := Vector2(1, 1)
var _center_position_adjust := Vector2.ZERO
var _touches: Dictionary = {}
var _touch_dragging := false
var _touch_drag_origin := Vector2.ZERO
var _touch_map_origin := Vector2.ZERO
func setup(metin_world: Node, ui_manager: CanvasLayer, get_player: Callable,
name := "", m2client: Node = null) -> void:
@@ -64,6 +68,8 @@ func close() -> void:
if is_open() and ui:
ui.close(_win)
_win = null
_touches.clear()
_touch_dragging = false
# CPythonMiniMap::SetAtlasCenterPosition ultimately calls the atlas window's
# SetCenterPositionAdjust. Keep the same screen-space adjustment instead of
@@ -119,6 +125,7 @@ func _build_window() -> void:
_map_view.position = Vector2(14, 42)
_map_view.size = Vector2(VIEW_SIZE.x - 28, VIEW_SIZE.y - 80)
_map_view.clip_contents = true
_map_view.mouse_filter = Control.MOUSE_FILTER_STOP
_map_view.gui_input.connect(_on_map_input)
_win.add_child(_map_view)
_overlay = AtlasOverlay.new()
@@ -176,6 +183,39 @@ func _build_map() -> void:
_coord.text = "坐标:--(缺 minimap.dds,仍可查询)"
func _on_map_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
var touch := event as InputEventScreenTouch
if touch.pressed:
_touches[touch.index] = touch.position
if _touches.size() == 1:
_touch_dragging = true
_touch_drag_origin = touch.position
_touch_map_origin = _map.position
else:
# A second finger is a gesture boundary. Do not continue the
# first finger's drag while pinch/scroll state is ambiguous.
_touch_dragging = false
else:
_touches.erase(touch.index)
if _touches.is_empty():
_touch_dragging = false
elif _touches.size() == 1:
# Re-arm panning for the remaining finger without jumping the map.
var remaining := int(_touches.keys()[0])
_touch_dragging = true
_touch_drag_origin = _touches[remaining]
_touch_map_origin = _map.position
_map_view.accept_event()
return
if event is InputEventScreenDrag:
var drag := event as InputEventScreenDrag
if _touches.has(drag.index):
_touches[drag.index] = drag.position
if _touches.size() == 1 and _touch_dragging and _touches.has(drag.index):
_map.position = _touch_map_origin + drag.position - _touch_drag_origin
_overlay.position = _map.position
_map_view.accept_event()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
_dragging = event.pressed
if _dragging:
@@ -187,11 +227,14 @@ func _on_map_input(event: InputEvent) -> void:
_overlay.position = _map.position
_map_view.accept_event()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT and event.pressed:
_map_origin = (_map_view.size - _map.size) * 0.5
_map.position = _map_origin
_overlay.position = _map.position
_recenter_map()
_map_view.accept_event()
func _recenter_map() -> void:
_map_origin = (_map_view.size - _map.size) * 0.5 + _center_position_adjust
_map.position = _map_origin
_overlay.position = _map.position
func _process(_dt: float) -> void:
if is_open():
_update_player_marker()
+303
View File
@@ -18,6 +18,7 @@ extends Node
const UiScript = preload("res://ui/uiscript.gd")
const UiBuild = preload("res://ui/ui_build.gd")
const UiAssets = preload("res://ui/ui_assets.gd")
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
# --- EPointTypesm2dev Packet.h),只列本窗要用的 ---
const P_LEVEL := 1
@@ -65,6 +66,14 @@ var uiscript_dir := ""
var _win: Dictionary = {} # { root, nodes }
var _state := "STATUS"
var _mobile_mode := false
var _mobile_root: Control
var _mobile_status_page: Control
var _mobile_emote_page: Control
var _mobile_labels := {}
var _mobile_stats := {}
var _mobile_equipment_labels: Array[Label] = []
var _mobile_hint: Label
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
ui = ui_manager
@@ -80,9 +89,19 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
if client and client.has_signal("entity_main_set"):
client.entity_main_set.connect(func(_v): if is_open(): _refresh())
func set_mobile_mode(enabled: bool) -> void:
if not enabled and _mobile_root != null:
var old_mode := _mobile_mode
_mobile_mode = true
close()
_mobile_mode = old_mode
_mobile_mode = enabled
# --- open / close -----------------------------------------------------
func is_open() -> bool:
if _mobile_mode:
return _mobile_root != null and is_instance_valid(_mobile_root)
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func toggle() -> void:
@@ -90,6 +109,16 @@ func toggle() -> void:
else: open()
func close() -> void:
if _mobile_mode:
if _mobile_root and is_instance_valid(_mobile_root) and ui:
ui.close(_mobile_root)
_mobile_root = null
_mobile_status_page = null
_mobile_emote_page = null
_mobile_labels.clear()
_mobile_stats.clear()
_mobile_equipment_labels.clear()
return
if is_open():
ui.close(_win["root"])
_win = {}
@@ -97,6 +126,9 @@ func close() -> void:
func open(state := "") -> void:
if state != "" and state in STATES:
_state = state
if _mobile_mode:
_open_mobile()
return
if is_open():
_set_state(_state)
_refresh()
@@ -113,6 +145,277 @@ func open(state := "") -> void:
_set_state(_state)
_refresh()
func _open_mobile() -> void:
if is_open():
_mobile_set_state(_state)
_refresh_mobile()
return
_mobile_root = Control.new()
_mobile_root.name = "MobileCharacterWindow"
_mobile_root.size = Vector2(760, 340)
_mobile_root.set_meta("mobile_title", "角色")
_mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP
_build_mobile_view()
ui.open(_mobile_root)
_mobile_set_state(_state)
_refresh_mobile()
func _build_mobile_view() -> void:
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_theme_stylebox_override("panel", _mobile_panel_style())
_mobile_root.add_child(panel)
var tabs := HBoxContainer.new()
tabs.position = Vector2(14, 48)
tabs.size = Vector2(250, 38)
tabs.add_theme_constant_override("separation", 6)
_mobile_root.add_child(tabs)
var status_tab := MobileTouchButton.new()
status_tab.setup("属性 / 装备", Color(0.65, 0.78, 0.98, 0.95))
status_tab.set_rect_style()
status_tab.custom_minimum_size = Vector2(118, 36)
status_tab.size = Vector2(118, 36)
status_tab.pressed.connect(func(): _mobile_set_state("STATUS"))
tabs.add_child(status_tab)
var emote_tab := MobileTouchButton.new()
emote_tab.setup("表情", Color(0.65, 0.78, 0.98, 0.95))
emote_tab.set_rect_style()
emote_tab.custom_minimum_size = Vector2(86, 36)
emote_tab.size = Vector2(86, 36)
emote_tab.pressed.connect(func(): _mobile_set_state("EMOTICON"))
tabs.add_child(emote_tab)
_mobile_status_page = Control.new()
_mobile_status_page.name = "StatusPage"
_mobile_status_page.position = Vector2(14, 92)
_mobile_status_page.size = Vector2(732, 236)
_mobile_root.add_child(_mobile_status_page)
_build_mobile_profile()
_build_mobile_stats()
_build_mobile_equipment()
_mobile_emote_page = Control.new()
_mobile_emote_page.name = "EmotePage"
_mobile_emote_page.position = Vector2(14, 92)
_mobile_emote_page.size = Vector2(732, 236)
_mobile_root.add_child(_mobile_emote_page)
_build_mobile_emotes()
func _build_mobile_profile() -> void:
var profile := Panel.new()
profile.position = Vector2(0, 0)
profile.size = Vector2(190, 236)
profile.add_theme_stylebox_override("panel", _mobile_panel_style(Color(0.035, 0.055, 0.09, 0.94), Color(0.28, 0.5, 0.78, 0.72)))
_mobile_status_page.add_child(profile)
var title := Label.new()
title.text = "角色信息"
title.position = Vector2(12, 10)
title.add_theme_font_size_override("font_size", 14)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
profile.add_child(title)
for data in [["name", "角色"], ["guild", "公会"], ["level", "等级"], ["exp", "经验"], ["hp", "生命"], ["sp", "法力"]]:
var row := HBoxContainer.new()
row.position = Vector2(12, 38 + _mobile_labels.size() * 29)
row.size = Vector2(166, 24)
var caption := Label.new()
caption.text = String(data[1])
caption.custom_minimum_size = Vector2(44, 22)
caption.add_theme_font_size_override("font_size", 11)
row.add_child(caption)
var value := Label.new()
value.name = String(data[0])
value.size_flags_horizontal = Control.SIZE_EXPAND_FILL
value.add_theme_font_size_override("font_size", 11)
value.add_theme_color_override("font_color", Color(0.82, 0.9, 1.0))
row.add_child(value)
profile.add_child(row)
_mobile_labels[String(data[0])] = value
func _build_mobile_stats() -> void:
var stats := Panel.new()
stats.position = Vector2(200, 0)
stats.size = Vector2(270, 236)
stats.add_theme_stylebox_override("panel", _mobile_panel_style(Color(0.035, 0.055, 0.09, 0.94), Color(0.28, 0.5, 0.78, 0.72)))
_mobile_status_page.add_child(stats)
var title := Label.new()
title.text = "属性"
title.position = Vector2(12, 10)
title.add_theme_font_size_override("font_size", 14)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
stats.add_child(title)
var points := Label.new()
points.name = "stat_points"
points.position = Vector2(146, 10)
points.size = Vector2(110, 22)
points.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
points.add_theme_font_size_override("font_size", 11)
points.add_theme_color_override("font_color", Color(0.96, 0.78, 0.35))
stats.add_child(points)
_mobile_labels["stat_points"] = points
for i in 4:
var key: String = ["STR", "HTH", "DEX", "INT"][i]
var row := HBoxContainer.new()
row.position = Vector2(12 + (i % 2) * 128, 44 + (i / 2) * 58)
row.size = Vector2(118, 48)
var caption := Label.new()
caption.text = key
caption.custom_minimum_size = Vector2(34, 42)
caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
caption.add_theme_font_size_override("font_size", 12)
row.add_child(caption)
var value := Label.new()
value.name = "value"
value.custom_minimum_size = Vector2(34, 42)
value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
value.add_theme_font_size_override("font_size", 13)
row.add_child(value)
var minus := MobileTouchButton.new()
minus.setup("", Color(0.72, 0.74, 0.82, 0.9))
minus.set_rect_style()
minus.custom_minimum_size = Vector2(24, 42)
minus.size = Vector2(24, 42)
minus.pressed.connect(func(): _send_stat(MINUS_CMD[key]))
row.add_child(minus)
var plus := MobileTouchButton.new()
plus.setup("", Color(0.95, 0.72, 0.3, 0.95))
plus.set_rect_style()
plus.custom_minimum_size = Vector2(24, 42)
plus.size = Vector2(24, 42)
plus.pressed.connect(func(): _send_stat(PLUS_CMD[key]))
row.add_child(plus)
stats.add_child(row)
_mobile_stats[key] = {"value": value, "minus": minus, "plus": plus}
var hint := Label.new()
hint.text = "分配属性点会通过服务器确认"
hint.position = Vector2(12, 174)
hint.size = Vector2(245, 40)
hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hint.add_theme_font_size_override("font_size", 10)
hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84))
stats.add_child(hint)
func _build_mobile_equipment() -> void:
var equipment := Panel.new()
equipment.position = Vector2(480, 0)
equipment.size = Vector2(252, 236)
equipment.add_theme_stylebox_override("panel", _mobile_panel_style(Color(0.035, 0.055, 0.09, 0.94), Color(0.28, 0.5, 0.78, 0.72)))
_mobile_status_page.add_child(equipment)
var title := Label.new()
title.text = "装备"
title.position = Vector2(12, 10)
title.add_theme_font_size_override("font_size", 14)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
equipment.add_child(title)
var grid := GridContainer.new()
grid.name = "equipment_grid"
grid.columns = 4
grid.position = Vector2(10, 38)
grid.size = Vector2(232, 188)
grid.add_theme_constant_override("h_separation", 5)
grid.add_theme_constant_override("v_separation", 5)
equipment.add_child(grid)
for i in 24:
var slot := Panel.new()
slot.custom_minimum_size = Vector2(53, 36)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.08, 0.11, 0.17, 0.95)
style.border_color = Color(0.32, 0.45, 0.62, 0.8)
style.set_border_width_all(1)
style.set_corner_radius_all(4)
slot.add_theme_stylebox_override("panel", style)
var value := Label.new()
value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
value.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
value.set_anchors_preset(Control.PRESET_FULL_RECT)
value.add_theme_font_size_override("font_size", 9)
value.text = str(i + 1)
value.mouse_filter = Control.MOUSE_FILTER_IGNORE
slot.add_child(value)
grid.add_child(slot)
_mobile_equipment_labels.append(value)
func _build_mobile_emotes() -> void:
var title := Label.new()
title.text = "点击发送表情"
title.position = Vector2(8, 8)
title.add_theme_font_size_override("font_size", 14)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
_mobile_emote_page.add_child(title)
var grid := GridContainer.new()
grid.position = Vector2(8, 42)
grid.columns = 5
grid.add_theme_constant_override("h_separation", 8)
grid.add_theme_constant_override("v_separation", 8)
_mobile_emote_page.add_child(grid)
for i in 9:
var button := MobileTouchButton.new()
button.setup("表情 %d" % (i + 1), Color(0.45, 0.7, 0.95, 0.92))
button.set_rect_style()
button.custom_minimum_size = Vector2(112, 52)
button.size = Vector2(112, 52)
var emote_id := i + 1
button.pressed.connect(func():
if client and client.has_method("send_emoticon"):
client.send_emoticon(emote_id)
if _mobile_hint: _mobile_hint.text = "已发送表情 %d" % emote_id)
grid.add_child(button)
_mobile_hint = Label.new()
_mobile_hint.position = Vector2(8, 192)
_mobile_hint.size = Vector2(500, 28)
_mobile_hint.add_theme_font_size_override("font_size", 11)
_mobile_hint.add_theme_color_override("font_color", Color(0.7, 0.8, 0.94))
_mobile_emote_page.add_child(_mobile_hint)
func _mobile_set_state(state: String) -> void:
if state in STATES:
_state = state
if _mobile_status_page:
_mobile_status_page.visible = _state != "EMOTICON"
if _mobile_emote_page:
_mobile_emote_page.visible = _state == "EMOTICON"
func _refresh_mobile() -> void:
if not is_open() or client == null:
return
var pd: Dictionary = client.get_points() if client.has_method("get_points") else {}
var pts: Array = pd.get("points", [])
var get := func(i: int) -> int: return int(pts[i]) if i >= 0 and i < pts.size() else 0
_mobile_set_label("level", "Lv %d" % int(pd.get("level", get.call(P_LEVEL))))
_mobile_set_label("exp", "%d / %d" % [_u32(int(pd.get("exp", get.call(P_EXP)))), _u32(int(pd.get("next_exp", get.call(P_NEXT_EXP))))])
_mobile_set_label("hp", "%d / %d" % [int(pd.get("hp", get.call(P_HP))), int(pd.get("max_hp", get.call(P_MAX_HP)))])
_mobile_set_label("sp", "%d / %d" % [int(pd.get("sp", get.call(P_SP))), int(pd.get("max_sp", get.call(P_MAX_SP)))])
var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0
var ent: Dictionary = client.get_entity(vid) if vid != 0 and client.has_method("get_entity") else {}
_mobile_set_label("name", String(ent.get("name", "冒险者")))
var guild_id := int(ent.get("guild", 0))
var guild_name := String(client.get_guild_name(guild_id)) if guild_id != 0 and client.has_method("get_guild_name") else ""
_mobile_set_label("guild", guild_name if guild_name != "" else "")
_mobile_set_label("stat_points", "可用点数:%d" % max(0, get.call(P_STAT)))
for key in _mobile_stats:
var source: int = int({"STR": P_ST, "HTH": P_HT, "DEX": P_DX, "INT": P_IQ}.get(key, 0))
_mobile_stats[key]["value"].text = str(get.call(int(source)))
_mobile_stats[key]["plus"].visible = get.call(P_STAT) > 0
_mobile_stats[key]["minus"].visible = get.call(P_STAT) > 0
var equipment: Array = client.get_equipment() if client.has_method("get_equipment") else []
for i in _mobile_equipment_labels.size():
var item: Dictionary = equipment[i] if i < equipment.size() else {}
var vnum := int(item.get("vnum", 0))
_mobile_equipment_labels[i].text = ("#%d" % vnum) if vnum != 0 else str(i + 1)
func _mobile_set_label(key: String, value: String) -> void:
if _mobile_labels.has(key) and is_instance_valid(_mobile_labels[key]):
_mobile_labels[key].text = value
func _mobile_panel_style(bg := Color(0.045, 0.065, 0.1, 0.96), border := Color(0.52, 0.68, 0.9, 0.82)) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = bg
style.border_color = border
style.set_border_width_all(1)
style.set_corner_radius_all(8)
return style
func _node(nm: String) -> Control:
if _win.is_empty():
return null
+107 -4
View File
@@ -50,6 +50,8 @@ var _fishing_active := false
var _whisper_dialog: Panel
var _whisper_target: LineEdit
var _whisper_message: LineEdit
var _mobile_mode := false
var _tab_row: HBoxContainer
func setup(m2client: Node, parent: Node, assets_root := "") -> void:
client = m2client
@@ -85,6 +87,29 @@ func focus_input() -> void:
if _input:
_input.grab_focus()
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_apply_mobile_layout()
_apply_mobile_whisper_layout()
if enabled and _root:
# Chat is an on-demand full-screen page on mobile; the landscape HUD has
# no permanent bottom chat strip.
_root.visible = false
func close() -> void:
if _root:
_root.visible = false
_close_whisper()
func get_mobile_windows() -> Array[Control]:
var result: Array[Control] = []
if _root:
result.append(_root)
if _whisper_dialog:
result.append(_whisper_dialog)
return result
# 预填 "/w <名> " 并聚焦(好友列表点名字时调)。
func start_whisper(name: String) -> void:
if _whisper_dialog == null or name.strip_edges() == "":
@@ -130,9 +155,9 @@ func _build(parent: Node) -> void:
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var tabrow := HBoxContainer.new()
tabrow.position = Vector2(6, 4)
_root.add_child(tabrow)
_tab_row = HBoxContainer.new()
_tab_row.position = Vector2(6, 4)
_root.add_child(_tab_row)
for i in TABS.size():
var b := Button.new()
b.text = TABS[i]
@@ -141,7 +166,7 @@ func _build(parent: Node) -> void:
b.add_theme_font_size_override("font_size", 11)
var idx := i
b.pressed.connect(func(): _select_tab(idx))
tabrow.add_child(b)
_tab_row.add_child(b)
_tab_btns.append(b)
for i in TABS.size():
@@ -165,6 +190,84 @@ func _build(parent: Node) -> void:
_input.text_submitted.connect(_on_submit)
_root.add_child(_input)
_build_whisper_dialog(parent)
_apply_mobile_layout()
_apply_mobile_whisper_layout()
func _apply_mobile_layout() -> void:
if _root == null:
return
if not _mobile_mode:
_root.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
_root.position = Vector2(12, -232)
_root.size = Vector2(460, 220)
if _tab_row:
_tab_row.position = Vector2(6, 4)
for rt in _log.values():
(rt as RichTextLabel).position = Vector2(6, 30)
(rt as RichTextLabel).size = Vector2(448, 158)
if _input:
_input.position = Vector2(6, 192)
_input.size = Vector2(448, 24)
return
# One landscape page with a large text area and a keyboard-friendly input
# row. The HUD remains clear until the player explicitly opens chat.
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
_root.position = Vector2.ZERO
_root.size = Vector2(760, 340)
_root.set_meta("mobile_title", "聊天")
if _tab_row:
_tab_row.position = Vector2(14, 48)
_tab_row.size = Vector2(732, 40)
_tab_row.add_theme_constant_override("separation", 6)
for child in _tab_row.get_children():
child.custom_minimum_size = Vector2(112, 38)
child.add_theme_font_size_override("font_size", 12)
for rt in _log.values():
(rt as RichTextLabel).position = Vector2(14, 94)
(rt as RichTextLabel).size = Vector2(732, 190)
(rt as RichTextLabel).add_theme_font_size_override("normal_font_size", 14)
if _input:
_input.position = Vector2(14, 294)
_input.size = Vector2(732, 38)
_input.add_theme_font_size_override("font_size", 13)
func _apply_mobile_whisper_layout() -> void:
if _whisper_dialog == null:
return
if not _mobile_mode:
_whisper_dialog.set_anchors_preset(Control.PRESET_CENTER)
_whisper_dialog.position = Vector2(-170, -82)
_whisper_dialog.size = Vector2(340, 164)
return
_whisper_dialog.set_anchors_preset(Control.PRESET_TOP_LEFT)
_whisper_dialog.position = Vector2.ZERO
_whisper_dialog.size = Vector2(480, 260)
_whisper_dialog.set_meta("mobile_title", "私聊")
var title := _whisper_dialog.get_child(0) as Label
if title:
title.position = Vector2(18, 50)
title.add_theme_font_size_override("font_size", 15)
var target_label := _whisper_dialog.get_child(1) as Label
if target_label:
target_label.position = Vector2(18, 94)
var message_label := _whisper_dialog.get_child(3) as Label
if message_label:
message_label.position = Vector2(18, 148)
if _whisper_target:
_whisper_target.position = Vector2(86, 90)
_whisper_target.size = Vector2(370, 42)
if _whisper_message:
_whisper_message.position = Vector2(86, 144)
_whisper_message.size = Vector2(370, 42)
_whisper_message.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_DEFAULT
var cancel := _whisper_dialog.get_node_or_null("Button") as Button
var send := _whisper_dialog.get_node_or_null("Button2") as Button
if cancel:
cancel.position = Vector2(174, 202)
cancel.size = Vector2(130, 44)
if send:
send.position = Vector2(316, 202)
send.size = Vector2(130, 44)
func _build_whisper_dialog(parent: Node) -> void:
# root/game.py 的 OpenWhisperDialog 是独立窗口,不应把私聊目标写回公共
+46 -2
View File
@@ -26,6 +26,10 @@ var _sel := -1
var _cube_slots: Dictionary = {} # cube index:int -> inventory cell:int
var _craft_in_flight := false
var _cube_npc_vnum := 0
var _mobile_mode := false
var _box: VBoxContainer
var _recipe_scroll: ScrollContainer
var _material_scroll: ScrollContainer
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -45,6 +49,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func get_mobile_window() -> Control:
return _root
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
func close() -> void:
if _root:
_root.visible = false
@@ -65,6 +76,7 @@ func _on_open(npc_vnum: int) -> void:
_cube_slots.clear()
_craft_in_flight = false
_cube_npc_vnum = npc_vnum
_apply_mobile_layout()
_root.visible = true
_title.text = "제작 (Cube) · NPC #%d" % npc_vnum
_status.text = ""
@@ -262,6 +274,31 @@ func _lbl(t: String, sz: int) -> Label:
l.add_theme_font_size_override("font_size", sz)
return l
func _apply_mobile_layout() -> void:
if _root == null or _box == null:
return
if _mobile_mode:
# Recipe selection and material/source-bag selection remain side by side
# in landscape. Both columns scroll independently so the craft action
# stays reachable when a recipe has many material alternatives.
_root.size = Vector2(720, 380)
_root.position = Vector2(-360, -190)
_box.position = Vector2(16, 48)
_box.size = Vector2(688, 318)
_box.custom_minimum_size = Vector2(688, 318)
_recipe_scroll.custom_minimum_size = Vector2(248, 254)
_material_scroll.custom_minimum_size = Vector2(430, 254)
_mat.custom_minimum_size = Vector2(414, 254)
else:
_root.size = Vector2(400, 380)
_root.position = Vector2(-200, -190)
_box.position = Vector2(16, 14)
_box.size = Vector2(368, 350)
_box.custom_minimum_size = Vector2(368, 350)
_recipe_scroll.custom_minimum_size = Vector2(180, 250)
_material_scroll.custom_minimum_size = Vector2(178, 250)
_mat.custom_minimum_size = Vector2(178, 250)
func _build(parent: Node) -> void:
_root = Panel.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
@@ -270,7 +307,8 @@ func _build(parent: Node) -> void:
_root.size = Vector2(400, 380)
_root.visible = false
parent.add_child(_root)
var box := VBoxContainer.new()
_box = VBoxContainer.new()
var box := _box
box.position = Vector2(16, 14)
box.custom_minimum_size = Vector2(368, 0)
box.add_theme_constant_override("separation", 6)
@@ -281,15 +319,20 @@ func _build(parent: Node) -> void:
split.add_theme_constant_override("separation", 10)
box.add_child(split)
var lsc := ScrollContainer.new()
_recipe_scroll = lsc
lsc.custom_minimum_size = Vector2(180, 250)
split.add_child(lsc)
_list = VBoxContainer.new()
_list.add_theme_constant_override("separation", 3)
lsc.add_child(_list)
_material_scroll = ScrollContainer.new()
_material_scroll.custom_minimum_size = Vector2(178, 250)
_material_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
split.add_child(_material_scroll)
_mat = VBoxContainer.new()
_mat.custom_minimum_size = Vector2(178, 250)
_mat.add_theme_constant_override("separation", 3)
split.add_child(_mat)
_material_scroll.add_child(_mat)
_status = _lbl("", 12)
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
box.add_child(_status)
@@ -320,3 +363,4 @@ func _build(parent: Node) -> void:
client.cube_close()
_root.visible = false)
brow.add_child(close)
_apply_mobile_layout()
+1
View File
@@ -0,0 +1 @@
uid://ku3jcc4x80r
+1
View File
@@ -0,0 +1 @@
uid://bwp78bnxnfvvx
+82 -1
View File
@@ -27,6 +27,9 @@ var _offered_cells := {}
var _self_grid: GridContainer
var _exchange_cells: Dictionary = {}
var item_mouse: Node
var _mobile_mode := false
var _mobile_inventory_scroll: ScrollContainer
var _mobile_inventory_list: VBoxContainer
const EXCHANGE_SLOT_COUNT := 12
const INVENTORY_WINDOW := 1
@@ -49,6 +52,15 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func get_mobile_window() -> Control:
return _root
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_apply_mobile_layout()
_refresh_mobile_inventory()
func close() -> void:
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
@@ -87,6 +99,7 @@ func offer(inv_window: int, inv_cell: int) -> void:
_offered_cells[key] = true
_next_display = (_next_display + 1) % 12
_status.text = ""
_refresh_mobile_inventory()
else:
_status.text = "交易请求发送失败"
@@ -135,6 +148,56 @@ func refresh() -> void:
_accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1)
_root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受"
_status.text = ""
_refresh_mobile_inventory()
func _apply_mobile_layout() -> void:
if _root == null:
return
if _mobile_mode:
# The inventory candidate list is part of the same hosted surface, so it
# scales and receives the same safe-area treatment as the trade window.
_root.size = Vector2(688, 400)
_root.position = Vector2(-344, -200)
if _mobile_inventory_scroll:
_mobile_inventory_scroll.position = Vector2(452, 54)
_mobile_inventory_scroll.size = Vector2(224, 280)
_mobile_inventory_scroll.visible = true
else:
_root.size = Vector2(440, 360)
_root.position = Vector2(-220, -180)
if _mobile_inventory_scroll:
_mobile_inventory_scroll.position = Vector2.ZERO
_mobile_inventory_scroll.size = Vector2.ZERO
_mobile_inventory_scroll.visible = false
func _refresh_mobile_inventory() -> void:
if not _mobile_mode or _mobile_inventory_list == null or not is_open():
return
for child in _mobile_inventory_list.get_children():
child.queue_free()
var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
var shown := 0
for item in inventory:
var cell := int(item.get("cell", -1))
var vnum := int(item.get("vnum", 0))
if cell < 0 or vnum <= 0:
continue
if _offered_cells.has("%d:%d" % [INVENTORY_WINDOW, cell]):
continue
var button := Button.new()
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.text = "%s ×%d · 点按加入" % [_name_of(vnum), int(item.get("count", 1))]
button.custom_minimum_size = Vector2(214, 34)
button.tooltip_text = _tooltip_for(item)
var captured_cell := cell
button.pressed.connect(func() -> void: offer(INVENTORY_WINDOW, captured_cell))
_mobile_inventory_list.add_child(button)
shown += 1
if shown == 0:
var empty := Label.new()
empty.text = "(没有可交易物品)"
empty.add_theme_font_size_override("font_size", 12)
_mobile_inventory_list.add_child(empty)
func _item_at(inv_window: int, inv_cell: int) -> Dictionary:
if client == null:
@@ -216,6 +279,7 @@ func _drop_to_slot(payload: Dictionary, display_pos: int) -> bool:
_status.text = "交易请求发送失败"
return false
_offered_cells[key] = true
_refresh_mobile_inventory()
return true
func _fill(box: VBoxContainer, rows: Array) -> void:
@@ -256,6 +320,11 @@ func _on_accept() -> void:
else:
_status.text = "接受请求发送失败"
func _on_cancel() -> void:
if client and client.has_method("exchange_cancel"):
client.exchange_cancel()
close()
func _capture_anchor() -> void:
if _anchor_valid or client == null or not client.has_method("get_main_vid") \
or not client.has_method("get_entity"):
@@ -369,6 +438,9 @@ func _build(parent: Node) -> void:
_gold_input = LineEdit.new()
_gold_input.placeholder_text = "金币"
_gold_input.custom_minimum_size = Vector2(90, 0)
# Godot will open the native numeric keyboard on Android/iOS while the
# validation below remains authoritative for desktop and hardware keyboards.
_gold_input.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
bottom.add_child(_gold_input)
var put := Button.new()
put.text = "放金币"
@@ -380,5 +452,14 @@ func _build(parent: Node) -> void:
bottom.add_child(_accept_btn)
var cancel := Button.new()
cancel.text = "取消"
cancel.pressed.connect(func() -> void: client.exchange_cancel())
cancel.pressed.connect(_on_cancel)
bottom.add_child(cancel)
_mobile_inventory_scroll = ScrollContainer.new()
_mobile_inventory_scroll.name = "MobileInventory"
_mobile_inventory_scroll.visible = false
_mobile_inventory_list = VBoxContainer.new()
_mobile_inventory_list.add_theme_constant_override("separation", 4)
_mobile_inventory_scroll.add_child(_mobile_inventory_list)
_root.add_child(_mobile_inventory_scroll)
_apply_mobile_layout()
+79 -16
View File
@@ -17,6 +17,10 @@ var _list: VBoxContainer
var _name_edit: LineEdit
var _invite_dialog: ConfirmationDialog
var _pending_invite := ""
var _mobile_mode := false
var _title: Label
var _bottom: HBoxContainer
var _add_button: Button
func setup(m2client: Node, parent: Node) -> void:
client = m2client
@@ -29,6 +33,15 @@ func setup(m2client: Node, parent: Node) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func get_mobile_window() -> Control:
return _root
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
if _invite_dialog and _invite_dialog.visible:
_popup_invite_dialog()
func close() -> void:
if _root:
_root.visible = false
@@ -57,15 +70,18 @@ func refresh() -> void:
func _row(f: Dictionary) -> Control:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(260, 0)
row.custom_minimum_size = Vector2(640, 46) if _mobile_mode else Vector2(260, 0)
var dot := Label.new()
dot.text = ""
dot.custom_minimum_size = Vector2(28, 42) if _mobile_mode else Vector2.ZERO
dot.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
dot.modulate = Color(0.4, 0.9, 0.4) if f.get("online", false) else Color(0.4, 0.4, 0.4)
row.add_child(dot)
var nm := Button.new()
nm.text = String(f.get("name", "?"))
nm.flat = true
nm.custom_minimum_size = Vector2(150, 0)
nm.custom_minimum_size = Vector2(450, 42) if _mobile_mode else Vector2(150, 0)
nm.alignment = HORIZONTAL_ALIGNMENT_LEFT
nm.pressed.connect(func() -> void: whisper_to.emit(String(f.get("name", ""))))
row.add_child(nm)
if f.get("mobile", false):
@@ -76,6 +92,7 @@ func _row(f: Dictionary) -> Control:
row.add_child(mobile)
var del := Button.new()
del.text = "×"
del.custom_minimum_size = Vector2(64, 42) if _mobile_mode else Vector2.ZERO
del.pressed.connect(func() -> void: client.remove_friend(String(f.get("name", ""))))
row.add_child(del)
return row
@@ -91,7 +108,7 @@ func _on_friend_invite(name: String) -> void:
return
_pending_invite = name
_invite_dialog.dialog_text = "接受“%s”的好友请求?" % name
_invite_dialog.popup_centered()
_popup_invite_dialog()
func _answer_invite(accept: bool) -> void:
var name := _pending_invite
@@ -108,6 +125,7 @@ func _build(parent: Node) -> void:
_root.size = Vector2(300, 360)
_root.visible = false
_root.set_meta("is_titlebar", true)
_root.set_meta("mobile_title", "好友")
parent.add_child(_root)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -116,27 +134,29 @@ func _build(parent: Node) -> void:
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var title := Label.new()
title.text = "好友"
title.position = Vector2(12, 8)
_root.add_child(title)
_title = Label.new()
_title.text = "好友"
_title.position = Vector2(12, 8)
_root.add_child(_title)
_list = VBoxContainer.new()
_list.position = Vector2(12, 34)
_list.size = Vector2(276, 278)
_list.add_theme_constant_override("separation", 3)
_root.add_child(_list)
var bottom := HBoxContainer.new()
bottom.position = Vector2(12, 320)
bottom.custom_minimum_size = Vector2(276, 0)
_root.add_child(bottom)
_bottom = HBoxContainer.new()
_bottom.position = Vector2(12, 320)
_bottom.custom_minimum_size = Vector2(276, 0)
_root.add_child(_bottom)
_name_edit = LineEdit.new()
_name_edit.placeholder_text = "角色名"
_name_edit.custom_minimum_size = Vector2(190, 0)
bottom.add_child(_name_edit)
var add_btn := Button.new()
add_btn.text = "添加"
add_btn.pressed.connect(_on_add)
bottom.add_child(add_btn)
_bottom.add_child(_name_edit)
_add_button = Button.new()
_add_button.text = "添加"
_add_button.pressed.connect(_on_add)
_bottom.add_child(_add_button)
_invite_dialog = ConfirmationDialog.new()
_invite_dialog.name = "FriendInviteDialog"
_invite_dialog.title = "好友请求"
_invite_dialog.ok_button_text = "接受"
_invite_dialog.cancel_button_text = "拒绝"
@@ -145,3 +165,46 @@ func _build(parent: Node) -> void:
# Keep the dialog outside the toggleable friend panel: requests must still
# be visible when the friend list window itself is closed.
parent.add_child(_invite_dialog)
if parent.has_method("track_mobile_modal"):
parent.track_mobile_modal(_invite_dialog)
_apply_mobile_layout()
func _popup_invite_dialog() -> void:
if not is_instance_valid(_invite_dialog):
return
if _mobile_mode:
# Native Window remains the owner of the confirmation signals, while its
# mobile footprint is large enough for landscape touch targets and the
# software keyboard-safe input area.
_invite_dialog.set_meta("mobile_title", "好友请求")
_invite_dialog.min_size = Vector2i(480, 220)
_invite_dialog.size = Vector2i(480, 220)
_invite_dialog.popup_centered(Vector2i(480, 220))
else:
_invite_dialog.popup_centered()
func _apply_mobile_layout() -> void:
if _root == null or _title == null or _bottom == null:
return
if _mobile_mode:
_root.size = Vector2(680, 344)
_root.position = Vector2(-340, -172)
_title.position = Vector2(18, 48)
_title.add_theme_font_size_override("font_size", 16)
_list.position = Vector2(18, 88)
_list.size = Vector2(644, 190)
_bottom.position = Vector2(18, 288)
_bottom.custom_minimum_size = Vector2(644, 44)
_name_edit.custom_minimum_size = Vector2(520, 44)
_add_button.custom_minimum_size = Vector2(112, 44)
else:
_root.size = Vector2(300, 360)
_root.position = Vector2(-150, -180)
_title.position = Vector2(12, 8)
_title.remove_theme_font_size_override("font_size")
_list.position = Vector2(12, 34)
_list.size = Vector2(276, 278)
_bottom.position = Vector2(12, 320)
_bottom.custom_minimum_size = Vector2(276, 0)
_name_edit.custom_minimum_size = Vector2(190, 0)
_add_button.custom_minimum_size = Vector2(0, 0)
+143
View File
@@ -13,6 +13,7 @@
# —— 客户端显示开关,持久化到 user://system_option.cfg [gameopt];渲染节点按需读取这些设置。
extends Node
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
# Packet.h EBlockAction
const BLOCK_BITS := {
"block_exchange_button": 1 << 0,
@@ -73,6 +74,8 @@ var _win: Dictionary = {}
var _cfg := ConfigFile.new()
var _block_mode := 0 # blockMode(本地跟踪 + 服务端回包同步)
var _display := {} # cfg [gameopt] 快照
var _mobile_mode := false
var _mobile_root: Control
static func config_path() -> String:
var override_dir := OS.get_environment("MT_CONFIG_DIR")
@@ -98,8 +101,18 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "") -> void:
client.block_mode_changed.connect(_on_server_block_mode)
func is_open() -> bool:
if _mobile_mode:
return _mobile_root != null and is_instance_valid(_mobile_root)
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
if not enabled and _mobile_root != null:
var old_mode := _mobile_mode
_mobile_mode = true
close()
_mobile_mode = old_mode
_mobile_mode = enabled
# 渲染层开局回填用:cfg [gameopt] 里某显示开关的当前值(0/1),缺省同 setup() 的默认。
func display_value(key: String) -> int:
return int(_display.get(key, 0))
@@ -109,11 +122,19 @@ func toggle() -> void:
else: open()
func close() -> void:
if _mobile_mode:
if _mobile_root and is_instance_valid(_mobile_root) and ui:
ui.close(_mobile_root)
_mobile_root = null
return
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if _mobile_mode:
_open_mobile()
return
if is_open():
return
var path := uiscript_dir.path_join("gameoptiondialog.py")
@@ -127,6 +148,128 @@ func open() -> void:
_wire()
_sync()
func _open_mobile() -> void:
if is_open():
return
_mobile_root = Control.new()
_mobile_root.name = "MobileGameOptionWindow"
_mobile_root.size = Vector2(700, 360)
_mobile_root.set_meta("mobile_title", "游戏设置")
_mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_theme_stylebox_override("panel", _mobile_panel_style())
_mobile_root.add_child(panel)
var scroll := ScrollContainer.new()
scroll.name = "MobileGameOptionScroll"
scroll.position = Vector2(14, 48)
scroll.size = Vector2(672, 298)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.mouse_filter = Control.MOUSE_FILTER_STOP
_mobile_root.add_child(scroll)
var content := VBoxContainer.new()
content.custom_minimum_size = Vector2(650, 0)
content.add_theme_constant_override("separation", 8)
scroll.add_child(content)
_add_mobile_section_title(content, "屏蔽设置")
_add_mobile_block_buttons(content)
_add_mobile_section_title(content, "PK 模式")
_add_mobile_pk_radios(content)
_add_mobile_section_title(content, "显示设置")
_add_mobile_display_radios(content)
ui.open(_mobile_root)
func _add_mobile_section_title(parent: VBoxContainer, text: String) -> void:
var title := Label.new()
title.text = text
title.custom_minimum_size = Vector2(640, 28)
title.add_theme_font_size_override("font_size", 14)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
parent.add_child(title)
func _add_mobile_block_buttons(parent: VBoxContainer) -> void:
var grid := GridContainer.new()
grid.columns = 3
grid.custom_minimum_size = Vector2(640, 94)
grid.add_theme_constant_override("h_separation", 6)
grid.add_theme_constant_override("v_separation", 6)
parent.add_child(grid)
for name in BLOCK_BITS:
var button := Button.new()
button.text = String(LABELS.get(name, name))
button.toggle_mode = true
button.button_pressed = (_block_mode & int(BLOCK_BITS[name])) != 0
button.custom_minimum_size = Vector2(206, 42)
var bit := int(BLOCK_BITS[name])
button.pressed.connect(func(): _toggle_block(bit))
grid.add_child(button)
func _add_mobile_pk_radios(parent: VBoxContainer) -> void:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(640, 44)
row.add_theme_constant_override("separation", 6)
var buttons: Array[Button] = []
for name in ["pvp_peace", "pvp_revenge", "pvp_guild", "pvp_free"]:
var button := Button.new()
button.text = String(LABELS.get(name, name))
button.toggle_mode = true
button.custom_minimum_size = Vector2(145, 42)
button.button_pressed = false
var picked: String = name
button.pressed.connect(func():
for other in buttons.size():
buttons[other].set_pressed_no_signal(buttons[other] == button)
if client and client.has_method("say"):
client.say(0, "/pkmode %d" % PK_CMD[picked]))
buttons.append(button)
row.add_child(button)
var current: Variant = _cfg.get_value("gameopt", "pk_mode", -1)
if int(current) in PK_CMD.values():
for i in PK_CMD.keys().size():
if int(PK_CMD[PK_CMD.keys()[i]]) == int(current):
buttons[i].button_pressed = true
parent.add_child(row)
func _add_mobile_display_radios(parent: VBoxContainer) -> void:
for group in RADIO_GROUPS:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(640, 44)
row.add_theme_constant_override("separation", 6)
var key: String = String(DISPLAY_RADIOS[group[0]][0])
var caption := Label.new()
caption.text = String(LABELS.get(key, key))
caption.custom_minimum_size = Vector2(126, 40)
caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
caption.add_theme_font_size_override("font_size", 12)
row.add_child(caption)
var buttons: Array[Button] = []
var current := int(_display.get(key, 0))
for name in group:
var button := Button.new()
button.text = String(LABELS.get(name, name))
button.toggle_mode = true
button.custom_minimum_size = Vector2(112, 42)
button.button_pressed = int(DISPLAY_RADIOS[name][1]) == current
var selected: String = name
button.pressed.connect(func():
for other in buttons.size():
buttons[other].set_pressed_no_signal(buttons[other] == button)
var spec: Array = DISPLAY_RADIOS[selected]
_display[String(spec[0])] = int(spec[1])
_save()
display_option_changed.emit(String(spec[0]), int(spec[1])))
buttons.append(button)
row.add_child(button)
parent.add_child(row)
func _mobile_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.04, 0.065, 0.1, 0.98)
style.border_color = Color(0.52, 0.68, 0.9, 0.86)
style.set_border_width_all(1)
style.set_corner_radius_all(8)
return style
func _node(nm: String) -> Control:
if _win.is_empty():
return null
+10
View File
@@ -209,6 +209,16 @@ func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void:
else:
tag.position.y = TAG_BASE_Y - d_px * px
# 移动端情境按钮用它判断附近是否有可拾取物,避免常驻占用 HUD。
func has_nearby_item() -> bool:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
if p == null:
return false
for node in _by_vid.values():
if is_instance_valid(node) and p.global_position.distance_to(node.global_position) < PICKUP_RANGE:
return true
return false
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
func try_pickup() -> int:
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
+133 -40
View File
@@ -6,7 +6,7 @@
# gu.toggle() # G 键
#
# `guild_changed` / `guild_skill_changed` / `guild_war_changed` 刷新,`guild_marks_ready`
# 到时刷会徽图。⬜ 等级页、日志页
# 到时刷会徽图。等级/日志页仍取决于协议是否提供对应数据,不在当前四页入口内伪造
extends Node
const TABS := ["成员", "技能", "公会战", "公告"]
@@ -19,8 +19,12 @@ var _ui_parent: Node
# 返回一张 Image(16×12,或任意会被转换/缩放)用作会徽上传源;null = 不显示上传按钮。
var mark_image_provider: Callable
var _root: Control
var _title: Label
var _info: Label
var _mark: TextureRect
var _tabrow: HBoxContainer
var _scroll: ScrollContainer
var _stack: Control
var _tab := 0
var _tab_btns: Array[Button] = []
var _pages: Array[VBoxContainer] = []
@@ -29,6 +33,9 @@ var _upload_status: Label
var _create_dialog: ConfirmationDialog
var _create_name_edit: LineEdit
var _create_status: Label
var _mobile_mode := false
const MOBILE_SIZE := Vector2(700, 340)
func setup(m2client: Node, parent: Node, table: Object = null) -> void:
client = m2client
@@ -48,6 +55,19 @@ func setup(m2client: Node, parent: Node, table: Object = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
if _mobile_mode and is_open() and _ui_parent and _ui_parent.has_method("reflow_mobile_window"):
_ui_parent.reflow_mobile_window(_root)
func close() -> void:
if _root:
_root.visible = false
func get_mobile_window() -> Control:
return _root
func toggle() -> void:
_root.visible = not _root.visible
if _root.visible:
@@ -95,7 +115,7 @@ func _fill_members() -> void:
page.add_child(row)
var ub := Button.new()
ub.text = "上传会徽"
ub.custom_minimum_size = Vector2(96, 26)
ub.custom_minimum_size = Vector2(140, 40) if _mobile_mode else Vector2(96, 26)
ub.pressed.connect(_upload_mark)
row.add_child(ub)
_upload_status = Label.new()
@@ -112,7 +132,9 @@ func _fill_members() -> void:
if gi >= 1 and gi <= grades.size():
gname = String(grades[gi - 1].get("name", ""))
var row := Label.new()
row.add_theme_font_size_override("font_size", 12)
row.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
row.text = "%s Lv.%d [%s] 贡献 %d%s" % [
String(m.get("name", "?")), int(m.get("level", 0)),
gname if gname != "" else str(gi),
@@ -134,11 +156,11 @@ func _fill_comments() -> void:
page.add_child(actions)
var edit := LineEdit.new()
edit.placeholder_text = "公告内容(最多 50 字)"
edit.custom_minimum_size = Vector2(245, 26)
edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(245, 26)
actions.add_child(edit)
var post := Button.new()
post.text = "发布"
post.custom_minimum_size = Vector2(52, 26)
post.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26)
post.pressed.connect(func():
var text := edit.text.strip_edges()
if text != "" and client.has_method("guild_post_comment"):
@@ -146,7 +168,7 @@ func _fill_comments() -> void:
actions.add_child(post)
var refresh_btn := Button.new()
refresh_btn.text = "刷新"
refresh_btn.custom_minimum_size = Vector2(52, 26)
refresh_btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(52, 26)
refresh_btn.pressed.connect(func():
if client.has_method("guild_refresh_comments"):
client.guild_refresh_comments())
@@ -162,13 +184,15 @@ func _fill_comments() -> void:
row.add_theme_constant_override("separation", 6)
page.add_child(row)
var label := Label.new()
label.custom_minimum_size = Vector2(286, 0)
label.custom_minimum_size = Vector2(560, 42) if _mobile_mode else Vector2(286, 0)
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 13 if _mobile_mode else 12)
label.text = "%s%s" % [String(item.get("name", "?")), String(item.get("content", ""))]
row.add_child(label)
var delete := Button.new()
delete.text = ""
delete.custom_minimum_size = Vector2(32, 24)
delete.custom_minimum_size = Vector2(64, 40) if _mobile_mode else Vector2(32, 24)
delete.pressed.connect(func():
if client.has_method("guild_delete_comment"):
client.guild_delete_comment(int(item.get("id", 0))))
@@ -193,7 +217,9 @@ func _fill_skills() -> void:
page.add_child(l)
return
var head := Label.new()
head.add_theme_font_size_override("font_size", 12)
head.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO
head.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
head.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
head.modulate = Color(0.8, 0.9, 1.0)
head.text = "技能点 %d 公会点 %d / %d" % [
int(sk.get("skill_point", 0)), int(sk.get("guild_point", 0)), int(sk.get("max_guild_point", 0))]
@@ -211,14 +237,15 @@ func _fill_skills() -> void:
row.add_theme_constant_override("separation", 8)
page.add_child(row)
var lbl := Label.new()
lbl.add_theme_font_size_override("font_size", 12)
lbl.custom_minimum_size = Vector2(240, 0)
lbl.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
lbl.custom_minimum_size = Vector2(430, 40) if _mobile_mode else Vector2(240, 0)
lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lbl.text = "%s Lv %d" % [nm, lv]
row.add_child(lbl)
if sid >= 0 and lv > 0:
var btn := Button.new()
btn.text = "施放"
btn.custom_minimum_size = Vector2(56, 24)
btn.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(56, 24)
btn.pressed.connect(func(): client.use_guild_skill(sid, 0))
row.add_child(btn)
@@ -230,7 +257,9 @@ func _fill_war(my_guild_id: int) -> void:
var cur: Dictionary = client.get_guild_war()
var st := int(cur.get("state", 0))
var cur_lbl := Label.new()
cur_lbl.add_theme_font_size_override("font_size", 12)
cur_lbl.custom_minimum_size = Vector2(640, 36) if _mobile_mode else Vector2.ZERO
cur_lbl.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
cur_lbl.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
cur_lbl.modulate = Color(1.0, 0.8, 0.7)
if st == 0:
cur_lbl.text = "当前无公会战"
@@ -247,11 +276,11 @@ func _fill_war(my_guild_id: int) -> void:
page.add_child(decl)
_war_name_edit = LineEdit.new()
_war_name_edit.placeholder_text = "对方公会名"
_war_name_edit.custom_minimum_size = Vector2(200, 26)
_war_name_edit.custom_minimum_size = Vector2(440, 40) if _mobile_mode else Vector2(200, 26)
decl.add_child(_war_name_edit)
var db := Button.new()
db.text = "宣战"
db.custom_minimum_size = Vector2(60, 26)
db.custom_minimum_size = Vector2(92, 40) if _mobile_mode else Vector2(60, 26)
db.pressed.connect(func():
var nm := _war_name_edit.text.strip_edges()
if nm != "":
@@ -259,7 +288,9 @@ func _fill_war(my_guild_id: int) -> void:
decl.add_child(db)
var hdr := Label.new()
hdr.add_theme_font_size_override("font_size", 12)
hdr.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO
hdr.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
hdr.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
hdr.text = "— 进行中的公会战 —"
page.add_child(hdr)
var wars: Array = client.get_guild_wars()
@@ -271,7 +302,9 @@ func _fill_war(my_guild_id: int) -> void:
var sn := String(wpair.get("src_name", ""))
var dn := String(wpair.get("dst_name", ""))
var row := Label.new()
row.add_theme_font_size_override("font_size", 12)
row.custom_minimum_size = Vector2(640, 34) if _mobile_mode else Vector2.ZERO
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 14 if _mobile_mode else 12)
var mark_self := " ◀我方" if int(wpair.get("src", 0)) == my_guild_id or int(wpair.get("dst", 0)) == my_guild_id else ""
row.text = "%s vs %s%s" % [
sn if sn != "" else "#%d" % int(wpair.get("src", 0)),
@@ -303,7 +336,7 @@ func _on_mark_uploaded(ok: bool) -> void:
# 让名称校验发生在发包前,同时把最终权限 / 敏感词判断留给服务端。
func _on_guild_make_requested() -> void:
if is_instance_valid(_create_dialog):
_create_dialog.popup_centered()
_popup_create_dialog()
_create_name_edit.grab_focus()
return
if _ui_parent == null or client == null:
@@ -318,17 +351,19 @@ func _on_guild_make_requested() -> void:
_create_name_edit.name = "GuildName"
_create_name_edit.max_length = 12
_create_name_edit.placeholder_text = "公会名称"
_create_name_edit.custom_minimum_size = Vector2(250, 28)
_create_name_edit.custom_minimum_size = Vector2(380, 44) if _mobile_mode else Vector2(250, 28)
_create_dialog.add_child(_create_name_edit)
_create_status = Label.new()
_create_status.name = "Status"
_create_status.add_theme_font_size_override("font_size", 11)
_create_status.add_theme_font_size_override("font_size", 13 if _mobile_mode else 11)
_create_status.modulate = Color(1.0, 0.7, 0.6)
_create_dialog.add_child(_create_status)
_ui_parent.add_child(_create_dialog)
_create_dialog.confirmed.connect(_on_guild_create_confirmed)
_create_dialog.canceled.connect(_close_guild_create_dialog)
_create_dialog.popup_centered()
if _ui_parent.has_method("track_mobile_modal"):
_ui_parent.track_mobile_modal(_create_dialog)
_popup_create_dialog()
_create_name_edit.grab_focus()
func _on_guild_create_confirmed() -> void:
@@ -347,12 +382,12 @@ func _on_guild_create_confirmed() -> void:
break
if error != "":
_create_status.text = error
_create_dialog.call_deferred("popup_centered")
_create_dialog.call_deferred("popup_centered", Vector2i(520, 270) if _mobile_mode else Vector2i(-1, -1))
_create_name_edit.call_deferred("grab_focus")
return
if not client.has_method("guild_answer_make") or not bool(client.call("guild_answer_make", name)):
_create_status.text = "创建请求发送失败"
_create_dialog.call_deferred("popup_centered")
_create_dialog.call_deferred("popup_centered", Vector2i(520, 270) if _mobile_mode else Vector2i(-1, -1))
_create_name_edit.call_deferred("grab_focus")
return
_close_guild_create_dialog()
@@ -365,6 +400,17 @@ func _close_guild_create_dialog() -> void:
if is_instance_valid(dialog):
dialog.queue_free()
func _popup_create_dialog() -> void:
if not is_instance_valid(_create_dialog):
return
if _mobile_mode:
_create_dialog.set_meta("mobile_title", "创建公会")
_create_dialog.min_size = Vector2i(520, 270)
_create_dialog.size = Vector2i(520, 270)
_create_dialog.popup_centered(Vector2i(520, 270))
else:
_create_dialog.popup_centered()
# 公会 id 变了就重新取会徽图(16x12,放大 3x 显示)。
func _refresh_mark(guild_id: int) -> void:
if _mark == null:
@@ -396,10 +442,10 @@ func _build(parent: Node) -> void:
sb.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", sb)
_root.add_child(panel)
var title := Label.new()
title.text = "公会"
title.position = Vector2(12, 8)
_root.add_child(title)
_title = Label.new()
_title.text = "公会"
_title.position = Vector2(12, 8)
_root.add_child(_title)
_mark = TextureRect.new()
_mark.position = Vector2(320, 6)
_mark.custom_minimum_size = Vector2(48, 36) # 16x12 放大 3x
@@ -414,30 +460,77 @@ func _build(parent: Node) -> void:
_info.modulate = Color(0.9, 0.85, 0.6)
_root.add_child(_info)
var tabrow := HBoxContainer.new()
tabrow.position = Vector2(12, 54)
tabrow.add_theme_constant_override("separation", 4)
_root.add_child(tabrow)
_tabrow = HBoxContainer.new()
_tabrow.position = Vector2(12, 54)
_tabrow.add_theme_constant_override("separation", 4)
_root.add_child(_tabrow)
for i in TABS.size():
var b := Button.new()
b.text = TABS[i]
b.custom_minimum_size = Vector2(84, 26)
b.pressed.connect(_set_tab.bind(i))
tabrow.add_child(b)
_tabrow.add_child(b)
_tab_btns.append(b)
var sc := ScrollContainer.new()
sc.position = Vector2(12, 86)
sc.custom_minimum_size = Vector2(356, 322)
_root.add_child(sc)
var stack := Control.new()
stack.custom_minimum_size = Vector2(350, 0)
sc.add_child(stack)
_scroll = ScrollContainer.new()
_scroll.position = Vector2(12, 86)
_scroll.custom_minimum_size = Vector2(356, 322)
_root.add_child(_scroll)
_stack = Control.new()
_stack.custom_minimum_size = Vector2(350, 0)
_scroll.add_child(_stack)
for i in TABS.size():
var page := VBoxContainer.new()
page.custom_minimum_size = Vector2(350, 0)
page.add_theme_constant_override("separation", 3)
page.visible = (i == 0)
stack.add_child(page)
_stack.add_child(page)
_pages.append(page)
_tab_btns[0].disabled = true
_apply_mobile_layout()
func _apply_mobile_layout() -> void:
if _root == null or _title == null or _tabrow == null or _scroll == null or _stack == null:
return
if _mobile_mode:
_root.size = MOBILE_SIZE
_root.position = Vector2(-MOBILE_SIZE.x * 0.5, -MOBILE_SIZE.y * 0.5)
_title.visible = false
_info.position = Vector2(18, 46)
_info.size = Vector2(580, 26)
_info.add_theme_font_size_override("font_size", 14)
_mark.position = Vector2(624, 46)
_mark.size = Vector2(48, 36)
_tabrow.position = Vector2(18, 78)
_tabrow.size = Vector2(664, 40)
for b in _tab_btns:
b.custom_minimum_size = Vector2(160, 38)
b.add_theme_font_size_override("font_size", 13)
_scroll.custom_minimum_size = Vector2(664, 194)
_scroll.position = Vector2(18, 124)
_scroll.size = Vector2(664, 194)
_stack.custom_minimum_size = Vector2(648, 0)
for page in _pages:
page.custom_minimum_size = Vector2(648, 0)
page.add_theme_constant_override("separation", 6)
else:
_root.size = Vector2(380, 420)
_root.position = Vector2(-190, -210)
_title.visible = true
_title.position = Vector2(12, 8)
_info.position = Vector2(12, 32)
_info.remove_theme_font_size_override("font_size")
_mark.position = Vector2(320, 6)
_mark.size = Vector2(48, 36)
_tabrow.position = Vector2(12, 54)
_tabrow.size = Vector2(0, 0)
for b in _tab_btns:
b.custom_minimum_size = Vector2(84, 26)
b.remove_theme_font_size_override("font_size")
_scroll.custom_minimum_size = Vector2(356, 322)
_scroll.position = Vector2(12, 86)
_scroll.size = Vector2(356, 322)
_stack.custom_minimum_size = Vector2(350, 0)
for page in _pages:
page.custom_minimum_size = Vector2(350, 0)
page.add_theme_constant_override("separation", 3)
+75
View File
@@ -19,6 +19,7 @@
# 的窗口栈统一处理(关最顶层);H 键复用 `toggle()`。
extends Node
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
# helpwindow.py 的 name → uiScriptLocale key(逐字,含原脚本里重名的两个 `help_02`)。
const FLOAT_ROWS := {
"help_01": "HELP_MOVE_KEY",
@@ -99,6 +100,9 @@ var uiscript_dir := ""
var _win: Dictionary = {}
var _loc # Localeres://locale.gd
var _mobile_mode := false
var _mobile_root: Control
var _mobile_labels: Array[Label] = []
func setup(ui_manager: CanvasLayer, m2client: Node = null, assets := "") -> void:
ui = ui_manager
@@ -117,18 +121,37 @@ func setup(ui_manager: CanvasLayer, m2client: Node = null, assets := "") -> void
# --- open / close ---------------------------------------------------
func is_open() -> bool:
if _mobile_mode:
return _mobile_root != null and is_instance_valid(_mobile_root)
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
if not enabled and _mobile_root != null:
var old_mode := _mobile_mode
_mobile_mode = true
close()
_mobile_mode = old_mode
_mobile_mode = enabled
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if _mobile_mode:
if _mobile_root and is_instance_valid(_mobile_root) and ui:
ui.close(_mobile_root)
_mobile_root = null
_mobile_labels.clear()
return
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if _mobile_mode:
_open_mobile()
return
if is_open():
return
var path := uiscript_dir.path_join("helpwindow.py")
@@ -141,6 +164,45 @@ func open() -> void:
_relabel()
_wire()
func _open_mobile() -> void:
if is_open():
return
_mobile_root = Control.new()
_mobile_root.name = "MobileHelpWindow"
_mobile_root.size = Vector2(760, 340)
_mobile_root.set_meta("mobile_title", "帮助")
_mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_theme_stylebox_override("panel", _mobile_panel_style())
_mobile_root.add_child(panel)
var scroll := ScrollContainer.new()
scroll.position = Vector2(14, 48)
scroll.size = Vector2(732, 278)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.mouse_filter = Control.MOUSE_FILTER_STOP
_mobile_root.add_child(scroll)
var list := VBoxContainer.new()
list.custom_minimum_size = Vector2(710, 0)
list.add_theme_constant_override("separation", 4)
scroll.add_child(list)
_mobile_labels.clear()
for key in FLOAT_ROWS.values():
var row := Label.new()
row.text = "" + _text(String(key))
row.custom_minimum_size = Vector2(700, 28)
row.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
row.add_theme_font_size_override("font_size", 13)
row.add_theme_color_override("font_color", Color(0.82, 0.9, 1.0))
list.add_child(row)
_mobile_labels.append(row)
var task_hint := Label.new()
task_hint.text = "\n" + _text("HELP_QUICKSLOT") + " · " + _text("HELP_SYSTEM_BUTTON")
task_hint.add_theme_font_size_override("font_size", 11)
task_hint.add_theme_color_override("font_color", Color(0.66, 0.76, 0.9))
list.add_child(task_hint)
ui.open(_mobile_root)
func _node(nm: String) -> Control:
if _win.is_empty():
return null
@@ -181,6 +243,11 @@ func _text(key: String) -> String:
# 已绑定的帮助行文本(供自检 / 调试)。
func rows() -> Array:
if _mobile_mode:
var mobile_rows: Array = []
for key in FLOAT_ROWS.values():
mobile_rows.append(_text(String(key)))
return mobile_rows
var out: Array = []
for nm in FLOAT_ROWS:
var n := _node(nm)
@@ -195,3 +262,11 @@ func _wire() -> void:
if btn is BaseButton:
btn.pressed.connect(close)
# helpwindow.py2 的多页按钮在单页版不存在;此处不绑。
func _mobile_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.04, 0.065, 0.1, 0.98)
style.border_color = Color(0.52, 0.68, 0.9, 0.86)
style.set_border_width_all(1)
style.set_corner_radius_all(8)
return style
+277 -14
View File
@@ -67,6 +67,18 @@ var _drag_from := -1
var _combine_from := -1 # Shift 选中的“使用到物品”来源格
var _hint: Label
var _money_button: BaseButton
var _mobile_mode := false
var _mobile_selected_ui := -1
var _mobile_press_cell := -1
var _mobile_touch_index := -1
var _mobile_long_press_token := 0
var _mobile_detail_dialog: Control
var _mobile_dragging := false
var _mobile_drag_ui := -1
var _mobile_press_position_value := Vector2.ZERO
var _mobile_long_press_ready := false
const MOBILE_LONG_PRESS_SECONDS := 0.55
const MOBILE_DRAG_THRESHOLD := 14.0
func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: String,
il: RefCounted = null) -> void:
@@ -82,15 +94,35 @@ func setup(ui_manager: CanvasLayer, m2client: Node, proto_node: Node, assets: St
uiscript_dir = assets.path_join("uiscript")
if client and client.has_signal("inventory_changed"):
client.inventory_changed.connect(_on_inv_changed)
set_process_input(true)
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
mobile_selected_clear()
if enabled:
_set_hint("点按物品选择;再次点按使用,点另一格移动")
func mobile_selected() -> int:
return _mobile_selected_ui
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog) and ui:
ui.close(_mobile_detail_dialog)
_mobile_detail_dialog = null
_mobile_long_press_token += 1
_mobile_press_cell = -1
_mobile_touch_index = -1
_mobile_dragging = false
_mobile_drag_ui = -1
_mobile_press_position_value = Vector2.ZERO
_mobile_long_press_ready = false
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
@@ -100,6 +132,7 @@ func close() -> void:
_win = {}
_cells.clear()
_combine_from = -1
_mobile_selected_ui = -1
_hint = null
func open() -> void:
@@ -312,6 +345,22 @@ func _apply_belt_locks(eq: Array) -> void:
cell.tooltip_text = "需要腰带等级 %d" % BELT_RULES[local]
func _on_cell_input(ui_idx: int, e: InputEvent) -> void:
if _mobile_mode and e is InputEventScreenTouch:
var touch := e as InputEventScreenTouch
if touch.pressed:
_begin_mobile_cell_press(ui_idx, touch.index, touch.position)
elif touch.index == _mobile_touch_index:
_finish_mobile_cell_press(ui_idx, touch.index)
var cell: Panel = _cells.get(ui_idx, null)
if cell:
cell.accept_event()
return
if _mobile_mode and e is InputEventScreenDrag:
_handle_mobile_cell_drag(ui_idx, e as InputEventScreenDrag)
var drag_cell: Panel = _cells.get(ui_idx, null)
if drag_cell:
drag_cell.accept_event()
return
if not (e is InputEventMouseButton):
return
var cell: Panel = _cells[ui_idx]
@@ -361,6 +410,199 @@ func _on_cell_input(ui_idx: int, e: InputEvent) -> void:
move_to(_drag_from, over, 1)
_drag_from = -1
func _mobile_cell_tap(ui_idx: int) -> void:
var cell: Panel = _cells.get(ui_idx, null)
if cell == null or bool(cell.get_meta("locked", false)):
_set_hint("该格当前不可用")
return
var vnum := int(cell.get_meta("vnum", 0))
if _mobile_selected_ui < 0:
if vnum == 0:
_set_hint("这里没有物品")
return
_mobile_selected_ui = ui_idx
_set_hint("已选择 %s;再次点按使用,点另一格移动" % _item_label(ui_idx))
return
if _mobile_selected_ui == ui_idx:
use(ui_idx)
mobile_selected_clear()
return
var from := _mobile_selected_ui
mobile_selected_clear()
if move_to(from, ui_idx, 1):
_set_hint("已发送移动请求")
else:
_set_hint("物品移动请求发送失败")
func _begin_mobile_cell_press(ui_idx: int, touch_index: int,
position := Vector2(-1, -1)) -> void:
var cell: Panel = _cells.get(ui_idx, null)
if cell == null or bool(cell.get_meta("locked", false)):
return
_mobile_press_cell = ui_idx
_mobile_touch_index = touch_index
_mobile_dragging = false
_mobile_drag_ui = -1
_mobile_long_press_ready = false
var center: Vector2 = _cells[ui_idx].get_global_rect().get_center()
_mobile_press_position_value = position if position.x >= 0.0 and position.y >= 0.0 else center
_mobile_long_press_token += 1
var token := _mobile_long_press_token
get_tree().create_timer(MOBILE_LONG_PRESS_SECONDS).timeout.connect(func():
if token != _mobile_long_press_token or _mobile_press_cell != ui_idx:
return
_mobile_long_press_ready = true
_show_mobile_detail(ui_idx))
func _finish_mobile_cell_press(ui_idx: int, touch_index: int) -> void:
if touch_index != _mobile_touch_index or ui_idx != _mobile_press_cell:
return
if _mobile_dragging:
_mobile_dragging = false
_mobile_drag_ui = -1
_mobile_press_cell = -1
_mobile_touch_index = -1
_mobile_press_position_value = Vector2.ZERO
_mobile_long_press_ready = false
return
_mobile_long_press_token += 1
_mobile_touch_index = -1
_mobile_press_cell = -1
_mobile_long_press_ready = false
if _mobile_detail_dialog == null:
_mobile_cell_tap(ui_idx)
func _handle_mobile_cell_drag(ui_idx: int, event: InputEventScreenDrag) -> void:
if event.index != _mobile_touch_index or ui_idx != _mobile_press_cell:
return
var distance := event.position.distance_to(_mobile_press_position())
if _mobile_dragging:
return
# A long press first exposes the item detail, as on the reference tooltip
# path. Continuing to drag that same finger converts the gesture into a
# real item drag, so a user can move across inventory/equipment targets
# without relying on right-click or a tiny desktop drag handle.
if not _mobile_long_press_ready or distance < MOBILE_DRAG_THRESHOLD:
return
_start_mobile_drag(ui_idx, event.index, event.position)
func _input(event: InputEvent) -> void:
# Once a long press has opened the detail card, the card can cover the
# source cell and steal GUI events. Keep the source touch at the feature
# level so dragging out of that card still reaches MouseController.
if not _mobile_mode or _mobile_touch_index < 0 or _mobile_press_cell < 0:
return
if event is InputEventScreenDrag and event.index == _mobile_touch_index:
_handle_mobile_cell_drag(_mobile_press_cell, event as InputEventScreenDrag)
elif event is InputEventScreenTouch and not event.pressed \
and event.index == _mobile_touch_index:
_finish_mobile_cell_press(_mobile_press_cell, event.index)
func _mobile_press_position() -> Vector2:
return _mobile_press_position_value
func _start_mobile_drag(ui_idx: int, touch_index: int, position: Vector2) -> void:
var cell: Panel = _cells.get(ui_idx, null)
if cell == null or int(cell.get_meta("vnum", 0)) == 0:
return
if item_mouse == null or not item_mouse.has_method("attach_item"):
_set_hint("当前窗口不支持拖放")
return
var wire := _to_wire(ui_idx)
var metadata: Dictionary = client.get_item(wire[0], wire[1]) \
if client and client.has_method("get_item") else {}
if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog):
_close_mobile_detail()
_mobile_long_press_token += 1
_mobile_dragging = true
_mobile_drag_ui = ui_idx
item_mouse.attach_item(wire[0], wire[1], int(cell.get_meta("vnum", 0)),
_item_count(ui_idx), _icon(int(cell.get_meta("vnum", 0))), "inventory",
metadata, touch_index, position)
_set_hint("拖动中:松手放置到目标格")
func _show_mobile_detail(ui_idx: int) -> void:
var cell: Panel = _cells.get(ui_idx, null)
if cell == null or int(cell.get_meta("vnum", 0)) == 0 or ui == null:
return
if _mobile_detail_dialog and is_instance_valid(_mobile_detail_dialog):
ui.close(_mobile_detail_dialog)
var vnum := int(cell.get_meta("vnum", 0))
var count := int(cell.get_meta("count", 1))
var dialog := Panel.new()
dialog.name = "MobileItemDetail"
dialog.size = Vector2(360, 220)
dialog.set_meta("mobile_title", "物品详情")
dialog.set_meta("mobile_modal", true)
dialog.set_meta("mobile_modal_close", Callable(self, "_close_mobile_detail"))
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.04, 0.065, 0.1, 0.98)
bg.border_color = Color(0.72, 0.56, 0.25, 0.95)
bg.set_border_width_all(1)
bg.set_corner_radius_all(8)
dialog.add_theme_stylebox_override("panel", bg)
var title := Label.new()
title.text = _item_label(ui_idx)
title.position = Vector2(18, 52)
title.size = Vector2(324, 28)
title.add_theme_font_size_override("font_size", 15)
title.add_theme_color_override("font_color", Color(0.98, 0.84, 0.52))
dialog.add_child(title)
var detail := Label.new()
detail.text = "物品编号:%d\n数量:%d\n\n%s" % [vnum, count, String(cell.tooltip_text)]
detail.position = Vector2(18, 84)
detail.size = Vector2(324, 70)
detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
detail.add_theme_font_size_override("font_size", 10)
detail.add_theme_color_override("font_color", Color(0.78, 0.86, 0.96))
dialog.add_child(detail)
var select := Button.new()
select.text = "选择"
select.position = Vector2(18, 168)
select.size = Vector2(100, 40)
select.pressed.connect(func():
_close_mobile_detail()
_mobile_cell_tap(ui_idx))
dialog.add_child(select)
var use_button := Button.new()
use_button.text = "使用"
use_button.position = Vector2(130, 168)
use_button.size = Vector2(100, 40)
use_button.pressed.connect(func():
_close_mobile_detail()
use(ui_idx)
mobile_selected_clear())
dialog.add_child(use_button)
var cancel := Button.new()
cancel.text = "关闭"
cancel.position = Vector2(242, 168)
cancel.size = Vector2(100, 40)
cancel.pressed.connect(_close_mobile_detail)
dialog.add_child(cancel)
_mobile_detail_dialog = dialog
ui.open(dialog, true)
func _close_mobile_detail() -> void:
var dialog := _mobile_detail_dialog
_mobile_detail_dialog = null
if dialog and is_instance_valid(dialog) and ui:
ui.close(dialog)
func _item_label(ui_idx: int) -> String:
var cell: Panel = _cells.get(ui_idx, null)
if cell == null:
return "物品"
var vnum := int(cell.get_meta("vnum", 0))
if proto and proto.has_method("item") and vnum != 0:
var data: Dictionary = proto.item(vnum)
var name := String(data.get("locale_name", data.get("name", "")))
if name != "":
return name
return "#%d" % vnum
func mobile_selected_clear() -> void:
_mobile_selected_ui = -1
func _drop_mouse_item(payload: Dictionary, target_ui: int) -> bool:
if client == null or payload.is_empty() or target_ui < 0:
return false
@@ -516,7 +758,9 @@ func _ask_count(title_text: String, max_count: int, on_confirm: Callable) -> voi
return
var dialog := Panel.new()
dialog.position = Vector2(760, 420)
dialog.size = Vector2(240, 132)
dialog.size = Vector2(360, 210) if _mobile_mode else Vector2(240, 132)
if _mobile_mode:
dialog.set_meta("mobile_title", title_text)
var bg := StyleBoxFlat.new()
bg.bg_color = Color(0.05, 0.06, 0.09, 0.97)
bg.border_color = Color(0.7, 0.55, 0.22, 0.9)
@@ -524,28 +768,47 @@ func _ask_count(title_text: String, max_count: int, on_confirm: Callable) -> voi
dialog.add_theme_stylebox_override("panel", bg)
var title := Label.new()
title.text = title_text
title.position = Vector2(12, 10)
title.position = Vector2(18, 50) if _mobile_mode else Vector2(12, 10)
if _mobile_mode:
title.add_theme_font_size_override("font_size", 15)
dialog.add_child(title)
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = max(1, max_count)
spin.value = 1
spin.step = 1
spin.allow_greater = false
spin.position = Vector2(12, 42)
spin.size = Vector2(216, 28)
dialog.add_child(spin)
var amount_input: Control
if _mobile_mode:
var edit := LineEdit.new()
edit.name = "CountInput"
edit.text = "1"
edit.placeholder_text = "1 - %d" % max(1, max_count)
edit.max_length = 8
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.position = Vector2(18, 88)
edit.size = Vector2(324, 40)
amount_input = edit
dialog.add_child(edit)
else:
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = max(1, max_count)
spin.value = 1
spin.step = 1
spin.allow_greater = false
spin.position = Vector2(12, 42)
spin.size = Vector2(216, 28)
amount_input = spin
dialog.add_child(spin)
var ok := Button.new()
ok.text = "确认"
ok.position = Vector2(76, 88)
ok.position = Vector2(18, 150) if _mobile_mode else Vector2(76, 88)
ok.size = Vector2(154, 44) if _mobile_mode else Vector2(58, 28)
ok.pressed.connect(func():
var amount := int(spin.value)
var amount := int((amount_input as LineEdit).text) if amount_input is LineEdit else int((amount_input as SpinBox).value)
amount = clampi(amount, 1, max(1, max_count))
ui.close(dialog)
on_confirm.call(amount))
dialog.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.position = Vector2(142, 88)
cancel.position = Vector2(188, 150) if _mobile_mode else Vector2(142, 88)
cancel.size = Vector2(154, 44) if _mobile_mode else Vector2(58, 28)
cancel.pressed.connect(func(): ui.close(dialog))
dialog.add_child(cancel)
ui.open(dialog, true)
+1
View File
@@ -0,0 +1 @@
uid://dqv4o4srhhl45
+17 -1
View File
@@ -5,6 +5,7 @@ var client: Node
var _root: Panel
var _name: Label
var _bar: ProgressBar
var _mobile_mode := false
func setup(m2client: Node, parent: Node) -> void:
client = m2client
@@ -18,13 +19,28 @@ func refresh() -> void:
return
var lover: Dictionary = client.get_lover()
var valid := bool(lover.get("valid", false))
_root.visible = valid
_root.visible = valid and not _mobile_mode
if not valid:
return
_name.text = "" + String(lover.get("name", ""))
_bar.value = clampi(int(lover.get("love_point", 0)), 0, 100)
_bar.tooltip_text = "爱意值:%d%%" % int(_bar.value)
func get_mobile_window() -> Control:
return _root
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if enabled and _root:
_root.visible = false
func toggle() -> void:
if _root:
var valid := false
if client and client.has_method("get_lover"):
valid = bool(client.get_lover().get("valid", false))
_root.visible = valid and not _root.visible
func _build(parent: Node) -> void:
_root = Panel.new()
_root.name = "LoveStatus"
+34 -3
View File
@@ -24,6 +24,7 @@ var _grid: GridContainer
var _cells: Dictionary = {}
var _page_label: Label
var _page := 0
var _mobile_mode := false
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -41,6 +42,12 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
func get_mobile_window() -> Control:
return _root
func close() -> void:
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
@@ -136,6 +143,20 @@ func _on_grid_input(local_pos: int, event: InputEvent) -> void:
item_mouse.attach_item(MALL_WINDOW, _page * MALL_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "mall")
func _on_mobile_grid_tap(local_pos: int) -> void:
if not _mobile_mode or client == null or not _cells.has(local_pos):
return
var cell: Button = _cells[local_pos]
var vnum := int(cell.get_meta("vnum", 0))
if vnum == 0 or not client.has_method("mall_checkout"):
return
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
return
if not client.mall_checkout(_page * MALL_PAGE_SLOTS + local_pos, 1, target):
_status.text = "取出请求发送失败"
func _change_page(delta: int) -> void:
var pages := maxi(1, int(ceil(float(client.get_mall_size()) / float(MALL_PAGE_SLOTS))))
_page = clampi(_page + delta, 0, pages - 1)
@@ -153,7 +174,8 @@ func _ask_password() -> void:
edit.secret = true
edit.max_length = 6
edit.placeholder_text = "密码"
edit.custom_minimum_size = Vector2(220, 28)
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(220, 28)
_password_dialog.add_child(edit)
_password_dialog.confirmed.connect(func() -> void:
if not client.mall_password(edit.text) and is_instance_valid(_status):
@@ -163,8 +185,16 @@ func _ask_password() -> void:
_password_dialog.canceled.connect(func() -> void:
_password_dialog.queue_free()
_password_dialog = null)
_root.get_parent().add_child(_password_dialog)
_password_dialog.popup_centered()
if _mobile_mode:
_password_dialog.set_meta("mobile_modal", true)
_password_dialog.set_meta("mobile_title", "商城密码")
_password_dialog.min_size = Vector2i(460, 230)
_password_dialog.size = Vector2i(460, 230)
_root.add_child(_password_dialog)
if _mobile_mode:
_password_dialog.popup_centered(Vector2i(460, 230))
else:
_password_dialog.popup_centered()
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
@@ -252,6 +282,7 @@ func _build(parent: Node) -> void:
cell.custom_minimum_size = Vector2(58, 30)
cell.add_theme_font_size_override("font_size", 9)
cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
cell.pressed.connect(func(): _on_mobile_grid_tap(pos))
_grid.add_child(cell)
_cells[pos] = cell
var close := Button.new()
+33
View File
@@ -9,6 +9,8 @@
# 世界→图:Δ = blip.xz - player.xz,按 scale 转像素,(+x 右, -z 上),超出半径夹到边缘。
extends Node
signal map_pressed
const RADIUS := 70.0
const SCALE := 0.25 # 像素/米(越大越放大)
# CPythonMiniMap 的 waypoint 夹边留边:c_fMiniMapWindowRadius = min(w,h)/2 - 9.0f
@@ -77,6 +79,29 @@ func set_scale(pixels_per_meter: float) -> void:
func get_scale() -> float:
return _scale
# Reparent the same live minimap view into the mobile top-right HUD. The
# drawing/data path stays shared; only the presentation container changes.
func mount_mobile(host: Control) -> void:
if host == null or _root == null:
return
var old_parent := _root.get_parent()
if old_parent:
old_parent.remove_child(_root)
host.add_child(_root)
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
_root.position = Vector2(0, 0)
_root.size = Vector2(140, 156)
_root.scale = Vector2(0.64, 0.64)
_root.mouse_filter = Control.MOUSE_FILTER_STOP
func restore_desktop_layout() -> void:
if _root == null:
return
_root.scale = Vector2.ONE
_root.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_root.position = Vector2(-2 * RADIUS - 16, 12)
_root.size = Vector2(2 * RADIUS, 2 * RADIUS + 16)
# CPythonMiniMap::AddSignalPoint stores quest-script waypoints separately from
# server target markers. EventManager supplies absolute server centimetres.
func add_signal_point(x: float, y: float) -> void:
@@ -177,7 +202,15 @@ func _observer_position(vid: int, fallback: Vector3 = Vector3.ZERO) -> Vector3:
return cur
func _on_gui_input(event: InputEvent) -> void:
if event is InputEventScreenTouch and event.pressed:
map_pressed.emit()
_root.accept_event()
return
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
map_pressed.emit()
_root.accept_event()
return
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
set_scale(_scale * 1.15)
_root.accept_event()
+342
View File
@@ -0,0 +1,342 @@
# MobileHud —— landscape in-game HUD.
#
# This is deliberately a presentation-only node. NetPlay updates it through
# the same setter contract as hud.gd, while MobileUiRoot wires its buttons to
# the existing feature controllers.
extends Control
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
signal avatar_pressed
signal inventory_pressed
signal minimap_pressed
signal menu_pressed
signal quest_pressed
signal party_member_pressed(vid: int)
signal context_action_pressed
const AVATAR_SIZE := 78.0
const SAFE_MARGIN := 16.0
var client: Node
var _player_name: Label
var _avatar: TouchButton
var _hp_bar: ProgressBar
var _mp_bar: ProgressBar
var _hp_text: Label
var _mp_text: Label
var _level: Label
var _party_host: VBoxContainer
var _target_panel: Panel
var _target_name: Label
var _target_bar: ProgressBar
var _quest: TouchButton
var _context: TouchButton
var _mini_host: Control
var _mini_placeholder: Label
var _channel: Label
var _affects: HBoxContainer
var _client_bound := false
func setup() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_player_card()
_build_top_right()
_build_target()
_build_quest()
_build_context_action()
func bind_client(m2client: Node) -> void:
if _client_bound and client == m2client:
_refresh_player()
_refresh_party()
return
client = m2client
_client_bound = client != null
if client and client.has_signal("party_changed"):
client.party_changed.connect(_refresh_party)
if client and client.has_signal("vitals_changed"):
client.vitals_changed.connect(func(_vid): _refresh_party())
_refresh_player()
_refresh_party()
func set_player_name(value: String) -> void:
if _player_name:
_player_name.text = value if value != "" else "冒险者"
func get_minimap_host() -> Control:
return _mini_host
func get_quest_button() -> Control:
return _quest
func get_context_button() -> Control:
return _context
func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void:
if _hp_bar:
_hp_bar.max_value = maxi(1, max_hp)
_hp_bar.value = clampi(hp, 0, maxi(1, max_hp))
if _mp_bar:
_mp_bar.max_value = maxi(1, max_sp)
_mp_bar.value = clampi(sp, 0, maxi(1, max_sp))
if _hp_text:
_hp_text.text = "%d/%d" % [hp, max_hp]
if _mp_text:
_mp_text.text = "%d/%d" % [sp, max_sp]
func set_exp(xp: int, next_xp: int) -> void:
# The mobile card keeps the main HUD quiet; experience is shown in the
# level caption so it remains available without adding a bottom bar.
if _level and next_xp > 0:
_level.tooltip_text = "经验 %d / %d" % [xp, next_xp]
func set_level(value: int) -> void:
if _level:
_level.text = "Lv %d" % value
func set_energy(value: int, max_value: int = 100) -> void:
if _avatar:
_avatar.tooltip_text = "职业资源 %d/%d" % [value, max_value]
func set_stamina(value: int, max_value: int) -> void:
if _avatar:
_avatar.tooltip_text = "体力 %d/%d" % [value, max_value]
func set_affects(values: Array) -> void:
if _affects == null:
return
for child in _affects.get_children():
child.queue_free()
for value in values:
var chip := Label.new()
chip.text = ""
chip.add_theme_font_size_override("font_size", 11)
chip.add_theme_color_override("font_color", Color.from_hsv(
fmod(float(int(value.get("type", 0))) * 0.13, 1.0), 0.55, 0.95))
chip.tooltip_text = "状态 #%d" % int(value.get("type", 0))
_affects.add_child(chip)
func set_target(name: String, hp_pct: int) -> void:
if _target_panel == null:
_build_target()
_target_panel.visible = true
_target_name.text = name if name != "" else "目标"
_target_bar.value = clampi(hp_pct, 0, 100)
func clear_target() -> void:
if _target_panel:
_target_panel.visible = false
func set_channel(value: int) -> void:
if _channel:
_channel.text = "CH %d" % value
func set_dungeon_destination(active: bool, _world_pos: Vector3) -> void:
if _quest and active:
_quest.set_caption("副本目标")
func set_context_action(text: String, visible: bool) -> void:
if _context == null:
return
_context.set_caption(text)
_context.visible = visible
func _build_player_card() -> void:
var card := Control.new()
card.position = Vector2(SAFE_MARGIN, SAFE_MARGIN)
card.size = Vector2(110, 122)
add_child(card)
_player_name = Label.new()
_player_name.text = "冒险者"
_player_name.position = Vector2(0, 0)
_player_name.size = Vector2(AVATAR_SIZE + 10, 20)
_player_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_player_name.add_theme_font_size_override("font_size", 13)
_player_name.add_theme_color_override("font_color", Color(0.98, 0.88, 0.61))
_player_name.add_theme_color_override("font_outline_color", Color(0.01, 0.02, 0.04, 0.95))
_player_name.add_theme_constant_override("outline_size", 4)
card.add_child(_player_name)
_avatar = TouchButton.new()
_avatar.setup("\n", Color(0.92, 0.72, 0.32, 0.95))
_avatar.position = Vector2(5, 20)
_avatar.size = Vector2(AVATAR_SIZE, AVATAR_SIZE)
_avatar.pressed.connect(func(): avatar_pressed.emit())
card.add_child(_avatar)
_hp_bar = _bar(Color(0.86, 0.24, 0.22), Vector2(AVATAR_SIZE, 9))
_hp_bar.position = Vector2(5, 100)
card.add_child(_hp_bar)
_hp_text = _bar_text(_hp_bar)
_mp_bar = _bar(Color(0.24, 0.48, 0.93), Vector2(AVATAR_SIZE, 7))
_mp_bar.position = Vector2(5, 111)
card.add_child(_mp_bar)
_mp_text = _bar_text(_mp_bar)
_level = Label.new()
_level.text = "Lv 1"
_level.position = Vector2(86, 27)
_level.add_theme_font_size_override("font_size", 11)
_level.add_theme_color_override("font_color", Color(0.78, 0.86, 0.98))
card.add_child(_level)
_party_host = VBoxContainer.new()
_party_host.position = Vector2(0, 126)
_party_host.size = Vector2(196, 180)
_party_host.add_theme_constant_override("separation", 4)
add_child(_party_host)
_affects = HBoxContainer.new()
_affects.position = Vector2(5, 121)
_affects.add_theme_constant_override("separation", 3)
card.add_child(_affects)
func _build_top_right() -> void:
var actions := Control.new()
actions.set_anchors_preset(Control.PRESET_TOP_RIGHT)
actions.position = Vector2(-250, SAFE_MARGIN)
actions.size = Vector2(234, 98)
add_child(actions)
var bag := TouchButton.new()
bag.setup("背包", Color(0.95, 0.72, 0.34, 0.95))
bag.position = Vector2(0, 18)
bag.size = Vector2(56, 56)
bag.pressed.connect(func(): inventory_pressed.emit())
actions.add_child(bag)
_mini_host = Control.new()
_mini_host.position = Vector2(62, 0)
_mini_host.size = Vector2(96, 96)
_mini_host.mouse_filter = Control.MOUSE_FILTER_IGNORE
actions.add_child(_mini_host)
_mini_placeholder = Label.new()
_mini_placeholder.text = "地图"
_mini_placeholder.position = Vector2(28, 38)
_mini_placeholder.add_theme_font_size_override("font_size", 12)
_mini_placeholder.add_theme_color_override("font_color", Color(0.8, 0.9, 1.0, 0.85))
_mini_placeholder.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mini_host.add_child(_mini_placeholder)
var map_button := TouchButton.new()
map_button.setup("", Color(0.45, 0.7, 0.95, 0.95))
map_button.position = Vector2(62, 0)
map_button.size = Vector2(96, 96)
map_button.set_idle_modulate(Color(1, 1, 1, 0.03))
map_button.pressed.connect(func(): minimap_pressed.emit())
actions.add_child(map_button)
var menu := TouchButton.new()
menu.setup("菜单", Color(0.58, 0.74, 0.96, 0.95))
menu.position = Vector2(164, 18)
menu.size = Vector2(56, 56)
menu.pressed.connect(func(): menu_pressed.emit())
actions.add_child(menu)
_channel = Label.new()
_channel.text = "CH -"
_channel.position = Vector2(75, 98)
_channel.add_theme_font_size_override("font_size", 10)
_channel.add_theme_color_override("font_color", Color(0.64, 0.75, 0.9))
actions.add_child(_channel)
func _build_target() -> void:
_target_panel = Panel.new()
_target_panel.set_anchors_preset(Control.PRESET_CENTER_TOP)
_target_panel.position = Vector2(-112, 14)
_target_panel.size = Vector2(224, 48)
_target_panel.visible = false
_target_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
_target_panel.add_theme_stylebox_override("panel", _panel_style(Color(0.12, 0.05, 0.06, 0.84), Color(0.82, 0.3, 0.3)))
add_child(_target_panel)
_target_name = Label.new()
_target_name.position = Vector2(10, 4)
_target_name.size = Vector2(204, 18)
_target_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_target_name.add_theme_font_size_override("font_size", 12)
_target_panel.add_child(_target_name)
_target_bar = _bar(Color(0.88, 0.24, 0.23), Vector2(204, 9))
_target_bar.position = Vector2(10, 28)
_target_bar.max_value = 100
_target_bar.value = 100
_target_panel.add_child(_target_bar)
func _build_quest() -> void:
_quest = TouchButton.new()
_quest.setup("任务 · 点击查看", Color(1.0, 0.72, 0.3, 0.9))
_quest.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_quest.position = Vector2(-244, 126)
_quest.size = Vector2(224, 45)
_quest.pressed.connect(func(): quest_pressed.emit())
add_child(_quest)
func _build_context_action() -> void:
_context = TouchButton.new()
_context.setup("交互", Color(0.92, 0.68, 0.31, 0.95))
_context.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
_context.position = Vector2(110, -116)
_context.size = Vector2(74, 48)
_context.visible = false
_context.pressed.connect(func(): context_action_pressed.emit())
add_child(_context)
func _refresh_player() -> void:
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
return
var entity: Dictionary = client.get_entity(client.get_main_vid())
set_player_name(String(entity.get("name", "冒险者")))
set_level(int(entity.get("level", 1)))
func _refresh_party() -> void:
if _party_host == null:
return
for child in _party_host.get_children():
child.queue_free()
if client == null or not client.has_method("get_party"):
return
var members: Array = client.get_party()
for member in members:
var vid := int(member.get("vid", 0))
var row := TouchButton.new()
row.setup(" %s %d%%" % [String(member.get("name", "队员")), int(member.get("hp_pct", 0))],
Color(0.32, 0.52, 0.76, 0.8))
row.set_rect_style()
row.custom_minimum_size = Vector2(184, 29)
row.size = Vector2(184, 29)
row.pressed.connect(func(): party_member_pressed.emit(vid))
_party_host.add_child(row)
func _bar(color: Color, bar_size: Vector2) -> ProgressBar:
var bar := ProgressBar.new()
bar.custom_minimum_size = bar_size
bar.size = bar_size
bar.min_value = 0
bar.max_value = 100
bar.value = 100
bar.show_percentage = false
bar.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_theme_stylebox_override("background", _panel_style(Color(0.01, 0.02, 0.04, 0.78), Color(0.1, 0.14, 0.2, 0.6), 2))
bar.add_theme_stylebox_override("fill", _panel_style(color, color, 2))
return bar
func _bar_text(bar: ProgressBar) -> Label:
var label := Label.new()
label.set_anchors_preset(Control.PRESET_FULL_RECT)
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 8)
label.add_theme_color_override("font_color", Color(0.96, 0.98, 1.0))
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
bar.add_child(label)
return label
func _panel_style(bg: Color, border: Color, width := 1) -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = bg
style.border_color = border
style.set_border_width_all(width)
style.set_corner_radius_all(6)
return style
+1
View File
@@ -0,0 +1 @@
uid://bh0jqy5qycvad
+222
View File
@@ -0,0 +1,222 @@
# MobileInputOverlay —— touch controls layered over the world.
#
# Child controls consume their own touch events, so PlayerController and
# GameCamera continue receiving unhandled world touches for tap-to-move,
# camera orbit and pinch zoom.
extends Control
const Joystick := preload("res://ui/mobile/virtual_joystick.gd")
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
var player_controller: Node
var net_play: Node
var quickbar: Node
var ground_items: Node
var _joystick: Joystick
var _attack: TouchButton
var _potion: TouchButton
var _skills: Array[TouchButton] = []
var _ui_touch_indices := {}
var _aim_slot := -1
var _aim_direction := Vector2.ZERO
var _skip_skill_release := {}
var _aim_hint: Label
func setup() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_joystick()
_build_combat_pad()
_aim_hint = Label.new()
_aim_hint.name = "SkillAimHint"
_aim_hint.set_anchors_preset(Control.PRESET_CENTER_TOP)
_aim_hint.position = Vector2(-170, 70)
_aim_hint.size = Vector2(340, 30)
_aim_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_aim_hint.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_aim_hint.add_theme_font_size_override("font_size", 12)
_aim_hint.add_theme_color_override("font_color", Color(1.0, 0.86, 0.52))
_aim_hint.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95))
_aim_hint.add_theme_constant_override("outline_size", 4)
_aim_hint.mouse_filter = Control.MOUSE_FILTER_IGNORE
_aim_hint.visible = false
add_child(_aim_hint)
set_process_input(true)
set_process_unhandled_input(true)
func bind_controls(pc: Node, np: Node, qb: Node, ground: Node = null) -> void:
player_controller = pc
net_play = np
quickbar = qb
ground_items = ground
if _joystick:
if not _joystick.axis_changed.is_connected(_on_axis):
_joystick.axis_changed.connect(_on_axis)
if _attack:
if not _attack.press_state.is_connected(_on_attack_state):
_attack.press_state.connect(_on_attack_state)
func _build_joystick() -> void:
_joystick = Joystick.new()
_joystick.setup(172.0)
_joystick.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
_joystick.position = Vector2(20, -192)
_joystick.size = Vector2(172, 172)
add_child(_joystick)
func _build_combat_pad() -> void:
_attack = TouchButton.new()
_attack.setup("普攻", Color(1.0, 0.42, 0.25, 0.98))
_attack.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_attack.position = Vector2(-126, -168)
_attack.size = Vector2(112, 112)
add_child(_attack)
_potion = TouchButton.new()
_potion.setup("", Color(0.38, 0.88, 0.58, 0.96))
_potion.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
_potion.position = Vector2(-388, -72)
_potion.size = Vector2(52, 52)
_potion.pressed.connect(func():
# The first page's slot 5 is the conventional consumable slot in the
# existing quickbar. Quickbar still validates item availability/server state.
if quickbar and quickbar.has_method("activate"):
quickbar.activate(4))
add_child(_potion)
for slot in 4:
var skill := TouchButton.new()
skill.setup("%d" % (slot + 1), Color(0.34, 0.65, 0.98, 0.96))
skill.set_gesture_enabled(true)
skill.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
skill.position = Vector2(-330 + slot * 58, -72)
skill.size = Vector2(52, 52)
var captured := slot
skill.long_pressed.connect(func(): _begin_skill_aim(captured, skill))
skill.drag_changed.connect(func(_delta: Vector2): _update_skill_aim(captured, skill))
skill.drag_released.connect(func(_delta: Vector2): _release_skill_aim(captured, skill))
skill.released.connect(func(): _finish_short_skill_press(captured, skill))
add_child(skill)
_skills.append(skill)
func _on_axis(axis: Vector2) -> void:
if player_controller and player_controller.has_method("set_mobile_axis"):
player_controller.set_mobile_axis(axis)
func _on_attack_state(down: bool) -> void:
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(down)
func _begin_skill_aim(slot: int, button: TouchButton) -> void:
if _aim_slot >= 0 and _aim_slot != slot:
return
_aim_slot = slot
_aim_direction = Vector2.ZERO
button.set_aiming(true)
if _aim_hint:
_aim_hint.text = "拖动调整方向,松手释放"
_aim_hint.visible = true
func _update_skill_aim(slot: int, button: TouchButton) -> void:
if _aim_slot != slot:
return
var total := button.drag_total()
if total.length() >= TouchButton.DRAG_THRESHOLD:
# Screen Y grows downwards; keep the conventional joystick direction
# (up = forward) for Quickbar.activate_aimed().
_aim_direction = Vector2(total.x, total.y).normalized()
if _aim_hint:
_aim_hint.text = "方向 %d° · 松手释放" % int(round(rad_to_deg(atan2(-_aim_direction.x, -_aim_direction.y))))
func _release_skill_aim(slot: int, button: TouchButton) -> void:
if _aim_slot != slot:
return
_skip_skill_release[slot] = true
if quickbar:
if quickbar.has_method("activate_aimed"):
quickbar.activate_aimed(slot, _aim_direction)
elif quickbar.has_method("activate"):
quickbar.activate(slot)
_end_skill_aim(button)
func _finish_short_skill_press(slot: int, button: TouchButton) -> void:
if bool(_skip_skill_release.get(slot, false)):
_skip_skill_release.erase(slot)
return
if _aim_slot == slot:
_end_skill_aim(button)
return
if quickbar and quickbar.has_method("activate"):
quickbar.activate(slot)
func _end_skill_aim(button: TouchButton = null) -> void:
if button:
button.set_aiming(false)
else:
for skill in _skills:
if skill:
skill.set_aiming(false)
_aim_slot = -1
_aim_direction = Vector2.ZERO
if _aim_hint:
_aim_hint.visible = false
func cancel_all() -> void:
if _joystick:
_joystick.cancel_press()
if _attack:
_attack.cancel_press()
if _potion:
_potion.cancel_press()
for skill in _skills:
if skill:
skill.cancel_press()
_end_skill_aim()
_skip_skill_release.clear()
for index in _ui_touch_indices.keys():
if player_controller and player_controller.has_method("set_mobile_ui_touch"):
player_controller.set_mobile_ui_touch(int(index), false)
_ui_touch_indices.clear()
if player_controller:
if player_controller.has_method("clear_mobile_input"):
player_controller.clear_mobile_input()
elif player_controller.has_method("set_mobile_axis"):
player_controller.set_mobile_axis(Vector2.ZERO)
if net_play and net_play.has_method("set_attack_key"):
net_play.set_attack_key(false)
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT \
or what == NOTIFICATION_APPLICATION_PAUSED \
or what == NOTIFICATION_WM_WINDOW_FOCUS_OUT:
cancel_all()
func _input(event: InputEvent) -> void:
if not player_controller or not player_controller.has_method("set_mobile_ui_touch"):
return
if event is InputEventScreenTouch:
var touch := event as InputEventScreenTouch
if touch.pressed:
var owns := _control_at(touch.position) != null
if owns:
_ui_touch_indices[touch.index] = true
player_controller.set_mobile_ui_touch(touch.index, true)
elif _ui_touch_indices.has(touch.index):
_ui_touch_indices.erase(touch.index)
player_controller.set_mobile_ui_touch(touch.index, false)
func _control_at(screen_position: Vector2) -> Control:
var controls: Array[Control] = []
if _joystick:
controls.append(_joystick)
if _attack:
controls.append(_attack)
if _potion:
controls.append(_potion)
for skill in _skills:
if skill:
controls.append(skill)
for control in controls:
if control.visible and control.get_global_rect().has_point(screen_position):
return control
return null
@@ -0,0 +1 @@
uid://bqyoh0sj72ro1
+154
View File
@@ -0,0 +1,154 @@
# MobileMenuDrawer —— compact two-level menu for the top-right menu button.
#
# The drawer only routes actions. Feature nodes remain the owners of data,
# server state and permission checks.
extends Control
const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd")
signal item_selected(category: String, item: String)
signal opened
signal closed
const CATEGORIES := ["社交", "成长", "活动", "商业", "系统"]
const ITEMS := {
"社交": ["聊天", "好友", "公会", "情侣", "观战"],
"成长": ["精炼", "龙魂", "Cube 制作"],
"活动": ["副本", "钓鱼"],
"商业": ["商店", "商城", "交易", "私人商店", "仓库", "兑换"],
"系统": ["系统设置", "游戏设置", "帮助", "选择角色", "登出", "退出游戏"],
}
var _backdrop: ColorRect
var _panel: Panel
var _category_row: HBoxContainer
var _item_scroll: ScrollContainer
var _items: VBoxContainer
var _title: Label
var _hint: Label
var _active_category := "社交"
func setup() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build()
visible = false
func is_open() -> bool:
return visible
func toggle() -> void:
if visible:
close()
else:
open()
func open() -> void:
if visible:
return
visible = true
_refresh_items()
opened.emit()
func close() -> void:
if not visible:
return
visible = false
closed.emit()
func _build() -> void:
_backdrop = ColorRect.new()
_backdrop.color = Color(0.01, 0.02, 0.035, 0.38)
_backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP
_backdrop.gui_input.connect(func(event: InputEvent):
if event is InputEventScreenTouch and not event.pressed:
close()
elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed:
close())
add_child(_backdrop)
_panel = Panel.new()
_panel.set_anchors_preset(Control.PRESET_TOP_RIGHT)
_panel.position = Vector2(-366, 72)
_panel.size = Vector2(350, 300)
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override("panel", _panel_style())
add_child(_panel)
_title = Label.new()
_title.position = Vector2(18, 13)
_title.add_theme_font_size_override("font_size", 18)
_title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.52))
_panel.add_child(_title)
var close_button := TouchButton.new()
close_button.setup("×", Color(0.6, 0.75, 0.96, 0.9))
close_button.set_rect_style()
close_button.position = Vector2(310, 7)
close_button.size = Vector2(32, 32)
close_button.custom_minimum_size = Vector2(32, 32)
close_button.pressed.connect(close)
_panel.add_child(close_button)
_category_row = HBoxContainer.new()
_category_row.position = Vector2(14, 48)
_category_row.size = Vector2(322, 38)
_category_row.add_theme_constant_override("separation", 4)
_panel.add_child(_category_row)
for category in CATEGORIES:
var button := TouchButton.new()
button.setup(category, Color(0.45, 0.7, 0.95, 0.9))
button.set_rect_style()
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(0, 36)
var selected: String = String(category)
button.pressed.connect(func():
_active_category = selected
_refresh_items())
_category_row.add_child(button)
_item_scroll = ScrollContainer.new()
_item_scroll.position = Vector2(16, 96)
_item_scroll.size = Vector2(318, 160)
_item_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_item_scroll.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_child(_item_scroll)
_items = VBoxContainer.new()
_items.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_items.add_theme_constant_override("separation", 5)
_item_scroll.add_child(_items)
_hint = Label.new()
_hint.position = Vector2(18, 264)
_hint.size = Vector2(314, 26)
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_hint.add_theme_font_size_override("font_size", 11)
_hint.add_theme_color_override("font_color", Color(0.66, 0.72, 0.8))
_panel.add_child(_hint)
func _refresh_items() -> void:
if _panel == null:
return
_title.text = _active_category
_hint.text = "功能入口仍受当前场景和服务器状态限制。"
for child in _items.get_children():
child.queue_free()
for item in ITEMS.get(_active_category, []):
var button := TouchButton.new()
button.setup(" " + String(item), Color(0.42, 0.65, 0.9, 0.8))
button.set_rect_style()
button.custom_minimum_size = Vector2(0, 34)
var selected: String = String(item)
button.pressed.connect(func(): item_selected.emit(_active_category, selected))
_items.add_child(button)
func _panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.045, 0.065, 0.1, 0.96)
style.border_color = Color(0.52, 0.68, 0.9, 0.82)
style.set_border_width_all(1)
style.set_corner_radius_all(12)
style.shadow_color = Color(0, 0, 0, 0.45)
style.shadow_size = 12
return style
@@ -0,0 +1 @@
uid://c2ut2qatd5mbu
+226
View File
@@ -0,0 +1,226 @@
# MobileTouchButton —— a mouse and screen-touch friendly action surface.
#
# BaseButton's mouse emulation is intentionally disabled in project.godot, so
# the mobile layer owns the small amount of press/release handling it needs.
# This keeps a touch index alive until release and gives gameplay a reliable
# cancellation path when the app loses focus.
extends Control
signal pressed
signal released
signal press_state(down: bool)
signal long_pressed
signal drag_started
signal drag_changed(delta: Vector2)
signal drag_released(delta: Vector2)
var label_text := ""
var accent := Color(0.36, 0.64, 0.95, 0.95)
var idle_modulate := Color.WHITE
var circular := true
var active := false
var _touch_index := -1
var _caption: Label
var _press_position := Vector2.ZERO
var _last_position := Vector2.ZERO
var _drag_delta := Vector2.ZERO
var _long_press_token := 0
var _long_pressed := false
var _dragging := false
var _aiming := false
const LONG_PRESS_SECONDS := 0.55
const DRAG_THRESHOLD := 10.0
func setup(text: String, color: Color = Color(0.36, 0.64, 0.95, 0.95)) -> void:
label_text = text
accent = color
mouse_filter = Control.MOUSE_FILTER_STOP
custom_minimum_size = Vector2(56, 56)
_caption = Label.new()
_caption.text = label_text
_caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
_caption.set_anchors_preset(Control.PRESET_FULL_RECT)
_caption.mouse_filter = Control.MOUSE_FILTER_IGNORE
_caption.add_theme_font_size_override("font_size", 13)
_caption.add_theme_color_override("font_color", Color(0.94, 0.97, 1.0))
_caption.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95))
_caption.add_theme_constant_override("outline_size", 3)
add_child(_caption)
queue_redraw()
func set_caption(text: String) -> void:
label_text = text
if _caption:
_caption.text = text
func set_idle_modulate(value: Color) -> void:
idle_modulate = value
modulate = value if not active else Color(0.82, 0.9, 1.0, value.a)
## Enables the long-press/drag gesture used by mobile skill aiming and
## inventory movement. The ordinary pressed/released signals remain intact
## for buttons that do not need a gesture.
func set_gesture_enabled(enabled: bool) -> void:
set_meta("gesture_enabled", enabled)
func was_long_pressed() -> bool:
return _long_pressed
func is_dragging() -> bool:
return _dragging
func drag_total() -> Vector2:
return _drag_delta
func drag_end_position() -> Vector2:
return _last_position
func set_aiming(value: bool) -> void:
_aiming = value
queue_redraw()
func set_rect_style() -> void:
circular = false
queue_redraw()
func _ready() -> void:
set_process_unhandled_input(true)
queue_redraw()
func _gui_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed:
if _touch_index < 0:
_touch_index = event.index
_begin_press(_event_position(event))
else:
if event.index == _touch_index:
_touch_index = -1
_end_press(_event_position(event))
accept_event()
return
if event is InputEventScreenDrag and event.index == _touch_index:
_update_drag(_event_position(event))
accept_event()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_touch_index = -2
_begin_press(_event_position(event))
else:
_touch_index = -1
_end_press(_event_position(event))
accept_event()
return
if event is InputEventMouseMotion and _touch_index == -2:
_update_drag(_event_position(event))
accept_event()
func _event_position(event: InputEvent) -> Vector2:
if event is InputEventMouse:
return (event as InputEventMouse).position
if event is InputEventScreenTouch:
return (event as InputEventScreenTouch).position
if event is InputEventScreenDrag:
return (event as InputEventScreenDrag).position
return Vector2.ZERO
func _begin_press(position: Vector2) -> void:
if active:
return
active = true
_press_position = position
_last_position = position
_drag_delta = Vector2.ZERO
_long_pressed = false
_dragging = false
_aiming = false
_long_press_token += 1
var token := _long_press_token
if bool(get_meta("gesture_enabled", false)) and get_tree() != null:
get_tree().create_timer(LONG_PRESS_SECONDS).timeout.connect(func():
if token != _long_press_token or not active or _long_pressed:
return
_long_pressed = true
_dragging = true
long_pressed.emit()
queue_redraw())
modulate = Color(0.82, 0.9, 1.0, idle_modulate.a)
pressed.emit()
press_state.emit(true)
queue_redraw()
func _update_drag(position: Vector2) -> void:
if not active:
return
var delta := position - _last_position
_last_position = position
_drag_delta = position - _press_position
if not bool(get_meta("gesture_enabled", false)):
return
if not _long_pressed:
if _drag_delta.length() > DRAG_THRESHOLD:
_long_press_token += 1
return
if not _dragging:
_dragging = true
drag_started.emit()
drag_changed.emit(delta)
queue_redraw()
func _end_press(position: Vector2) -> void:
if not active:
return
_update_drag(position)
_long_press_token += 1
if _dragging and _long_pressed:
drag_released.emit(_drag_delta)
active = false
modulate = idle_modulate
released.emit()
press_state.emit(false)
queue_redraw()
func cancel_press() -> void:
_long_press_token += 1
_touch_index = -1
if active:
# A cancelled gesture must never look like a completed drag to its
# receiver. Clear the gesture flags before ending the visual press.
_dragging = false
_long_pressed = false
_aiming = false
_end_press(_last_position)
else:
_long_pressed = false
_dragging = false
_aiming = false
func _unhandled_input(event: InputEvent) -> void:
# A focus loss / window pause can leave a touch-up event outside the
# control. MobileUiRoot also calls cancel_all() for the full overlay.
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
_touch_index = -1
_end_press(_last_position)
func _draw() -> void:
if not circular:
var rect_style := StyleBoxFlat.new()
rect_style.bg_color = Color(0.06, 0.1, 0.16, 0.86)
rect_style.border_color = accent
rect_style.set_border_width_all(1)
rect_style.set_corner_radius_all(6)
draw_style_box(rect_style, Rect2(Vector2.ZERO, size))
return
var center := size * 0.5
var radius := minf(size.x, size.y) * 0.5 - 3.0
if radius <= 0.0:
return
draw_circle(center, radius, Color(0.025, 0.04, 0.07, 0.68))
draw_arc(center, radius, 0.0, TAU, 48, accent, 3.0 if active or _aiming else 2.0)
draw_arc(center, radius - 5.0, 0.0, TAU, 48, Color(accent, 0.22), 1.0)
if _aiming:
draw_circle(center, 5.0, Color(1.0, 0.88, 0.52, 0.9))
draw_arc(center, radius - 12.0, -PI * 0.5, PI * 0.5, 24,
Color(1.0, 0.88, 0.52, 0.8), 2.0)
@@ -0,0 +1 @@
uid://dpg433tb2c1qn
+453
View File
@@ -0,0 +1,453 @@
# 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
+1
View File
@@ -0,0 +1 @@
uid://d0etc5yr8cuuy
+163
View File
@@ -0,0 +1,163 @@
# MobileWindowHost —— touch-safe presentation for existing feature windows.
#
# Feature controllers keep ownership of data, packets and permissions. This
# helper only gives their Control roots a landscape-safe frame, a predictable
# back/close affordance and a screen-touch bridge for legacy Button nodes.
extends RefCounted
const NAV_HEIGHT := 40.0
const EDGE_MARGIN := 12.0
const MIN_SCALE := 0.42
static func safe_rect(viewport: Viewport) -> Rect2:
if viewport == null:
return Rect2(Vector2.ZERO, Vector2(844, 390))
var viewport_size := viewport.get_visible_rect().size
return safe_rect_for(viewport_size, DisplayServer.get_display_safe_area())
## Resolve a platform-reported safe area against the current logical viewport.
## Keeping this pure makes the edge cases testable without an Android/iOS
## device and avoids trusting desktop backends that return physical-screen
## coordinates unrelated to the current viewport.
static func safe_rect_for(viewport_size: Vector2, reported: Rect2) -> Rect2:
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
return Rect2(Vector2.ZERO, viewport_size)
if reported.size.x <= 0.0 or reported.size.y <= 0.0:
return Rect2(Vector2.ZERO, viewport_size)
var result := reported
# Some desktop backends report a screen-space safe area larger than the
# current viewport. Treat that as unavailable rather than moving windows
# outside the viewport.
if result.size.x < 1.0 or result.size.y < 1.0:
return Rect2(Vector2.ZERO, viewport_size)
if result.position.x < 0.0 or result.position.y < 0.0 \
or result.end.x > viewport_size.x + 1.0 \
or result.end.y > viewport_size.y + 1.0:
return Rect2(Vector2.ZERO, viewport_size)
return result
static func content_size(root: Control) -> Vector2:
if root == null:
return Vector2(320, 240)
var result := root.size
if result.x <= 1.0 or result.y <= 1.0:
result = root.get_combined_minimum_size()
if result.x <= 1.0 or result.y <= 1.0:
result = Vector2(320, 240)
return result
static func install(root: Control, area: Rect2, title: String,
close_cb: Callable, back_cb: Callable = Callable()) -> Dictionary:
if root == null:
return {}
var base_size := content_size(root)
var available := area.size - Vector2(EDGE_MARGIN * 2.0, EDGE_MARGIN * 2.0)
var scale_value := minf(available.x / base_size.x, available.y / base_size.y)
scale_value = maxf(MIN_SCALE, minf(1.0, scale_value))
root.set_anchors_preset(Control.PRESET_TOP_LEFT)
root.size = base_size
root.pivot_offset = Vector2.ZERO
root.scale = Vector2.ONE * scale_value
root.position = area.position + (area.size - base_size * scale_value) * 0.5
root.set_meta("mobile_hosted", true)
root.set_meta("mobile_base_size", base_size)
root.set_meta("mobile_scale", scale_value)
var nav := root.get_node_or_null("__mobile_nav") as Control
if nav == null:
nav = Panel.new()
nav.name = "__mobile_nav"
nav.mouse_filter = Control.MOUSE_FILTER_STOP
nav.z_index = 100
var nav_style := StyleBoxFlat.new()
nav_style.bg_color = Color(0.025, 0.045, 0.08, 0.96)
nav_style.border_color = Color(0.36, 0.6, 0.92, 0.82)
nav_style.border_width_bottom = 1
nav.add_theme_stylebox_override("panel", nav_style)
root.add_child(nav)
nav.position = Vector2.ZERO
nav.size = Vector2(base_size.x, NAV_HEIGHT)
nav.visible = true
var title_label := nav.get_node_or_null("Title") as Label
if title_label == null:
title_label = Label.new()
title_label.name = "Title"
title_label.position = Vector2(88, 0)
title_label.size = Vector2(maxf(80.0, base_size.x - 176.0), NAV_HEIGHT)
title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title_label.add_theme_font_size_override("font_size", 15)
title_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58))
title_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
nav.add_child(title_label)
title_label.text = title if title != "" else String(root.name)
var back := nav.get_node_or_null("Back") as Button
if back == null:
back = Button.new()
back.name = "Back"
back.text = "返回"
back.position = Vector2(8, 4)
back.size = Vector2(70, 32)
back.add_theme_font_size_override("font_size", 12)
nav.add_child(back)
if not back.has_meta("mobile_host_wired"):
back.set_meta("mobile_host_wired", true)
back.pressed.connect(func():
if back_cb.is_valid():
back_cb.call()
elif close_cb.is_valid():
close_cb.call())
var close := nav.get_node_or_null("Close") as Button
if close == null:
close = Button.new()
close.name = "Close"
close.text = "×"
close.add_theme_font_size_override("font_size", 18)
nav.add_child(close)
if not close.has_meta("mobile_host_wired"):
close.set_meta("mobile_host_wired", true)
close.pressed.connect(func(): if close_cb.is_valid(): close_cb.call())
close.position = Vector2(maxf(78.0, base_size.x - 78.0), 4)
close.size = Vector2(70, 32)
_wire_buttons(root)
return {"size": base_size, "scale": scale_value, "area": area}
static func uninstall(root: Control) -> void:
if root == null:
return
var nav := root.get_node_or_null("__mobile_nav") as Control
if nav:
nav.visible = false
root.set_meta("mobile_hosted", false)
static func wire_buttons(root: Node) -> void:
if root:
_wire_buttons(root)
static func _wire_buttons(root: Node) -> void:
for node in root.find_children("*", "BaseButton", true, false):
var button := node as BaseButton
if button == null or button.has_meta("mobile_touch_wired"):
continue
button.set_meta("mobile_touch_wired", true)
button.gui_input.connect(func(event: InputEvent):
_handle_button_touch(button, event))
static func _handle_button_touch(button: BaseButton, event: InputEvent) -> void:
if button == null or not is_instance_valid(button):
return
if event is InputEventScreenTouch:
var touch := event as InputEventScreenTouch
if touch.pressed:
if int(button.get_meta("mobile_touch_index", -1)) < 0:
button.set_meta("mobile_touch_index", touch.index)
else:
if int(button.get_meta("mobile_touch_index", -1)) == touch.index:
button.set_meta("mobile_touch_index", -1)
if not button.disabled:
button.pressed.emit()
button.accept_event()
@@ -0,0 +1 @@
uid://cqvgte43jbj2a
+86
View File
@@ -0,0 +1,86 @@
# VirtualJoystick —— continuous left-thumb movement input.
extends Control
signal axis_changed(axis: Vector2)
const DEAD_ZONE := 0.12
const KNOB_RADIUS := 29.0
var _touch_index := -1
var _axis := Vector2.ZERO
var _knob := Vector2.ZERO
func setup(diameter := 164.0) -> void:
custom_minimum_size = Vector2(diameter, diameter)
size = Vector2(diameter, diameter)
mouse_filter = Control.MOUSE_FILTER_STOP
queue_redraw()
func axis() -> Vector2:
return _axis
func _gui_input(event: InputEvent) -> void:
if event is InputEventScreenTouch:
if event.pressed and _touch_index < 0:
_touch_index = event.index
_update_from_position(event.position)
elif not event.pressed and event.index == _touch_index:
_touch_index = -1
_set_axis(Vector2.ZERO)
accept_event()
return
if event is InputEventScreenDrag and event.index == _touch_index:
_update_from_position(event.position)
accept_event()
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_touch_index = -2
_update_from_position(event.position)
else:
_touch_index = -1
_set_axis(Vector2.ZERO)
accept_event()
return
if event is InputEventMouseMotion and _touch_index == -2:
_update_from_position(event.position)
accept_event()
func _update_from_position(pos: Vector2) -> void:
var center := size * 0.5
var radius := maxf(1.0, minf(size.x, size.y) * 0.5 - KNOB_RADIUS - 8.0)
var delta := pos - center
var raw := delta / radius
var magnitude := minf(raw.length(), 1.0)
var direction := raw.normalized() if magnitude > 0.001 else Vector2.ZERO
var output := direction * magnitude
if output.length() < DEAD_ZONE:
output = Vector2.ZERO
else:
output = output.normalized() * ((output.length() - DEAD_ZONE) / (1.0 - DEAD_ZONE))
_knob = direction * radius * magnitude
_set_axis(output)
queue_redraw()
func _set_axis(value: Vector2) -> void:
var next := value.limit_length(1.0)
if next.is_equal_approx(_axis):
return
_axis = next
axis_changed.emit(_axis)
queue_redraw()
func cancel_press() -> void:
_touch_index = -1
_knob = Vector2.ZERO
_set_axis(Vector2.ZERO)
func _draw() -> void:
var center := size * 0.5
var outer := minf(size.x, size.y) * 0.5 - 5.0
if outer <= 0.0:
return
draw_circle(center, outer, Color(0.025, 0.04, 0.07, 0.38))
draw_arc(center, outer, 0.0, TAU, 48, Color(0.72, 0.83, 0.96, 0.42), 2.0)
draw_circle(center + _knob, KNOB_RADIUS, Color(0.18, 0.3, 0.48, 0.78))
draw_arc(center + _knob, KNOB_RADIUS, 0.0, TAU, 32, Color(0.7, 0.85, 1.0, 0.8), 2.0)
@@ -0,0 +1 @@
uid://snrdki713in1
+24 -2
View File
@@ -14,6 +14,8 @@ var _icon: TextureRect
var _label: Label
var _attached: Dictionary = {}
var _targets: Array[Dictionary] = []
var _touch_index := -1
var _touch_position := Vector2.ZERO
func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void:
cursor = cursor_manager
@@ -41,11 +43,15 @@ func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -
set_process_input(true)
func attach_item(window: int, cell: int, vnum: int, count: int,
texture: Texture2D = null, source := "inventory", metadata: Dictionary = {}) -> bool:
texture: Texture2D = null, source := "inventory", metadata: Dictionary = {},
touch_index := -1, touch_position := Vector2(-1, -1)) -> bool:
if vnum <= 0 or window < 0 or cell < 0:
return false
_attached = {"window": window, "cell": cell, "vnum": vnum,
"count": maxi(1, count), "source": source}
_touch_index = touch_index
if touch_position.x >= 0.0 and touch_position.y >= 0.0:
_touch_position = touch_position
# Keep the instance fields that the reference mouse module exposes to the
# receiving window (anti flags, sockets, attributes, ...), while retaining
# the canonical source coordinates supplied by the caller.
@@ -70,6 +76,8 @@ func attach_money(amount: int, source := "inventory") -> bool:
return false
_attached = {"window": -1, "cell": -1, "vnum": -1, "count": amount,
"source": source, "money": true}
_touch_index = -1
_touch_position = Vector2.ZERO
if _icon:
_icon.texture = null
_icon.visible = false
@@ -85,6 +93,8 @@ func attach_money(amount: int, source := "inventory") -> bool:
func cancel() -> void:
_attached.clear()
_touch_index = -1
_touch_position = Vector2.ZERO
if _overlay:
_overlay.visible = false
if cursor and cursor.has_method("reset"):
@@ -108,7 +118,10 @@ func unregister_owner(owner: Node) -> void:
func _process(_dt: float) -> void:
if _overlay and _overlay.visible:
_overlay.global_position = get_viewport().get_mouse_position() + Vector2(12, 12)
var position := get_viewport().get_mouse_position()
if _touch_index >= 0:
position = _touch_position
_overlay.global_position = position + Vector2(12, 12)
# 清理已被窗口销毁的目标,防止拖放回调落到旧 UI。
for i in range(_targets.size() - 1, -1, -1):
var c: Control = _targets[i].get("control", null)
@@ -129,6 +142,15 @@ func _input(event: InputEvent) -> void:
if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
_drop_at(event.position)
get_viewport().set_input_as_handled()
return
if event is InputEventScreenDrag and event.index == _touch_index:
_touch_position = event.position
get_viewport().set_input_as_handled()
return
if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index:
_touch_position = event.position
_drop_at(event.position)
get_viewport().set_input_as_handled()
func _drop_at(position: Vector2) -> void:
var payload := attached()
+1
View File
@@ -0,0 +1 @@
uid://c7exn3c2tcjfj
+13
View File
@@ -44,6 +44,7 @@ var _dist_btn: Button
var _heal_btn: Button
var _notice: Label
var _role_popup: Control = null
var _mobile_mode := false
func setup(m2client: Node, parent: Node, dlg: Node = null) -> void:
client = m2client
@@ -66,6 +67,15 @@ func close() -> void:
if _root:
_root.visible = false
# MobileUiRoot owns the compact party strip beneath the player avatar. Keep
# this controller alive for server updates, but hide its desktop window.
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if enabled:
_dismiss_role_popup()
if _root:
_root.visible = false
# --- 로컬 플레이어가 파티장인가 ---
func _local_is_leader() -> bool:
if client == null or not client.has_method("get_main_vid"):
@@ -86,6 +96,9 @@ func refresh() -> void:
_list.remove_child(c)
c.queue_free()
var members: Array = client.get_party()
if _mobile_mode:
_root.visible = false
return
_root.visible = not members.is_empty()
if members.is_empty():
return
+73 -13
View File
@@ -55,6 +55,7 @@ var _price_dialog: Control = null
var _server_prices: Dictionary = {} # vnum -> last price returned by MyShopPriceList
var item_mouse: Node
const INVENTORY_WINDOW := 1
var _mobile_mode := false
func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void:
client = m2client
@@ -80,6 +81,15 @@ func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, ass
func is_open() -> bool:
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func get_mobile_window() -> Control:
return _win.get("root") if not _win.is_empty() else null
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if is_open():
_add_mobile_width(_win["root"])
_refresh()
func toggle() -> void:
if is_open(): close()
else: open()
@@ -91,7 +101,14 @@ func close() -> void:
if item_mouse.has_method("is_attached") and item_mouse.is_attached():
item_mouse.cancel()
if is_open():
ui.close(_win["root"])
var root: Control = _win["root"]
# An external mobile window is owned by UiManager's presentation stack;
# calling ui.close() here would call this method again through its close
# callback. Hide it and let UiManager restore the original parent.
if _mobile_mode and bool(root.get_meta("mobile_external", false)):
root.visible = false
else:
ui.close(root)
_win = {}
_cells.clear()
_stock.clear()
@@ -113,6 +130,8 @@ func open() -> void:
_index_cells(root)
_overlay_name_edit(root)
_add_inv_panel(root)
if _mobile_mode and ui and ui.has_method("reflow_mobile_window"):
ui.reflow_mobile_window(root)
_wire_buttons(root)
_refresh()
@@ -132,6 +151,8 @@ func _index_cells(root: Node) -> void:
var idx := slot
n.gui_input.connect(func(e: InputEvent) -> void:
if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed:
_on_slot_clicked(idx)
elif e is InputEventScreenTouch and e.pressed:
_on_slot_clicked(idx))
# 同时支持从右侧背包候选区拖到空货位;点击选取仍保留给触屏/无鼠标场景。
n.set_drag_forwarding(Callable(self, "_no_drag_data"),
@@ -201,6 +222,7 @@ func _add_inv_panel(root: Control) -> void:
bg.set_border_width_all(1)
panel.add_theme_stylebox_override("panel", bg)
root.add_child(panel)
_add_mobile_width(root)
var title := Label.new()
title.text = "배낭 (클릭 → 집기)"
title.position = Vector2(10, 8)
@@ -219,6 +241,13 @@ func _add_inv_panel(root: Control) -> void:
_status.add_theme_color_override("font_color", Color(1, 0.9, 0.5))
panel.add_child(_status)
func _add_mobile_width(root: Control) -> void:
if not _mobile_mode or root == null:
return
# Keep the candidate list inside the hosted/scaled root instead of leaving
# it as an overflow panel outside the mobile safe area.
root.size.x = maxf(root.size.x, 674.0)
# --- 명칭 --------------------------------------------------------------
func _name_of(vnum: int) -> String:
@@ -375,9 +404,16 @@ func _on_slot_clicked(slot: int) -> void:
func _ask_price(slot: int) -> void:
_dismiss_price_dialog()
var dlg := Panel.new()
dlg.set_anchors_preset(Control.PRESET_CENTER)
dlg.position = Vector2(-120, -70)
dlg.set_anchors_preset(Control.PRESET_TOP_LEFT)
dlg.size = Vector2(240, 128)
dlg.position = (_win["root"].size - dlg.size) * 0.5
dlg.z_index = 200
dlg.mouse_filter = Control.MOUSE_FILTER_STOP
dlg.set_meta("mobile_modal", _mobile_mode)
# UiManager closes mobile modals before the feature window itself. Keep the
# controller's reference in sync when Android back/ESC hides this custom
# panel instead of going through the Cancel button.
dlg.set_meta("mobile_modal_close", Callable(self, "_dismiss_price_dialog"))
var sb := StyleBoxFlat.new()
sb.bg_color = Color(0.06, 0.06, 0.09, 0.98)
sb.border_color = Color(0.7, 0.55, 0.22, 0.9)
@@ -387,30 +423,54 @@ func _ask_price(slot: int) -> void:
t.text = "판매 가격"
t.position = Vector2(12, 10)
dlg.add_child(t)
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = 2000000000
spin.step = 1
var default_price := int(_server_prices.get(int(_picked["vnum"]), 1))
if _stock.has(slot):
default_price = int(_stock[slot].get("price", default_price))
spin.value = default_price
spin.position = Vector2(12, 40)
spin.size = Vector2(216, 28)
dlg.add_child(spin)
var price_input: Control
if _mobile_mode:
var edit := LineEdit.new()
edit.name = "PriceInput"
edit.text = str(default_price)
edit.placeholder_text = "输入价格"
edit.max_length = 10
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.position = Vector2(12, 40)
edit.size = Vector2(216, 32)
edit.text_submitted.connect(func(_text: String) -> void:
_place(slot, _parse_price(edit)))
price_input = edit
else:
var spin := SpinBox.new()
spin.min_value = 1
spin.max_value = 2000000000
spin.step = 1
spin.value = default_price
spin.position = Vector2(12, 40)
spin.size = Vector2(216, 28)
price_input = spin
dlg.add_child(price_input)
var ok := Button.new()
ok.text = "확인"
ok.position = Vector2(60, 86)
ok.pressed.connect(func() -> void: _place(slot, int(spin.value)))
ok.pressed.connect(func() -> void:
_place(slot, _parse_price(price_input)))
dlg.add_child(ok)
var cancel := Button.new()
cancel.text = "취소"
cancel.position = Vector2(130, 86)
cancel.pressed.connect(_dismiss_price_dialog)
dlg.add_child(cancel)
_win["root"].get_parent().add_child(dlg)
_win["root"].add_child(dlg)
_price_dialog = dlg
func _parse_price(input: Control) -> int:
if input is SpinBox:
return int((input as SpinBox).value)
if input is LineEdit:
var raw := (input as LineEdit).text.strip_edges()
return int(raw) if raw.is_valid_int() else 0
return 0
func _dismiss_price_dialog() -> void:
if is_instance_valid(_price_dialog):
_price_dialog.queue_free()
+13 -1
View File
@@ -42,6 +42,7 @@ var _quest_button_order: Array[int] = [] # 最新在前:BINARY_RecvQuest 的
var _party_shown := false
var _party_probe: Callable = Callable()
var _screen_size_override := Vector2.ZERO # >0 时覆盖 viewport 尺寸(离线回归用)
var _mobile_mode := false
var _clock_remaining: Dictionary = {}
var _clock_labels: Dictionary = {}
var _clock_accumulator := 0.0
@@ -66,6 +67,9 @@ func set_item_sources(proto_node: Node, item_list: RefCounted, assets_root: Stri
func is_open() -> bool:
return _root != null and _root.visible
func get_mobile_window() -> Control:
return _root
func close() -> void:
if _root:
_root.visible = false
@@ -120,6 +124,13 @@ func set_party_shown(shown: bool) -> void:
_party_shown = shown
_refresh_quest_buttons()
# MobileHud supplies one compact task entry at the right side of the
# landscape HUD. Keep the quest-letter data here, but hide the desktop
# absolute-positioned buttons while mobile mode is active.
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_update_button_visibility()
func set_screen_size_override(size: Vector2) -> void:
_screen_size_override = size
_refresh_quest_buttons()
@@ -425,4 +436,5 @@ func _start_quest(index: int) -> void:
func _update_button_visibility() -> void:
if _button_root:
_button_root.visible = _quest_buttons_visible and not _quest_buttons_suppressed and not _quest_button_data.is_empty()
_button_root.visible = not _mobile_mode and _quest_buttons_visible \
and not _quest_buttons_suppressed and not _quest_button_data.is_empty()
+57 -1
View File
@@ -8,7 +8,9 @@
# # 输入:数字 1..4 -> 本页 0..3F1..F4 -> 本页 4..7。
#
# 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(朝向 = 玩家 yaw)。
# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)默认朝向 = 玩家 yaw)。
# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的
# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。
# 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。
extends Node
@@ -35,6 +37,7 @@ var _slots := [] # 当前页 UI[{btn, cd, lbl}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _page := 0
var _move_from := -1
var _mobile_mode := false
func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void:
client = m2client
@@ -84,7 +87,38 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool:
_refresh_slot(slot)
return true
# Mobile skill pages cannot rely on drag-and-drop into the small PC quickbar.
# Put a selected skill/item/emote into the first free slot on the current page
# while keeping the same server-backed CG_QUICKSLOT_ADD path.
func assign_mobile(kind: String, id: int) -> bool:
for slot in SLOTS_PER_PAGE:
if _state[_global_slot(slot)].kind == "":
return assign(slot, kind, id)
return false
func mobile_slot_state(slot: int) -> Dictionary:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return {}
return _state[_global_slot(slot)].duplicate(true)
func mobile_swap(local_a: int, local_b: int) -> bool:
if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE:
return false
if local_a == local_b:
return true
_swap(local_a, local_b)
return true
func activate(slot: int) -> void:
_activate_slot(slot, Vector2.ZERO)
## Mobile long-press skill release. `screen_direction` uses screen space:
## right is +X and up is -Y. A zero vector intentionally falls back to the
## player's current facing, so a long press without a drag is still usable.
func activate_aimed(slot: int, screen_direction: Vector2) -> void:
_activate_slot(slot, screen_direction)
func _activate_slot(slot: int, screen_direction: Vector2) -> void:
if slot < 0 or slot >= SLOTS_PER_PAGE:
return
var global := _global_slot(slot)
@@ -101,6 +135,8 @@ func activate(slot: int) -> void:
if p:
yaw = fposmod(90.0 - rad_to_deg(p.rotation.y), 360.0)
xy = Vector2(p.position.x * CM, -p.position.z * CM)
if screen_direction.length() > 0.01:
yaw = _aim_heading(p, screen_direction, yaw)
var target_vid := 0
if client.has_method("get_target"):
target_vid = int(client.get_target().get("vid", 0))
@@ -159,6 +195,18 @@ func activate(slot: int) -> void:
elif s.kind == "emote" and client.has_method("send_emoticon"):
client.send_emoticon(s.id)
func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float:
# Keep the historical POC heading convention as the base and add the
# camera-relative drag angle. This makes an upward drag equal to the
# existing one-tap cast, while right/left drags rotate it predictably.
var angle_from_up := rad_to_deg(atan2(screen_direction.x, -screen_direction.y))
var camera_heading := player_node.rotation.y
if net_play and "camera" in net_play and net_play.camera \
and net_play.camera.has_method("heading"):
camera_heading = float(net_play.camera.heading())
var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y)
return fposmod(fallback + camera_delta + angle_from_up, 360.0)
# net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏
# 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。
func activate_reserved(global_slot: int) -> bool:
@@ -193,6 +241,14 @@ func toggle_visible() -> void:
if _root:
_root.visible = not _root.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root:
_root.visible = not enabled
func is_mobile_mode() -> bool:
return _mobile_mode
func is_visible() -> bool:
return _root != null and _root.visible
+47 -1
View File
@@ -9,6 +9,8 @@
# 按钮:立即重连 / 停止(不再自动)。
extends CanvasLayer
const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd")
const AUTO_DELAY := 5.0
const MAX_TRIES := 5
@@ -20,6 +22,9 @@ var _left := 0.0
var _tries := 0
var _auto := true
var _armed := false
var _mobile_mode := false
var _box: VBoxContainer
var _last_viewport_size := Vector2.ZERO
func setup(m2client: Node) -> void:
client = m2client
@@ -31,8 +36,26 @@ func setup(m2client: Node) -> void:
client.entered_game.connect(func(): _dismiss())
if client.has_signal("phase_changed"):
client.phase_changed.connect(func(p):
if String(p).to_lower() not in ["offline", "", "close"]:
# Keep the reconnect surface visible while auth/game handshaking is in
# progress. Hiding it on the first "login" phase left players with a
# dead-looking world and no retry feedback on mobile.
if not _armed and String(p).to_lower() not in ["offline", "", "close", "failed"]:
_dismiss())
MobileWindowHost.wire_buttons(self)
set_process(true)
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_layout()
func dismiss() -> void:
_dismiss()
func set_error(reason: String) -> void:
if _msg:
_msg.text = "重连失败:%s" % reason
if _count:
_count.text = "将自动重试"
func is_showing() -> bool:
return _panel.visible
@@ -41,8 +64,10 @@ func _on_disconnected(reason: String) -> void:
_msg.text = "连接断开:%s" % reason
_panel.visible = true
_armed = true
_auto = true
_tries = 0
_left = AUTO_DELAY
_apply_layout()
func _dismiss() -> void:
_panel.visible = false
@@ -59,6 +84,7 @@ func _retry_now() -> void:
_count.text = "重连失败,%0.0f 秒后再试" % AUTO_DELAY
func _process(dt: float) -> void:
_apply_layout()
if not _panel.visible or not _armed:
return
if not _auto:
@@ -79,6 +105,7 @@ func _build() -> void:
_panel.visible = false
add_child(_panel)
var box := VBoxContainer.new()
_box = box
box.set_anchors_preset(Control.PRESET_CENTER)
box.position = Vector2(-160, -70)
box.custom_minimum_size = Vector2(320, 0)
@@ -98,9 +125,28 @@ func _build() -> void:
box.add_child(row)
var now_btn := Button.new()
now_btn.text = "立即重连"
now_btn.custom_minimum_size = Vector2(150, 46)
now_btn.pressed.connect(_retry_now)
row.add_child(now_btn)
var stop_btn := Button.new()
stop_btn.text = "停止"
stop_btn.custom_minimum_size = Vector2(120, 46)
stop_btn.pressed.connect(func(): _auto = false)
row.add_child(stop_btn)
func _apply_layout() -> void:
if _panel == null or get_viewport() == null:
return
var viewport_size := get_viewport().get_visible_rect().size
if viewport_size.is_equal_approx(_last_viewport_size) and not _mobile_mode:
return
_last_viewport_size = viewport_size
if not _mobile_mode:
return
var area := MobileWindowHost.safe_rect(get_viewport())
var width := minf(520.0, maxf(300.0, area.size.x - 32.0))
_box.position = area.position + Vector2((area.size.x - width) * 0.5,
(area.size.y - 190.0) * 0.5)
_box.size = Vector2(width, 190.0)
_msg.add_theme_font_size_override("font_size", 18)
_count.add_theme_font_size_override("font_size", 15)
+46 -11
View File
@@ -13,6 +13,10 @@ var proto: Node
var _root: Control
var _text: RichTextLabel
var _cur := {}
var _mobile_mode := false
var _action_row: HBoxContainer
var _refine_button: Button
var _cancel_button: Button
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -26,6 +30,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func get_mobile_window() -> Control:
return _root
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
func close() -> void:
if _root:
_root.visible = false
@@ -65,6 +76,28 @@ func _on_result(ok: bool) -> void:
_text.text = "[b]精炼成功[/b]" if ok else "[b]精炼失败[/b]"
_root.visible = true
func _apply_mobile_layout() -> void:
if _root == null or _text == null or _action_row == null:
return
if _mobile_mode:
_root.size = Vector2(460, 300)
_root.position = Vector2(-230, -150)
_text.position = Vector2(18, 52)
_text.size = Vector2(424, 178)
_text.custom_minimum_size = Vector2(424, 178)
_action_row.position = Vector2(18, 242)
_refine_button.custom_minimum_size = Vector2(204, 44)
_cancel_button.custom_minimum_size = Vector2(204, 44)
else:
_root.size = Vector2(320, 260)
_root.position = Vector2(-160, -130)
_text.position = Vector2(14, 34)
_text.size = Vector2(292, 160)
_text.custom_minimum_size = Vector2(292, 160)
_action_row.position = Vector2(14, 208)
_refine_button.custom_minimum_size = Vector2(120, 30)
_cancel_button.custom_minimum_size = Vector2(120, 30)
func _build(parent: Node) -> void:
_root = Control.new()
_root.set_anchors_preset(Control.PRESET_CENTER)
@@ -90,17 +123,19 @@ func _build(parent: Node) -> void:
_text.custom_minimum_size = Vector2(292, 160)
_text.size = Vector2(292, 160)
_root.add_child(_text)
var row := HBoxContainer.new()
_action_row = HBoxContainer.new()
var row := _action_row
row.position = Vector2(14, 208)
row.add_theme_constant_override("separation", 12)
_root.add_child(row)
var ok := Button.new()
ok.text = "精炼"
ok.custom_minimum_size = Vector2(120, 30)
ok.pressed.connect(_do_refine)
row.add_child(ok)
var cancel := Button.new()
cancel.text = "取消"
cancel.custom_minimum_size = Vector2(120, 30)
cancel.pressed.connect(func(): _root.visible = false)
row.add_child(cancel)
_refine_button = Button.new()
_refine_button.text = "精炼"
_refine_button.custom_minimum_size = Vector2(120, 30)
_refine_button.pressed.connect(_do_refine)
row.add_child(_refine_button)
_cancel_button = Button.new()
_cancel_button.text = "取消"
_cancel_button.custom_minimum_size = Vector2(120, 30)
_cancel_button.pressed.connect(func(): _root.visible = false)
row.add_child(_cancel_button)
_apply_mobile_layout()
+1
View File
@@ -0,0 +1 @@
uid://bf22s7b7sym1
+123 -7
View File
@@ -25,9 +25,15 @@ var _cells: Dictionary = {}
var _page_label: Label
var _page := 0
var item_mouse: Node
var _mobile_mode := false
var _mobile_inventory_title: Label
var _mobile_inventory_scroll: ScrollContainer
var _mobile_inventory_list: VBoxContainer
var _mobile_deposit_pending := {}
const SAFE_PAGE_SLOTS := 45
const SAFE_WINDOW := 3
const INVENTORY_WINDOW := 1
func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
client = m2client
@@ -47,6 +53,14 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
func is_open() -> bool:
return _root != null and _root.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
_apply_mobile_layout()
_refresh_mobile_inventory()
func get_mobile_window() -> Control:
return _root
func close() -> void:
if item_mouse and item_mouse.has_method("unregister_owner"):
item_mouse.unregister_owner(self)
@@ -55,6 +69,7 @@ func close() -> void:
if _root:
_root.visible = false
_page = 0
_mobile_deposit_pending.clear()
if is_instance_valid(_password_dialog):
_password_dialog.queue_free()
_password_dialog = null
@@ -62,12 +77,22 @@ func close() -> void:
func deposit(inv_window: int, inv_cell: int) -> void:
if not is_open() or inv_window < 0 or inv_cell < 0:
return
if inv_window == INVENTORY_WINDOW and _mobile_deposit_pending.has(inv_cell):
_status.text = "该物品的存入请求已发送"
return
var safe_pos := _next_free_slot()
if safe_pos < 0:
_status.text = "仓库已满"
return
if client.has_method("safebox_checkin") and not client.safebox_checkin(safe_pos, inv_window, inv_cell):
if not client.has_method("safebox_checkin"):
_status.text = "存入请求发送失败"
return
if not client.safebox_checkin(safe_pos, inv_window, inv_cell):
_status.text = "存入请求发送失败"
return
if _mobile_mode and inv_window == INVENTORY_WINDOW:
_mobile_deposit_pending[inv_cell] = true
_status.text = "已发送存入请求"
func _next_free_slot() -> int:
var used := {}
@@ -114,9 +139,10 @@ func refresh() -> void:
var e := Label.new()
e.text = "(空)"
_list.add_child(e)
return
for it in items:
_list.add_child(_row(it))
else:
for it in items:
_list.add_child(_row(it))
_refresh_mobile_inventory()
func _render_grid() -> void:
if _grid == null:
@@ -171,6 +197,71 @@ func _on_grid_input(local_pos: int, event: InputEvent) -> void:
item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum,
int(cell.get_meta("count", 1)), null, "safebox")
func _on_mobile_grid_tap(local_pos: int) -> void:
if not _mobile_mode or client == null or not _cells.has(local_pos):
return
var cell: Button = _cells[local_pos]
if int(cell.get_meta("vnum", 0)) == 0 or not client.has_method("safebox_checkout"):
return
var target := _first_free_inv()
if target < 0:
_status.text = "背包已满"
return
if not client.safebox_checkout(_page * SAFE_PAGE_SLOTS + local_pos, 1, target):
_status.text = "取出请求发送失败"
func _apply_mobile_layout() -> void:
if _root == null:
return
if _mobile_mode:
# The source-bag candidates stay inside the same hosted surface as the
# warehouse grid, so both obey one safe-area scale and touch boundary.
_root.size = Vector2(688, 400)
_root.position = Vector2(-344, -200)
_grid.position = Vector2(12, 54)
if _mobile_inventory_title:
_mobile_inventory_title.visible = true
_mobile_inventory_title.position = Vector2(350, 48)
if _mobile_inventory_scroll:
_mobile_inventory_scroll.visible = true
_mobile_inventory_scroll.position = Vector2(350, 76)
_mobile_inventory_scroll.size = Vector2(326, 292)
else:
_root.size = Vector2(340, 400)
_root.position = Vector2(-170, -200)
_grid.position = Vector2(12, 54)
if _mobile_inventory_title:
_mobile_inventory_title.visible = false
if _mobile_inventory_scroll:
_mobile_inventory_scroll.visible = false
func _refresh_mobile_inventory() -> void:
if not _mobile_mode or _mobile_inventory_list == null or not is_open():
return
for child in _mobile_inventory_list.get_children():
child.queue_free()
var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
var shown := 0
for item in inventory:
var cell := int(item.get("cell", -1))
var vnum := int(item.get("vnum", 0))
if cell < 0 or vnum <= 0 or _mobile_deposit_pending.has(cell):
continue
var button := Button.new()
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.text = "%s ×%d · 点按存入" % [_name_of(vnum), int(item.get("count", 1))]
button.custom_minimum_size = Vector2(316, 34)
button.tooltip_text = _tooltip_for(item)
var captured_cell := cell
button.pressed.connect(func() -> void: deposit(INVENTORY_WINDOW, captured_cell))
_mobile_inventory_list.add_child(button)
shown += 1
if shown == 0:
var empty := Label.new()
empty.text = "(没有可存入物品)"
empty.add_theme_font_size_override("font_size", 12)
_mobile_inventory_list.add_child(empty)
func _change_page(delta: int) -> void:
var pages := maxi(1, int(client.get_safebox_size()))
_page = clampi(_page + delta, 0, pages - 1)
@@ -188,7 +279,8 @@ func _ask_password(kind: String) -> void:
edit.secret = true
edit.max_length = 6
edit.placeholder_text = "密码"
edit.custom_minimum_size = Vector2(220, 28)
edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(220, 28)
_password_dialog.add_child(edit)
_password_dialog.confirmed.connect(func() -> void:
var sent: bool = client.safebox_password(edit.text) if kind == "safebox" else client.mall_password(edit.text)
@@ -199,8 +291,16 @@ func _ask_password(kind: String) -> void:
_password_dialog.canceled.connect(func() -> void:
_password_dialog.queue_free()
_password_dialog = null)
_root.get_parent().add_child(_password_dialog)
_password_dialog.popup_centered()
if _mobile_mode:
_password_dialog.set_meta("mobile_modal", true)
_password_dialog.set_meta("mobile_title", "商城密码" if kind == "mall" else "仓库密码")
_password_dialog.min_size = Vector2i(460, 230)
_password_dialog.size = Vector2i(460, 230)
_root.add_child(_password_dialog)
if _mobile_mode:
_password_dialog.popup_centered(Vector2i(460, 230))
else:
_password_dialog.popup_centered()
func _row(it: Dictionary) -> Control:
var row := HBoxContainer.new()
@@ -296,8 +396,24 @@ func _build(parent: Node) -> void:
cell.add_theme_font_size_override("font_size", 9)
cell.set_meta("safe_pos", pos)
cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e))
cell.pressed.connect(func(): _on_mobile_grid_tap(pos))
if item_mouse and item_mouse.has_method("register_target"):
item_mouse.register_target(cell,
func(payload: Dictionary): return _drop_to_slot(payload, pos), self)
_grid.add_child(cell)
_cells[pos] = cell
_mobile_inventory_title = Label.new()
_mobile_inventory_title.text = "背包 · 点按存入"
_mobile_inventory_title.add_theme_font_size_override("font_size", 12)
_mobile_inventory_title.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58))
_mobile_inventory_title.visible = false
_root.add_child(_mobile_inventory_title)
_mobile_inventory_scroll = ScrollContainer.new()
_mobile_inventory_scroll.name = "MobileInventory"
_mobile_inventory_scroll.visible = false
_mobile_inventory_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_mobile_inventory_list = VBoxContainer.new()
_mobile_inventory_list.add_theme_constant_override("separation", 4)
_mobile_inventory_scroll.add_child(_mobile_inventory_list)
_root.add_child(_mobile_inventory_scroll)
_apply_mobile_layout()
+73 -4
View File
@@ -34,6 +34,9 @@ var _sell_mode_button: Button
var _sell_drop_target: Button
var _sell_confirm: ConfirmationDialog
var _buy_confirm: ConfirmationDialog
var _mobile_mode := false
var _mobile_sell_scroll: ScrollContainer
var _mobile_sell_list: VBoxContainer
func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void:
client = m2client
@@ -52,6 +55,14 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCoun
func is_open() -> bool:
return _root != null and _root.visible
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _root and _mobile_mode:
_set_mode(_mode)
func get_mobile_window() -> Control:
return _root
func close() -> void:
_close()
@@ -181,11 +192,15 @@ func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> voi
_sell_confirm = null
dialog.hide()
dialog.queue_free())
_prepare_mobile_confirmation(dialog, "确认出售")
_root.add_child(dialog)
if dialog.is_inside_tree():
dialog.popup_centered()
_popup_confirmation(dialog)
else:
dialog.call_deferred("popup_centered")
if _mobile_mode:
dialog.call_deferred("popup_centered", Vector2i(480, 220))
else:
dialog.call_deferred("popup_centered")
func _play_ui(name: String) -> void:
if audio and audio.has_method("play_ui"):
@@ -201,6 +216,10 @@ func _set_mode(mode: int) -> void:
_sell_drop_target.visible = _mode == 2
if cursor_manager and cursor_manager.has_method("set_cursor"):
cursor_manager.set_cursor(CursorManager.SELL if _mode == 2 else CursorManager.BUY)
if _mobile_sell_scroll:
_mobile_sell_scroll.visible = _mobile_mode and _mode == 2
_grid.visible = not (_mobile_mode and _mode == 2)
_refresh_mobile_sell()
func _name_of(vnum: int) -> String:
if proto and proto.has_method("item"):
@@ -256,6 +275,30 @@ func refresh() -> void:
for slot in SHOP_SLOT_COUNT:
var it: Dictionary = by_pos.get(slot, {})
_grid.add_child(_slot(it, base + slot))
_refresh_mobile_sell()
func _refresh_mobile_sell() -> void:
if not _mobile_mode or _mobile_sell_list == null:
return
for child in _mobile_sell_list.get_children():
child.queue_free()
var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
if inv.is_empty():
var empty := Label.new()
empty.text = "(背包为空)"
_mobile_sell_list.add_child(empty)
return
for item in inv:
var cell := int(item.get("cell", -1))
var vnum := int(item.get("vnum", 0))
if cell < 0 or vnum <= 0:
continue
var button := Button.new()
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
button.text = "%s ×%d · 点击出售" % [_name_of(vnum), int(item.get("count", 1))]
button.custom_minimum_size = Vector2(330, 30)
button.pressed.connect(func(): sell(cell, int(_sell_quantity.value)))
_mobile_sell_list.add_child(button)
func _slot(it: Dictionary, pos: int) -> Button:
var slot := Button.new()
@@ -301,11 +344,29 @@ func _buy_slot(pos: int, item: Dictionary) -> void:
_buy_confirm = null
dialog.hide()
dialog.queue_free())
_prepare_mobile_confirmation(dialog, "确认购买")
_root.add_child(dialog)
if dialog.is_inside_tree():
dialog.popup_centered()
_popup_confirmation(dialog)
else:
dialog.call_deferred("popup_centered")
if _mobile_mode:
dialog.call_deferred("popup_centered", Vector2i(480, 220))
else:
dialog.call_deferred("popup_centered")
func _prepare_mobile_confirmation(dialog: ConfirmationDialog, title: String) -> void:
if not _mobile_mode:
return
dialog.set_meta("mobile_modal", true)
dialog.set_meta("mobile_title", title)
dialog.min_size = Vector2i(480, 220)
dialog.size = Vector2i(480, 220)
func _popup_confirmation(dialog: ConfirmationDialog) -> void:
if _mobile_mode:
dialog.popup_centered(Vector2i(480, 220))
else:
dialog.popup_centered()
func _on_error(kind: String) -> void:
var tbl := {
@@ -395,6 +456,14 @@ func _build(parent: Node) -> void:
_grid.add_theme_constant_override("h_separation", 3)
_grid.add_theme_constant_override("v_separation", 2)
_root.add_child(_grid)
_mobile_sell_scroll = ScrollContainer.new()
_mobile_sell_scroll.position = Vector2(12, 58)
_mobile_sell_scroll.size = Vector2(350, 252)
_mobile_sell_scroll.visible = false
_root.add_child(_mobile_sell_scroll)
_mobile_sell_list = VBoxContainer.new()
_mobile_sell_list.add_theme_constant_override("separation", 4)
_mobile_sell_scroll.add_child(_mobile_sell_list)
var close_btn := Button.new()
close_btn.text = "离开"
close_btn.position = Vector2(300, 360)
+217 -21
View File
@@ -14,6 +14,7 @@
# 辅助页 HIDE_SUPPORT_SKILL_POINT 整页隐藏。升级结果仍由 GC_SKILL_LEVEL 回包驱动。
extends Node
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
const TABS := ["主动", "辅助", "坐骑"]
const MASTER_SUFFIX := ["", " M", " G", " P"] # master_type 0..3
@@ -28,6 +29,11 @@ var ui: CanvasLayer # UiManager
var job := "WARRIOR"
var drag_skill_id := 0
var _tab := 0
var _mobile_mode := false
var _mobile_quickbar: Node
var _mobile_hint: Label
var _mobile_drop_slots: Array[MobileTouchButton] = []
var _mobile_drag_quickbar_slot := -1
var _win: Control
var _rows := {} # skill_id -> {lv, up}
@@ -44,6 +50,15 @@ func set_job(j: String) -> void:
if is_open():
_rebuild()
func set_mobile_mode(enabled: bool) -> void:
_mobile_mode = enabled
if _mobile_mode and _win:
close()
func set_mobile_quickbar(quickbar: Node) -> void:
_mobile_quickbar = quickbar
_refresh_mobile_quickbar_targets()
func is_open() -> bool:
return _win != null and is_instance_valid(_win)
@@ -56,6 +71,8 @@ func close() -> void:
ui.close(_win)
_win = null
_rows.clear()
_mobile_drop_slots.clear()
_mobile_drag_quickbar_slot = -1
func _cat_key() -> String:
match _tab:
@@ -67,9 +84,16 @@ func open() -> void:
if is_open() or table == null:
return
_win = Control.new()
_win.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
_win.position = Vector2(-300, -240)
_win.size = Vector2(290, 470)
_win.name = "MobileSkillWindow" if _mobile_mode else "SkillWindow"
if _mobile_mode:
_win.set_anchors_preset(Control.PRESET_TOP_LEFT)
_win.position = Vector2.ZERO
_win.size = Vector2(760, 340)
_win.set_meta("mobile_title", "技能")
else:
_win.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
_win.position = Vector2(-300, -240)
_win.size = Vector2(290, 470)
_win.set_meta("is_titlebar", true)
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
@@ -80,27 +104,54 @@ func open() -> void:
_win.add_child(panel)
var title := Label.new()
title.text = "技能"
title.position = Vector2(12, 8)
title.position = Vector2(12, 8) if not _mobile_mode else Vector2(14, 48)
title.visible = not _mobile_mode
if _mobile_mode:
title.add_theme_font_size_override("font_size", 15)
_win.add_child(title)
# 分类页签
var tabrow := HBoxContainer.new()
tabrow.name = "tabs"
tabrow.position = Vector2(10, 30)
tabrow.position = Vector2(10, 30) if not _mobile_mode else Vector2(14, 48)
if _mobile_mode:
tabrow.size = Vector2(360, 40)
tabrow.add_theme_constant_override("separation", 6)
_win.add_child(tabrow)
for i in TABS.size():
var b := Button.new()
b.text = TABS[i]
b.toggle_mode = true
b.button_pressed = (i == _tab)
if _mobile_mode:
b.custom_minimum_size = Vector2(110, 38)
b.add_theme_font_size_override("font_size", 12)
var ti: int = i
b.pressed.connect(func(): _switch_tab(ti))
tabrow.add_child(b)
var vb := VBoxContainer.new()
vb.name = "list"
vb.position = Vector2(10, 60)
vb.custom_minimum_size = Vector2(270, 0)
vb.position = Vector2(10, 60) if not _mobile_mode else Vector2(0, 0)
vb.custom_minimum_size = Vector2(270, 0) if not _mobile_mode else Vector2(720, 0)
vb.add_theme_constant_override("separation", 2)
_win.add_child(vb)
if _mobile_mode:
var scroll := ScrollContainer.new()
scroll.name = "SkillScroll"
scroll.position = Vector2(14, 92)
scroll.size = Vector2(732, 214)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
scroll.mouse_filter = Control.MOUSE_FILTER_STOP
scroll.add_child(vb)
_win.add_child(scroll)
_mobile_hint = Label.new()
_mobile_hint.position = Vector2(14, 310)
_mobile_hint.size = Vector2(720, 22)
_mobile_hint.add_theme_font_size_override("font_size", 10)
_mobile_hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84))
_mobile_hint.text = "可用点数与技能等级由服务器实时确认"
_win.add_child(_mobile_hint)
_build_mobile_quickbar_targets()
else:
_win.add_child(vb)
ui.open(_win)
_rebuild()
@@ -113,7 +164,11 @@ func _switch_tab(i: int) -> void:
_rebuild()
func _rebuild() -> void:
var vb: VBoxContainer = _win.get_node("list")
var vb: VBoxContainer = _win.get_node_or_null("list") as VBoxContainer
if vb == null and _mobile_mode:
vb = _win.get_node_or_null("SkillScroll/list") as VBoxContainer
if vb == null:
return
for c in vb.get_children():
c.queue_free()
_rows.clear()
@@ -125,19 +180,39 @@ func _rebuild() -> void:
return
for id in ids:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(268, 26)
var nm := Label.new()
nm.text = table.name_of(id)
nm.custom_minimum_size = Vector2(140, 0)
nm.add_theme_font_size_override("font_size", 12)
row.custom_minimum_size = Vector2(720, 46) if _mobile_mode else Vector2(268, 26)
var nm: Control
if _mobile_mode:
var skill_name := MobileTouchButton.new()
skill_name.setup(table.name_of(id), Color(0.34, 0.58, 0.88, 0.88))
skill_name.set_rect_style()
skill_name.set_gesture_enabled(true)
skill_name.custom_minimum_size = Vector2(300, 42)
skill_name.size = Vector2(300, 42)
var skill_button := skill_name
skill_button.long_pressed.connect(func(): _mobile_begin_skill_drag(id, skill_button))
skill_button.drag_changed.connect(func(_delta: Vector2):
_mobile_update_skill_drag(id, skill_button))
skill_button.drag_released.connect(func(_delta: Vector2):
_mobile_end_skill_drag(id, skill_button))
nm = skill_name
else:
var skill_label := Label.new()
skill_label.text = table.name_of(id)
skill_label.custom_minimum_size = Vector2(140, 0)
skill_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
skill_label.add_theme_font_size_override("font_size", 12)
nm = skill_label
row.add_child(nm)
var lv := Label.new()
lv.name = "lv"
lv.custom_minimum_size = Vector2(58, 0)
lv.add_theme_font_size_override("font_size", 12)
lv.custom_minimum_size = Vector2(100, 42) if _mobile_mode else Vector2(58, 0)
lv.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
lv.add_theme_font_size_override("font_size", 13 if _mobile_mode else 12)
row.add_child(lv)
var tag := Label.new()
tag.custom_minimum_size = Vector2(40, 0)
tag.custom_minimum_size = Vector2(76, 42) if _mobile_mode else Vector2(40, 0)
tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
tag.add_theme_font_size_override("font_size", 10)
if table.is_passive(id):
tag.text = "被动"
@@ -150,17 +225,138 @@ func _rebuild() -> void:
if table.can_level_up(id) and not table.is_passive(id):
var up := Button.new()
up.text = ""
up.custom_minimum_size = Vector2(28, 22)
up.custom_minimum_size = Vector2(48, 42) if _mobile_mode else Vector2(28, 22)
if _mobile_mode:
up.add_theme_font_size_override("font_size", 18)
up.pressed.connect(func(): client.skill_up(sid))
row.add_child(up)
_rows[id] = {"lv": lv, "up": up}
else:
_rows[id] = {"lv": lv, "up": null}
nm.gui_input.connect(func(e: InputEvent):
if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT:
drag_skill_id = sid)
if not _mobile_mode:
nm.gui_input.connect(func(e: InputEvent):
if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT:
drag_skill_id = sid
if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid)
nm.accept_event())
if _mobile_mode:
var assign := Button.new()
assign.text = "快捷栏"
assign.custom_minimum_size = Vector2(92, 42)
assign.add_theme_font_size_override("font_size", 11)
assign.pressed.connect(func(): _mobile_assign_skill(sid))
row.add_child(assign)
vb.add_child(row)
refresh()
_refresh_mobile_quickbar_targets()
func _build_mobile_quickbar_targets() -> void:
var title := Label.new()
title.text = "快捷栏"
title.position = Vector2(390, 48)
title.size = Vector2(60, 38)
title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
title.add_theme_font_size_override("font_size", 12)
title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54))
title.mouse_filter = Control.MOUSE_FILTER_IGNORE
_win.add_child(title)
for i in 8:
var target := MobileTouchButton.new()
target.setup("", Color(0.42, 0.65, 0.92, 0.9))
target.set_rect_style()
target.set_gesture_enabled(true)
target.position = Vector2(448 + i * 38, 48)
target.size = Vector2(34, 38)
target.custom_minimum_size = Vector2(34, 38)
var slot := i
target.pressed.connect(func(): _mobile_drop_on_quickbar(slot))
target.long_pressed.connect(func(): _mobile_begin_quickbar_drag(slot))
target.drag_released.connect(func(_delta: Vector2): _mobile_end_quickbar_drag(slot, target))
_win.add_child(target)
_mobile_drop_slots.append(target)
func _mobile_begin_skill_drag(skill_id: int, _button: MobileTouchButton) -> void:
drag_skill_id = skill_id
if _mobile_hint:
_mobile_hint.text = "拖到上方快捷栏槽位,松手放入"
func _mobile_update_skill_drag(skill_id: int, _button: MobileTouchButton) -> void:
if drag_skill_id == skill_id and _mobile_hint:
_mobile_hint.text = "拖到上方快捷栏槽位,松手放入"
func _mobile_end_skill_drag(skill_id: int, button: MobileTouchButton) -> void:
if drag_skill_id != skill_id:
return
var target := _mobile_drop_slot_at(button.drag_end_position())
if target >= 0 and _mobile_quickbar and _mobile_quickbar.has_method("assign"):
if bool(_mobile_quickbar.assign(target, "skill", skill_id)):
drag_skill_id = 0
_refresh_mobile_quickbar_targets()
if _mobile_hint: _mobile_hint.text = "已放入快捷栏第 %d" % (target + 1)
return
if _mobile_hint:
_mobile_hint.text = "已选择:%s;可拖到快捷栏" % table.name_of(skill_id)
func _mobile_drop_on_quickbar(slot: int) -> void:
if _mobile_quickbar == null or not _mobile_quickbar.has_method("assign"):
return
if drag_skill_id != 0:
if bool(_mobile_quickbar.assign(slot, "skill", drag_skill_id)):
drag_skill_id = 0
_refresh_mobile_quickbar_targets()
if _mobile_hint: _mobile_hint.text = "已放入快捷栏第 %d" % (slot + 1)
func _mobile_begin_quickbar_drag(slot: int) -> void:
_mobile_drag_quickbar_slot = slot
if _mobile_hint:
_mobile_hint.text = "拖到另一个快捷栏槽位以交换"
func _mobile_end_quickbar_drag(slot: int, button: MobileTouchButton) -> void:
if _mobile_drag_quickbar_slot != slot:
return
var target := _mobile_drop_slot_at(button.drag_end_position())
if target >= 0 and target != slot and _mobile_quickbar \
and _mobile_quickbar.has_method("mobile_swap"):
_mobile_quickbar.mobile_swap(slot, target)
_refresh_mobile_quickbar_targets()
if _mobile_hint: _mobile_hint.text = "已交换快捷栏位置"
_mobile_drag_quickbar_slot = -1
func _mobile_drop_slot_at(position: Vector2) -> int:
for i in _mobile_drop_slots.size():
var slot := _mobile_drop_slots[i]
if slot and slot.get_global_rect().has_point(position):
return i
return -1
func _refresh_mobile_quickbar_targets() -> void:
if not _mobile_mode:
return
for i in _mobile_drop_slots.size():
var slot := _mobile_drop_slots[i]
if slot == null or _mobile_quickbar == null:
continue
var state: Dictionary = _mobile_quickbar.mobile_slot_state(i) \
if _mobile_quickbar.has_method("mobile_slot_state") else {}
var label := ""
if state.get("kind", "") == "skill" and table:
label = table.name_of(int(state.get("id", 0))).substr(0, 4)
elif state.get("kind", "") == "item":
label = ""
elif state.get("kind", "") == "emote":
label = ""
slot.set_caption(label)
func _mobile_assign_skill(skill_id: int) -> void:
if _mobile_quickbar == null or not _mobile_quickbar.has_method("assign_mobile"):
if _mobile_hint: _mobile_hint.text = "快捷栏暂不可用"
return
if bool(_mobile_quickbar.assign_mobile("skill", skill_id)):
drag_skill_id = 0
_refresh_mobile_quickbar_targets()
if _mobile_hint: _mobile_hint.text = "已放入快捷栏"
else:
if _mobile_hint: _mobile_hint.text = "快捷栏已满,请先移除一个技能"
func refresh() -> void:
if not is_open():
+120 -9
View File
@@ -35,6 +35,11 @@ var help_ui: Node # HelpUI(§8.3)—— 未注入时 _ensur
var toast: Callable = Callable() # 可选:func(msg: String) 显示提示
var _win: Dictionary = {}
var _mobile_mode := false
var _mobile_root: Control
var _mobile_buttons: Dictionary = {}
const MOBILE_SIZE := Vector2(700, 330)
func setup(ui_manager: CanvasLayer, m2client: Node, assets := "",
sys_opt: Node = null, game_opt: Node = null, help_win: Node = null) -> void:
@@ -51,18 +56,37 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "",
uiscript_dir = assets_root.path_join("uiscript")
func is_open() -> bool:
if _mobile_mode:
return _mobile_root != null and is_instance_valid(_mobile_root)
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
if enabled != _mobile_mode and is_open():
close()
_mobile_mode = enabled
if is_instance_valid(help_ui) and help_ui.has_method("set_mobile_mode"):
help_ui.set_mobile_mode(enabled)
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if _mobile_mode:
var root := _mobile_root
_mobile_root = null
_mobile_buttons.clear()
if root and is_instance_valid(root) and ui:
ui.close(root)
return
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if _mobile_mode:
_open_mobile()
return
if is_open():
return
var path := uiscript_dir.path_join("systemdialog.py")
@@ -75,6 +99,97 @@ func open() -> void:
_relabel()
_wire()
func _open_mobile() -> void:
if is_open():
return
_mobile_root = Control.new()
_mobile_root.name = "MobileSystemMenuWindow"
_mobile_root.size = MOBILE_SIZE
_mobile_root.set_meta("mobile_title", "系统菜单")
_mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_theme_stylebox_override("panel", _mobile_panel_style())
_mobile_root.add_child(panel)
var hint := Label.new()
hint.text = "账户、设置和帮助"
hint.position = Vector2(22, 52)
hint.size = Vector2(640, 24)
hint.add_theme_font_size_override("font_size", 13)
hint.add_theme_color_override("font_color", Color(0.68, 0.78, 0.92))
_mobile_root.add_child(hint)
var grid := GridContainer.new()
grid.name = "MobileSystemActions"
grid.columns = 2
grid.position = Vector2(22, 82)
grid.size = Vector2(656, 174)
grid.add_theme_constant_override("h_separation", 12)
grid.add_theme_constant_override("v_separation", 8)
_mobile_root.add_child(grid)
for action in ["帮助", "商城", "系统设置", "游戏设置", "选择角色", "登出"]:
var button := Button.new()
button.name = "MobileAction_%s" % action
button.text = String(action)
button.custom_minimum_size = Vector2(322, 48)
button.add_theme_font_size_override("font_size", 14)
var selected := String(action)
button.pressed.connect(func(): _mobile_action(selected))
_mobile_buttons[selected] = button
grid.add_child(button)
var exit := Button.new()
exit.name = "MobileExitButton"
exit.text = "退出游戏"
exit.position = Vector2(22, 272)
exit.size = Vector2(154, 42)
exit.add_theme_font_size_override("font_size", 13)
exit.pressed.connect(func(): _mobile_action("退出游戏"))
_mobile_buttons["退出游戏"] = exit
_mobile_root.add_child(exit)
var cancel := Button.new()
cancel.name = "MobileCancelButton"
cancel.text = "取消"
cancel.position = Vector2(524, 272)
cancel.size = Vector2(154, 42)
cancel.add_theme_font_size_override("font_size", 13)
cancel.pressed.connect(close)
_mobile_buttons["取消"] = cancel
_mobile_root.add_child(cancel)
ui.open(_mobile_root, true)
func _mobile_action(action: String) -> void:
match action:
"帮助":
close()
open_help()
"商城":
_send_and_close("/in_game_mall")
"系统设置":
close()
if system_option_ui and system_option_ui.has_method("open"):
system_option_ui.open()
"游戏设置":
close()
if game_option_ui and game_option_ui.has_method("open"):
game_option_ui.open()
"选择角色": _send_and_close("/phase_select")
"登出": _send_and_close("/logout")
"退出游戏": get_tree().quit()
func _send_and_close(command: String) -> void:
if client and client.has_method("say"):
client.say(0, command)
close()
func _mobile_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.04, 0.065, 0.1, 0.98)
style.border_color = Color(0.52, 0.68, 0.9, 0.86)
style.set_border_width_all(1)
style.set_corner_radius_all(10)
style.shadow_color = Color(0, 0, 0, 0.45)
style.shadow_size = 12
return style
func _node(nm: String) -> Control:
if _win.is_empty():
return null
@@ -99,6 +214,8 @@ func _ensure_help() -> Node:
help_ui = HelpUI.new()
add_child(help_ui)
help_ui.setup(ui, client, assets_root)
if help_ui.has_method("set_mobile_mode"):
help_ui.set_mobile_mode(_mobile_mode)
return help_ui
func open_help() -> void:
@@ -112,9 +229,7 @@ func _wire() -> void:
close()
open_help())
_btn("mall_button", func():
if client and client.has_method("say"):
client.say(0, "/in_game_mall") # net.SendChatPacket("/in_game_mall")
close())
_send_and_close("/in_game_mall"))
_btn("system_option_button", func():
close()
if system_option_ui and system_option_ui.has_method("open"):
@@ -124,13 +239,9 @@ func _wire() -> void:
if game_option_ui and game_option_ui.has_method("open"):
game_option_ui.open())
_btn("change_button", func():
if client and client.has_method("say"):
client.say(0, "/phase_select") # net.ExitGame()
close())
_send_and_close("/phase_select"))
_btn("logout_button", func():
if client and client.has_method("say"):
client.say(0, "/logout") # net.LogOutGame()
close())
_send_and_close("/logout"))
_btn("exit_button", func(): get_tree().quit())
_btn("cancel_button", close)
var board := _node("board")
+106
View File
@@ -15,6 +15,7 @@
# 设置持久化到 `user://system_option.cfg`= 原 systemSetting 的配置文件)。
extends Node
const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
const CFG_PATH := "user://system_option.cfg"
const CAMERA_MAX := [11.0, 20.0] # 近 / 远(game_camera.max_dist
const FOG_DENSITY := [0.055, 0.018, 0.004] # 浓 / 中 / 淡(Environment.fog_density
@@ -37,6 +38,8 @@ var _env_get: Callable = Callable()
var _win: Dictionary = {}
var _cfg := ConfigFile.new()
var _mobile_mode := false
var _mobile_root: Control
# --- 状态(= systemSetting 值)---
var music_volume := 0.6
@@ -72,18 +75,36 @@ func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null,
# --- open / close ---------------------------------------------------
func is_open() -> bool:
if _mobile_mode:
return _mobile_root != null and is_instance_valid(_mobile_root)
return not _win.is_empty() and is_instance_valid(_win.get("root"))
func set_mobile_mode(enabled: bool) -> void:
if not enabled and _mobile_root != null:
var old_mode := _mobile_mode
_mobile_mode = true
close()
_mobile_mode = old_mode
_mobile_mode = enabled
func toggle() -> void:
if is_open(): close()
else: open()
func close() -> void:
if _mobile_mode:
if _mobile_root and is_instance_valid(_mobile_root) and ui:
ui.close(_mobile_root)
_mobile_root = null
return
if is_open():
ui.close(_win["root"])
_win = {}
func open() -> void:
if _mobile_mode:
_open_mobile()
return
if is_open():
return
var path := uiscript_dir.path_join("systemoptiondialog.py")
@@ -97,6 +118,91 @@ func open() -> void:
_wire()
_sync_controls()
func _open_mobile() -> void:
if is_open():
return
_mobile_root = Control.new()
_mobile_root.name = "MobileSystemOptionWindow"
_mobile_root.size = Vector2(680, 340)
_mobile_root.set_meta("mobile_title", "系统设置")
_mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP
var panel := Panel.new()
panel.set_anchors_preset(Control.PRESET_FULL_RECT)
panel.add_theme_stylebox_override("panel", _mobile_panel_style())
_mobile_root.add_child(panel)
var content := VBoxContainer.new()
content.position = Vector2(18, 50)
content.size = Vector2(644, 270)
content.add_theme_constant_override("separation", 8)
_mobile_root.add_child(content)
_add_mobile_slider(content, "背景音乐", music_volume, func(v: float): _on_music_volume(v))
_add_mobile_slider(content, "音效", sound_volume, func(v: float): _on_sound_volume(v))
_add_mobile_radio(content, "镜头距离", ["", ""], camera_mode,
func(i: int): camera_mode = i; _apply_camera(); _save())
_add_mobile_radio(content, "", ["", "", ""], fog_level,
func(i: int): fog_level = i; _apply_fog(); _save())
_add_mobile_radio(content, "图形内存", ["CPU", "GPU"], tiling_mode,
func(i: int): tiling_mode = i; _save())
var hint := Label.new()
hint.text = "设置会自动保存;图形内存选项将在下次启动时使用"
hint.add_theme_font_size_override("font_size", 11)
hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84))
content.add_child(hint)
ui.open(_mobile_root)
func _add_mobile_slider(parent: VBoxContainer, caption: String, initial: float, changed: Callable) -> void:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(640, 42)
var label := Label.new()
label.text = caption
label.custom_minimum_size = Vector2(92, 38)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 13)
row.add_child(label)
var slider := HSlider.new()
slider.min_value = 0.0
slider.max_value = 1.0
slider.step = 0.01
slider.value = initial
slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL
slider.custom_minimum_size = Vector2(500, 38)
slider.value_changed.connect(changed)
row.add_child(slider)
parent.add_child(row)
func _add_mobile_radio(parent: VBoxContainer, caption: String, choices: Array, active: int, picked: Callable) -> void:
var row := HBoxContainer.new()
row.custom_minimum_size = Vector2(640, 44)
var label := Label.new()
label.text = caption
label.custom_minimum_size = Vector2(92, 40)
label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", 13)
row.add_child(label)
var buttons: Array[Button] = []
for i in choices.size():
var button := Button.new()
button.text = String(choices[i])
button.toggle_mode = true
button.button_pressed = i == active
button.custom_minimum_size = Vector2(100, 40)
var idx := i
button.pressed.connect(func():
for other in buttons.size():
buttons[other].set_pressed_no_signal(other == idx)
picked.call(idx))
buttons.append(button)
row.add_child(button)
parent.add_child(row)
func _mobile_panel_style() -> StyleBoxFlat:
var style := StyleBoxFlat.new()
style.bg_color = Color(0.04, 0.065, 0.1, 0.98)
style.border_color = Color(0.52, 0.68, 0.9, 0.86)
style.set_border_width_all(1)
style.set_corner_radius_all(8)
return style
func _node(nm: String) -> Control:
if _win.is_empty():
return null
+252 -2
View File
@@ -11,16 +11,25 @@ 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
@@ -35,8 +44,69 @@ func _ready() -> void:
_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 --------------------------------------------------------
@@ -61,7 +131,11 @@ func open(win: Control, modal := false) -> void:
if win.get_parent() == null:
_root.add_child(win)
win.set_meta("modal", modal)
_wire_drag(win)
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()
@@ -70,6 +144,13 @@ func open(win: Control, modal := false) -> void:
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)
@@ -78,9 +159,19 @@ func close(win: Control) -> void:
window_closed.emit(win)
func close_top() -> bool:
if _close_mobile_popup():
return true
if _stack.is_empty():
return false
close(_stack[-1])
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:
@@ -90,6 +181,9 @@ func top() -> Control:
# 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗;
# 模态确认框只允许 ESC 回到窗口栈。
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]
@@ -97,6 +191,10 @@ func blocks_game_input(event: InputEvent) -> bool:
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)
if event is InputEventKey:
@@ -112,6 +210,115 @@ func _input(event: InputEvent) -> void:
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():
@@ -120,6 +327,9 @@ func _restack() -> void:
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()
@@ -129,9 +339,49 @@ func _restack() -> void:
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:
+48
View File
@@ -0,0 +1,48 @@
# UiProfile —— desktop / mobile presentation selection.
#
# The profile only selects presentation and input surfaces. It must never
# change the M2 protocol or gameplay rules. Desktop developers can force the
# mobile HUD with `--mobile-ui` (or the project setting below) without needing
# an Android build.
extends RefCounted
enum Mode { AUTO, DESKTOP, MOBILE }
const SETTING := "mt/ui/profile"
const MOBILE_ARG := "--mobile-ui"
const DESKTOP_ARG := "--desktop-ui"
static func parse(value: String) -> int:
match value.strip_edges().to_lower():
"mobile":
return Mode.MOBILE
"desktop":
return Mode.DESKTOP
_:
return Mode.AUTO
static func resolve(value: String = "") -> int:
var requested := value
if requested == "":
requested = String(ProjectSettings.get_setting(SETTING, "auto"))
var parsed := parse(requested)
if parsed != Mode.AUTO:
return parsed
for arg in OS.get_cmdline_args():
if arg == MOBILE_ARG:
return Mode.MOBILE
if arg == DESKTOP_ARG:
return Mode.DESKTOP
return Mode.MOBILE if OS.has_feature("mobile") else Mode.DESKTOP
static func is_mobile(value: String = "") -> bool:
return resolve(value) == Mode.MOBILE
static func name(mode: int) -> String:
match mode:
Mode.MOBILE:
return "mobile"
Mode.DESKTOP:
return "desktop"
_:
return "auto"
+1
View File
@@ -0,0 +1 @@
uid://hvyi07nm3dbg