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
+1
View File
@@ -0,0 +1 @@
uid://p7skimuw8kud
+46 -5
View File
@@ -6,7 +6,8 @@
# af.start(assets_root) # assets_root 传给 game_scene
#
# 状态机:LOGIN → SELECT → GAME。M2Client 信号驱动切屏。
# 断线:任何阶段回到登录阶段,和 ClientVS22 的 SetLoginPhase 一致。
# 断线:登录/选人阶段回到登录;游戏阶段保留 GameScene,显示重连遮罩,
# 重连成功后自动重新进入上次角色,避免短暂闪回选人界面。
extends Node
const ServerInfoRes = preload("res://net/serverinfo.gd")
@@ -15,6 +16,8 @@ const LoadingScreen = preload("res://ui/loading_screen.gd")
const CharSelectScreen = preload("res://ui/char_select_screen.gd")
const GameScene = preload("res://game_scene.gd")
const AppLifecycle = preload("res://app_lifecycle.gd")
const ReconnectUI = preload("res://ui/reconnect_ui.gd")
const UiProfile = preload("res://ui/ui_profile.gd")
enum { LOGIN, SELECT, GAME }
@@ -26,12 +29,15 @@ var _ui: CanvasLayer # 登录 / 选人的临时界面层
var _loading: CanvasLayer
var _game: Node
var _lifecycle: Node # 全流程唯一生命周期协调者
var _reconnect: Node
var build_game_scene := true # 测试里置 false,只跑状态机不建重场景
var _sel_server := 0
var _sel_channel := 1
var _id := OS.get_environment("MT_ACCOUNT")
var _pw := OS.get_environment("MT_PASSWORD")
var _chars: Array = []
var _selected_char_slot := -1
var _reconnecting_game := false
var _mark_redl_after := 0 # guild-mark re-download cooldown (ticks_msec)
# MT_AUTOLOGIN=1:进 LOGIN 自动连接,收到角色列表自动选第一个(冒烟 / CI 用)。
var auto_login := OS.get_environment("MT_AUTOLOGIN") == "1" and not _id.is_empty() and not _pw.is_empty()
@@ -60,6 +66,11 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
_lifecycle.name = "AppLifecycle"
add_child(_lifecycle)
_lifecycle.bind(client)
_reconnect = ReconnectUI.new()
_reconnect.name = "ReconnectUI"
add_child(_reconnect)
_reconnect.setup(client)
_reconnect.set_mobile_mode(UiProfile.resolve() == UiProfile.Mode.MOBILE)
_goto_login()
if auto_login:
call_deferred("_do_connect")
@@ -80,6 +91,16 @@ func _wire_client() -> void:
func _on_char_list(list: Array) -> void:
_chars = list
if _reconnecting_game:
# A full reconnect replays the character list before it can enter the
# previous character. Do not briefly replace the live game with SELECT;
# re-enter the remembered slot and keep the reconnect mask on screen.
var slot := _selected_char_slot
if slot < 0 and not _chars.is_empty():
slot = int(_chars[0].get("index", 0))
if slot >= 0:
_enter_character(slot)
return
# 已在选人页(建号 / 删号后 M2Client 会重发 char_list)——只刷数据,别整屏重建
if _state == SELECT and _ui:
var sc: Node = _ui.get_node_or_null("CharSelect")
@@ -100,6 +121,9 @@ func _on_char_name_changed(pid: int, name: String) -> void:
sc.on_char_name_changed(pid, name)
func _on_entered_game() -> void:
_reconnecting_game = false
if _reconnect and _reconnect.has_method("dismiss"):
_reconnect.dismiss()
_goto_game()
_start_guild_mark_download()
@@ -125,12 +149,24 @@ func _on_guild_mark_updated(_guild_id: int, _img_idx: int) -> void:
_start_guild_mark_download()
func _on_login_failed(reason: String) -> void:
if _reconnecting_game:
if _reconnect and _reconnect.has_method("set_error"):
_reconnect.set_error(reason)
return
if _state != GAME:
_goto_login()
_set_status("登录失败:%s" % reason)
func _on_disconnected(reason: String) -> void:
if _state == GAME and _game != null and is_instance_valid(_game):
_reconnecting_game = true
if _reconnect and _reconnect.has_method("set_error"):
_reconnect.set_error(reason)
return
_reconnecting_game = false
_goto_login()
if _reconnect and _reconnect.has_method("dismiss"):
_reconnect.dismiss()
_set_status("连接断开:%s" % reason)
# --- 切屏 ------------------------------------------------------------------
@@ -142,6 +178,7 @@ func _clear_ui() -> void:
func _goto_login() -> void:
_state = LOGIN
_reconnecting_game = false
if _lifecycle:
# GameScene 即将释放,不能让生命周期协调者保留悬空 Audio 引用。
_lifecycle.bind(client)
@@ -334,10 +371,7 @@ func _build_char_list() -> void:
_ui.add_child(screen)
screen.setup(client, _assets, _chars)
screen.select_requested.connect(func(idx: int):
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx))
_enter_character(idx))
screen.back_requested.connect(func():
if client.has_method("disconnect_from_server"):
client.disconnect_from_server()
@@ -376,3 +410,10 @@ func _on_marks_ready_for_select() -> void:
var sc: Node = _ui.get_node_or_null("CharSelect") if _ui else null
if sc and sc.has_method("refresh_crest"):
sc.refresh_crest()
func _enter_character(idx: int) -> void:
_selected_char_slot = idx
if client.has_method("enter_game"):
client.enter_game(idx)
elif client.has_method("select_character"):
client.select_character(idx)
+27
View File
@@ -15,6 +15,14 @@ class FakeClient extends Node:
signal char_create_failed(reason: int)
signal char_deleted(slot: int)
signal char_delete_failed()
var selects := []
var reconnects := 0
func select_character(index: int) -> bool:
selects.append(index)
return true
func reconnect() -> bool:
reconnects += 1
return true
var _fail := 0
@@ -41,6 +49,25 @@ func _init() -> void:
_ck(client.get_parent() == flow,
"AppFlow keeps the same client beside lifecycle coordinator")
# A live game keeps its scene during a transient disconnect. ReconnectUI
# owns the retry while the next character list silently re-enters the last
# selected slot instead of flashing the character-select page.
flow._game = Node.new()
flow.add_child(flow._game)
flow._state = AppFlow.GAME
flow._selected_char_slot = 2
client.disconnected.emit("wifi lost")
_ck(flow.state() == AppFlow.GAME and flow._reconnecting_game,
"game disconnect enters reconnecting state without leaving GAME")
_ck(flow._reconnect != null and flow._reconnect.is_showing(),
"game disconnect shows reconnect overlay")
client.char_list.emit([{"index": 2, "name": "Hero"}])
_ck(client.selects == [2], "reconnect re-enters remembered character slot")
client.entered_game.emit()
_ck(flow.state() == AppFlow.GAME and not flow._reconnecting_game
and not flow._reconnect.is_showing(),
"successful reconnect restores GAME and hides overlay")
flow.queue_free()
await process_frame
if _fail == 0:
+1
View File
@@ -0,0 +1 @@
uid://dn635j1v44kp5
+48
View File
@@ -67,6 +67,54 @@ func _run() -> void:
drag.position = Vector2(140, 120)
atlas._on_map_input(drag)
_ck(atlas._map.position != old_pos, "atlas left drag moves map")
# 移动端地图用单指平移;第二根手指出现后,当前拖动必须被冻结,不能
# 把双指手势继续解释成地图移动或世界点地。
atlas._recenter_map()
var single_down := InputEventScreenTouch.new()
single_down.index = 4
single_down.pressed = true
single_down.position = Vector2(120, 120)
atlas._on_map_input(single_down)
var single_drag := InputEventScreenDrag.new()
single_drag.index = 4
single_drag.position = Vector2(160, 145)
single_drag.relative = Vector2(40, 25)
atlas._on_map_input(single_drag)
_ck(atlas._map.position != atlas._map_origin, "atlas single-finger touch drag moves map")
var single_up := InputEventScreenTouch.new()
single_up.index = 4
single_up.pressed = false
single_up.position = single_drag.position
atlas._on_map_input(single_up)
atlas._recenter_map()
var first_down := InputEventScreenTouch.new()
first_down.index = 5
first_down.pressed = true
first_down.position = Vector2(120, 120)
atlas._on_map_input(first_down)
var second_down := InputEventScreenTouch.new()
second_down.index = 6
second_down.pressed = true
second_down.position = Vector2(300, 120)
atlas._on_map_input(second_down)
var before_two_finger_drag: Vector2 = atlas._map.position
var two_finger_drag := InputEventScreenDrag.new()
two_finger_drag.index = 5
two_finger_drag.position = Vector2(220, 220)
two_finger_drag.relative = Vector2(100, 100)
atlas._on_map_input(two_finger_drag)
_ck(atlas._map.position == before_two_finger_drag,
"two-finger map gesture does not pan from one finger")
var first_up := InputEventScreenTouch.new()
first_up.index = 5
first_up.pressed = false
first_up.position = two_finger_drag.position
atlas._on_map_input(first_up)
var second_up := InputEventScreenTouch.new()
second_up.index = 6
second_up.pressed = false
second_up.position = Vector2(300, 120)
atlas._on_map_input(second_up)
var reset := InputEventMouseButton.new()
reset.button_index = MOUSE_BUTTON_RIGHT
reset.pressed = true
+1
View File
@@ -0,0 +1 @@
uid://ctbdjmg33pe67
+16
View File
@@ -150,3 +150,19 @@ func _run() -> void:
cs.close()
_ck(not cs.is_open(), "character window closed")
# 移动端使用独立横屏视图,不把 PC 固定坐标页面缩小后直接复用。
ui.set_mobile_mode(true)
cs.set_mobile_mode(true)
cs.open("STATUS")
_ck(cs.is_open(), "mobile character window opened")
_ck(cs._mobile_root.size == Vector2(760, 340), "mobile character base size is landscape")
_ck(cs._mobile_status_page.visible and not cs._mobile_emote_page.visible,
"mobile character opens on status/equipment page")
_ck(cs._mobile_stats.has("STR") and cs._mobile_stats["STR"]["value"].text == "33",
"mobile character binds STR")
cs.open("EMOTICON")
_ck(not cs._mobile_status_page.visible and cs._mobile_emote_page.visible,
"mobile character switches to emote page")
cs.close()
_ck(not cs.is_open(), "mobile character window closed")
+51
View File
@@ -0,0 +1,51 @@
# chat_mobile_ui_test —— 横屏聊天页与私聊输入的移动布局自检。
extends SceneTree
const ChatUI = preload("res://ui/chat_ui.gd")
class FakeClient extends Node:
var sent := []
func say(channel: int, text: String) -> bool:
sent.append(["say", channel, text]); return true
func whisper(name: String, text: String) -> bool:
sent.append(["whisper", name, text]); return true
var _fail := 0
func _ck(c: bool, m: String) -> void:
if not c:
_fail += 1
printerr("FAIL: " + m)
func _init() -> void:
_run()
if _fail == 0:
print("PASS: chat_mobile_ui_test (landscape chat + whisper input)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var client := FakeClient.new()
get_root().add_child(client)
var chat := ChatUI.new()
get_root().add_child(chat)
chat.setup(client, get_root())
chat.set_mobile_mode(true)
_ck(chat._root.size == Vector2(760, 340), "mobile chat uses landscape page size")
_ck(chat._log[0].size == Vector2(732, 190), "mobile chat log has touch-friendly content area")
_ck(chat._input.size == Vector2(732, 38), "mobile chat input is keyboard-friendly")
_ck(not chat.is_log_visible(), "mobile chat is hidden until explicitly opened")
chat.toggle_log()
_ck(chat.is_log_visible(), "chat opens from menu")
chat._on_submit("/s hello")
_ck(client.sent == [["say", 6, "hello"]], "chat channel prefix remains server-compatible")
chat.start_whisper("Bob")
_ck(chat.is_whisper_open(), "whisper dialog opens")
_ck(chat._whisper_dialog.size == Vector2(480, 260), "mobile whisper uses landscape touch layout")
chat._on_whisper_submit("hi")
_ck(client.sent.size() == 2 and client.sent[1] == ["whisper", "Bob", "hi"],
"mobile whisper sends target and message")
chat.close()
chat.queue_free()
client.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://bhyev28dml0wh
+1
View File
@@ -0,0 +1 @@
uid://bilb10438dl5i
+1
View File
@@ -0,0 +1 @@
uid://dh4ucte7pxc3u
+1
View File
@@ -0,0 +1 @@
uid://x2vuateiv3w1
+1
View File
@@ -0,0 +1 @@
uid://dlpn6gv5jrh14
+1
View File
@@ -0,0 +1 @@
uid://c77cvj7gtqck4
+1
View File
@@ -0,0 +1 @@
uid://icsacgdld3gb
+1
View File
@@ -0,0 +1 @@
uid://da5ixd8n62gyh
+1
View File
@@ -0,0 +1 @@
uid://exadl2wqkpmh
+1
View File
@@ -0,0 +1 @@
uid://7g66aavury6r
+1
View File
@@ -0,0 +1 @@
uid://b8hnrnm5whxx
+1
View File
@@ -0,0 +1 @@
uid://crv0udnsb76ik
+1
View File
@@ -0,0 +1 @@
uid://dq43hpy353dab
+1
View File
@@ -0,0 +1 @@
uid://dl4x22ievekvx
+1
View File
@@ -0,0 +1 @@
uid://faq2wm5101df
+1
View File
@@ -0,0 +1 @@
uid://ub000mncflac
+1
View File
@@ -0,0 +1 @@
uid://312cgt6dprj8
+1
View File
@@ -0,0 +1 @@
uid://bs8t4ae40g81s
+1
View File
@@ -0,0 +1 @@
uid://vei6jny5w1m2
+88 -8
View File
@@ -17,6 +17,8 @@ const PlayerCtl = preload("res://player_controller.gd")
const NetWorld = preload("res://net_world.gd")
const NetPlay = preload("res://net_play.gd")
const Hud = preload("res://hud.gd")
const UiProfile = preload("res://ui/ui_profile.gd")
const MobileUiRoot = preload("res://ui/mobile/mobile_ui_root.gd")
const Audio = preload("res://audio.gd")
const BgmDirector = preload("res://bgm_director.gd")
const UiManager = preload("res://ui/ui_manager.gd")
@@ -134,11 +136,13 @@ var _guild_invite_dialog: ConfirmationDialog
var _gift_dialog: AcceptDialog
var _quick_page_mode := false
var _show_names := false
var _ui_mobile := false
func setup(m2client: Node, assets_root: String,
initial_map_path: String = "OutdoorA1/metin2_map_a1") -> void:
client = m2client
_assets = assets_root
_ui_mobile = UiProfile.resolve() == UiProfile.Mode.MOBILE
# GC_MAIN_CHARACTER only carries the server-space coordinates, not the map
# folder. The old fixed A1 fallback puts characters from A2/A3 outside the
# rendered terrain, which looks like an empty map. Resolve the map from the
@@ -187,12 +191,17 @@ func setup(m2client: Node, assets_root: String,
or int(pickable.get_meta("vid", -1)) == vid:
pc.pickables.erase(pickable))
hud = Hud.new()
if _map_loaded():
hud.setup(world, player) # 含小地图 / 快捷栏 / 背包
else:
if _ui_mobile:
hud = MobileUiRoot.new()
add_child(hud)
hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级)
hud.setup(world, player, client)
else:
hud = Hud.new()
add_child(hud)
if _map_loaded():
hud.setup(world, player) # 含小地图 / 快捷栏 / 背包
else:
hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级)
net_play = NetPlay.new()
add_child(net_play)
@@ -541,6 +550,9 @@ func setup(m2client: Node, assets_root: String,
if client.has_signal("channel_changed") and hud and hud.has_method("set_channel"):
client.channel_changed.connect(func(c): hud.set_channel(c))
if _ui_mobile and hud and hud.has_method("bind_controls"):
_setup_mobile_ui()
client.entity_main_set.connect(_on_main_set)
if client.get_main_vid() != 0: # 重连 / 已在局内
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
@@ -558,8 +570,50 @@ func setup(m2client: Node, assets_root: String,
net_world.catch_up()
set_process_unhandled_input(true)
func _setup_mobile_ui() -> void:
# MobileUiRoot owns the presentation and touch layer; every feature node in
# this dictionary remains the existing PC/business controller.
hud.bind_controls({
"client": client,
"ui": ui,
"lifecycle": _lifecycle_node(),
"player_controller": pc,
"net_play": net_play,
"quickbar": quickbar,
"ground_items": ground_items,
"inventory": inventory,
"atlas_ui": atlas_ui,
"quest_log": quest_log,
"party_ui": party_ui,
"char_status_ui": char_status_ui,
"skills": skills,
"chat": chat,
"friend_ui": friend_ui,
"guild_ui": guild_ui,
"love_ui": love_ui,
"refine_ui": refine_ui,
"cube_ui": cube_ui,
"mall_ui": mall_ui,
"shop_ui": shop_ui,
"exchange_ui": exchange_ui,
"safebox_ui": safebox_ui,
"private_shop_ui": private_shop_ui,
"system_menu_ui": system_menu_ui,
"system_option_ui": system_option_ui,
"game_option_ui": game_option_ui,
"minimap": minimap,
})
set_process(true)
func _lifecycle_node() -> Node:
# AppFlow owns the single lifecycle observer across login/select/game. The
# game scene only borrows it for Android back and focus recovery.
var owner := get_parent()
if owner:
return owner.get_node_or_null("AppLifecycle")
return null
func _process(_dt: float) -> void:
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
# node keeps its listener at the origin and rebases active 3D instances.
@@ -591,14 +645,17 @@ func _on_guild_invite(guild_id: int, guild_name: String) -> void:
_guild_invite_dialog.dialog_text = "加入公会“%s”?" % guild_name
_guild_invite_dialog.ok_button_text = "加入"
_guild_invite_dialog.cancel_button_text = "拒绝"
_guild_invite_dialog.set_meta("mobile_title", "公会邀请")
ui.add_child(_guild_invite_dialog)
if ui.has_method("track_mobile_modal"):
ui.track_mobile_modal(_guild_invite_dialog)
_guild_invite_dialog.confirmed.connect(func():
client.guild_answer_invite(guild_id, true)
_guild_invite_dialog.queue_free())
_guild_invite_dialog.canceled.connect(func():
client.guild_answer_invite(guild_id, false)
_guild_invite_dialog.queue_free())
_guild_invite_dialog.popup_centered()
_popup_guild_invite()
func _on_gift_available() -> void:
# 40250's Gift_Show has no corresponding claim packet in the shipped
@@ -607,17 +664,40 @@ func _on_gift_available() -> void:
if ui == null:
return
if _gift_dialog and is_instance_valid(_gift_dialog):
_gift_dialog.popup_centered()
_popup_gift()
return
_gift_dialog = AcceptDialog.new()
_gift_dialog.title = "礼物"
_gift_dialog.dialog_text = "有新的礼物可查看。"
_gift_dialog.ok_button_text = "知道了"
_gift_dialog.set_meta("mobile_title", "礼物")
ui.add_child(_gift_dialog)
if ui.has_method("track_mobile_modal"):
ui.track_mobile_modal(_gift_dialog)
_gift_dialog.confirmed.connect(func():
if is_instance_valid(_gift_dialog):
_gift_dialog.queue_free())
_gift_dialog.popup_centered()
_popup_gift()
func _popup_guild_invite() -> void:
if not is_instance_valid(_guild_invite_dialog):
return
if _ui_mobile:
_guild_invite_dialog.min_size = Vector2i(500, 230)
_guild_invite_dialog.size = Vector2i(500, 230)
_guild_invite_dialog.popup_centered(Vector2i(500, 230))
else:
_guild_invite_dialog.popup_centered()
func _popup_gift() -> void:
if not is_instance_valid(_gift_dialog):
return
if _ui_mobile:
_gift_dialog.min_size = Vector2i(460, 210)
_gift_dialog.size = Vector2i(460, 210)
_gift_dialog.popup_centered(Vector2i(460, 210))
else:
_gift_dialog.popup_centered()
# .msa MotionEventData 分派(对齐 GameLib/RaceMotionData EMotionEventType):
# 1 EFFECT / 10 EFFECT_TO_TARGET → 挂特效;2 SCREEN_WAVING → 震屏;
+15
View File
@@ -4,6 +4,7 @@
extends SceneTree
const GameScene = preload("res://game_scene.gd")
const MobileUiRoot = preload("res://ui/mobile/mobile_ui_root.gd")
class FakeClient extends Node:
signal entity_spawned(entity: Dictionary)
@@ -122,3 +123,17 @@ func _run() -> void:
_ck(true, "points_changed handled without error")
gs.queue_free()
await process_frame
# Mobile profile uses the same scene assembly and NetPlay HUD contract, but
# swaps only the presentation root. This also guards against accidentally
# constructing the desktop HUD alongside the touch layer.
ProjectSettings.set_setting("mt/ui/profile", "mobile")
var mobile_gs: Node3D = GameScene.new()
get_root().add_child(mobile_gs)
await mobile_gs.setup(fc, "", "no_such_map")
await process_frame
_ck(mobile_gs.hud is MobileUiRoot, "mobile profile selects MobileUiRoot")
_ck(mobile_gs.hud.input_overlay != null, "mobile profile creates touch overlay")
mobile_gs.queue_free()
ProjectSettings.set_setting("mt/ui/profile", "auto")
+2
View File
@@ -39,10 +39,12 @@ func _run_checks() -> void:
var gu: Node = GuildUI.new()
get_root().add_child(gu)
gu.setup(fc, canvas)
gu.set_mobile_mode(true)
# 服务端请求应弹出创建窗口,且只复用一个窗口。
fc.guild_make_requested.emit()
_ck(is_instance_valid(gu._create_dialog), "guild_make_requested -> 创建公会窗口")
_ck(gu._create_dialog.size == Vector2i(520, 270), "移动创建公会弹窗使用横屏尺寸")
_ck(gu._create_name_edit.max_length == 12, "名称输入限制为 12 字")
var first_dialog = gu._create_dialog
fc.guild_make_requested.emit()
+1
View File
@@ -0,0 +1 @@
uid://ch0kvrxf13dnh
+10
View File
@@ -139,3 +139,13 @@ func _run() -> void:
b.pressed.emit()
break
_ck(fc.calls[-1] == ["delete_comment", 9], "公告页:删除")
# 移动端公会页使用横屏高触控密度布局,四页业务仍是同一套控制器。
gu.close()
gu.set_mobile_mode(true)
gu.toggle()
_ck(gu.is_open() and gu._root.size == Vector2(700, 340),
"移动公会窗使用横屏基准尺寸")
_ck(gu._scroll.size == Vector2(664, 194), "移动公会窗内容区避开导航栏")
_ck(gu._tab_btns[0].custom_minimum_size == Vector2(160, 38),
"移动公会页签使用大触控目标")
+10
View File
@@ -113,3 +113,13 @@ func _run() -> void:
_ck(is_instance_valid(sm.help_ui), "SystemMenuUI.open_help() lazily builds help_ui")
_ck(sm.help_ui.is_open(), "SystemMenuUI.open_help() opens the help window")
sm.help_ui.close()
# 移动端帮助页使用横屏滚动布局,不能把 PC 帮助图缩到不可读。
ui.set_mobile_mode(true)
hw.set_mobile_mode(true)
hw.open()
_ck(hw.is_open() and hw._mobile_root.size == Vector2(760, 340),
"mobile help window uses landscape base size")
_ck(hw.rows().size() == 19 and not hw.rows()[0].begins_with("<"),
"mobile help keeps all localized rows")
hw.close()
+1
View File
@@ -0,0 +1 @@
uid://iiiuyg47tudg
+22 -1
View File
@@ -97,8 +97,29 @@ func _run() -> void:
_ck(iv._drop_mouse_item({"window": 1, "cell": 6, "vnum": 27, "count": 3}, 8),
"mouse drop accepts inventory stack")
_ck(fc.moved.size() == 2 and fc.moved[1] == [1, 6, 1, 8, 3],
"mouse drop preserves attached stack count")
"mouse drop preserves attached stack count")
iv.close()
_ck(not iv.is_open(), "window closed")
# 移动端长按详情与数量输入:不依赖右键、悬停或 PC SpinBox 微小箭头。
ui.set_mobile_mode(true)
iv.set_mobile_mode(true)
iv.open()
var long_down := InputEventScreenTouch.new()
long_down.index = 31
long_down.pressed = true
long_down.position = Vector2(20, 20)
iv._on_cell_input(6, long_down)
await create_timer(0.65).timeout
_ck(iv._mobile_detail_dialog != null, "mobile long-press detail opens")
_ck(ui.top() == iv._mobile_detail_dialog, "detail is a modal window above inventory")
ui.close_top()
_ck(iv._mobile_detail_dialog == null, "mobile detail closes with back")
iv._ask_count("丢弃数量", 3, func(_amount): pass)
var count_dialog: Control = ui.top()
_ck(count_dialog != null and count_dialog.get_node_or_null("CountInput") is LineEdit,
"mobile count dialog uses numeric LineEdit")
ui.close_top()
iv.close()
ui.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://bmarcd40nexwg
+190
View File
@@ -0,0 +1,190 @@
# mobile_gesture_test —— 移动端长按、瞄准、快捷栏交换和物品触摸拖放。
# godot --display-driver headless --audio-driver Dummy --rendering-method gl_compatibility --path project --script mobile_gesture_test.gd
extends SceneTree
const TouchButton = preload("res://ui/mobile/mobile_touch_button.gd")
const Quickbar = preload("res://ui/quickbar.gd")
const SkillTable = preload("res://ui/skill_table.gd")
const SkillUI = preload("res://ui/skill_ui.gd")
const UiManager = preload("res://ui/ui_manager.gd")
const InventoryUI = preload("res://ui/inventory_ui.gd")
const MouseController = preload("res://ui/mouse_controller.gd")
class FakeClient extends Node:
var inventory := [{"cell": 5, "vnum": 19, "count": 2}]
var skills := [{"id": 1, "level": 5, "master": 0}, {"id": 16, "level": 3, "master": 0}]
var quickslots := []
var calls := []
var casts := []
var main := 1000
func get_main_vid() -> int: return main
func get_entity(_vid: int) -> Dictionary: return {"vid": _vid, "kind": 2, "race": 0}
func get_target() -> Dictionary: return {"vid": 2000}
func get_inventory() -> Array: return inventory
func get_equipment() -> Array: return []
func get_skills() -> Array: return skills
func get_skill_group() -> int: return 1
func get_points() -> Dictionary: return {"level": 30, "skill_active": 5, "skill_support": 3, "skill_horse": 2}
func get_quickslots() -> Array: return quickslots
func use_skill(id: int, target: int) -> bool:
calls.append(["use_skill", id, target]); return true
func cast_skill(motion: int, heading: float, x: int, y: int) -> bool:
casts.append([motion, heading, x, y]); return true
func quickslot_add(pos: int, kind: int, ref: int) -> bool:
calls.append(["quickslot_add", pos, kind, ref]); return true
func quickslot_swap(a: int, b: int) -> bool:
calls.append(["quickslot_swap", a, b]); return true
func use_item(window: int, cell: int) -> bool:
calls.append(["use_item", window, cell]); return true
func move_item(fw: int, fc: int, tw: int, tc: int, count: int) -> bool:
calls.append(["move_item", fw, fc, tw, tc, count]); return true
class FakeProto extends Node:
func item(vnum: int) -> Dictionary:
return {"vnum": vnum, "name": "Item%d" % vnum, "locale_name": "物品%d" % vnum,
"type": 1, "sub_type": 0, "anti_flags": 0, "wear_flags": 1 << 4,
"limits": []}
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: mobile_gesture_test (long press + aim + quickbar swap + item touch drag)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
await process_frame
# 1) 长按按钮必须保留同一 touch index,并在拖动松手时发出完成信号。
var gesture_host := Control.new()
get_root().add_child(gesture_host)
var gesture := TouchButton.new()
gesture.setup("技能", Color(0.4, 0.7, 1.0))
gesture.set_gesture_enabled(true)
gesture.size = Vector2(72, 72)
gesture_host.add_child(gesture)
var press_count := [0]
var long_count := [0]
var release_count := [0]
gesture.pressed.connect(func(): press_count[0] += 1)
gesture.long_pressed.connect(func(): long_count[0] += 1)
gesture.drag_released.connect(func(_delta: Vector2): release_count[0] += 1)
var down := InputEventScreenTouch.new()
down.index = 41
down.pressed = true
down.position = Vector2(30, 30)
gesture._gui_input(down)
await create_timer(0.65).timeout
var drag := InputEventScreenDrag.new()
drag.index = 41
drag.position = Vector2(52, 12)
gesture._gui_input(drag)
var up := InputEventScreenTouch.new()
up.index = 41
up.pressed = false
up.position = drag.position
gesture._gui_input(up)
_ck(press_count[0] == 1, "长按手势只触发一次按下")
_ck(long_count[0] == 1, "超过阈值后触发 long_pressed")
_ck(release_count[0] == 1 and not gesture.active, "拖动松手发出 drag_released 并清理状态")
# 2) 快捷栏保持服务器槽位交换,同时瞄准方向进入 cast_skill heading。
var assets := AssetRoot.path()
var table := SkillTable.new()
var loaded := false
for lang in ["en", "common"]:
if table.load_file(assets.path_join("locale/locale/%s/skilldesc.txt" % lang)):
loaded = true
break
if not loaded:
print(" (skip: no skilldesc.txt)")
return
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
ui._ready()
ui.set_mobile_mode(true)
var client := FakeClient.new()
get_root().add_child(client)
var player := Node3D.new()
get_root().add_child(player)
var quickbar: Node = Quickbar.new()
get_root().add_child(quickbar)
quickbar.setup(client, table, ui, func() -> Node: return player)
var skill_ui: Node = SkillUI.new()
get_root().add_child(skill_ui)
skill_ui.setup(client, table, ui)
skill_ui.set_mobile_mode(true)
skill_ui.set_mobile_quickbar(quickbar)
skill_ui.open()
await process_frame
_ck(skill_ui._mobile_drop_slots.size() == 8, "技能页创建 8 个移动快捷栏落点")
# 用真实技能行按钮的拖放结束坐标,验证技能可放入指定槽位。
var source: TouchButton = null
for row in skill_ui._win.get_node("SkillScroll/list").get_children():
if row.get_child_count() > 0 and row.get_child(0) is TouchButton:
source = row.get_child(0)
break
if source:
var target_pos: Vector2 = skill_ui._mobile_drop_slots[2].get_global_rect().get_center()
source._last_position = target_pos
skill_ui._mobile_begin_skill_drag(1, source)
skill_ui._mobile_end_skill_drag(1, source)
_ck(quickbar.mobile_slot_state(2).get("id", 0) == 1,
"长按技能拖放到第 3 格快捷栏")
quickbar.assign(0, "skill", 16)
quickbar.mobile_swap(0, 2)
_ck(quickbar.mobile_slot_state(0).get("id", 0) == 1
and quickbar.mobile_slot_state(2).get("id", 0) == 16,
"移动快捷栏交换更新本地槽位")
_ck(client.calls.has(["quickslot_swap", 0, 2]),
"移动快捷栏交换沿用 CG_QUICKSLOT_SWAP")
quickbar.activate_aimed(0, Vector2(1, 0))
_ck(client.casts.size() == 1 and client.calls.has(["use_skill", 1, 2000]),
"技能长按瞄准仍走 use_skill + cast_skill")
# 3) 背包长按详情后继续拖动,松手由 MouseController 落到目标格。
var proto := FakeProto.new()
get_root().add_child(proto)
var mouse: Node = MouseController.new()
get_root().add_child(mouse)
mouse.setup(ui)
var inventory: Node = InventoryUI.new()
inventory.item_mouse = mouse
get_root().add_child(inventory)
inventory.setup(ui, client, proto, assets)
inventory.set_mobile_mode(true)
inventory.open()
await process_frame
var source_cell: Panel = inventory._cells.get(5)
var target_cell: Panel = inventory._cells.get(8)
if source_cell and target_cell:
var item_down := InputEventScreenTouch.new()
item_down.index = 52
item_down.pressed = true
item_down.position = source_cell.get_global_rect().get_center()
inventory._on_cell_input(5, item_down)
await create_timer(0.65).timeout
_ck(inventory._mobile_detail_dialog != null, "物品长按先打开详情卡")
var item_drag := InputEventScreenDrag.new()
item_drag.index = 52
item_drag.position = target_cell.get_global_rect().get_center()
inventory._handle_mobile_cell_drag(5, item_drag)
_ck(mouse.is_attached(), "继续拖动后进入全局物品拖放状态")
var item_up := InputEventScreenTouch.new()
item_up.index = 52
item_up.pressed = false
item_up.position = item_drag.position
inventory._input(item_up)
mouse._input(item_up)
_ck(client.calls.has(["move_item", 1, 5, 1, 8, 2]),
"物品触摸松手落到目标格并保留堆叠数量")
else:
_ck(false, "inventory source/target cells exist")
+1
View File
@@ -0,0 +1 @@
uid://j1ufi6gp5au7
+116
View File
@@ -0,0 +1,116 @@
# mobile_ui_test —— mobile presentation smoke test without a server or assets.
# godot --headless --path project --script mobile_ui_test.gd
extends SceneTree
const UiProfile := preload("res://ui/ui_profile.gd")
const MobileHud := preload("res://ui/mobile/mobile_hud.gd")
const MobileUiRoot := preload("res://ui/mobile/mobile_ui_root.gd")
const MobileMenuDrawer := preload("res://ui/mobile/mobile_menu_drawer.gd")
const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd")
const PlayerCtl := preload("res://player_controller.gd")
class FakeClient extends Node:
signal party_changed
signal vitals_changed(vid: int)
var main := 7
var entity := {"vid": 7, "name": "测试角色", "race": 0, "level": 12}
var party := [{"vid": 7, "name": "测试角色", "hp_pct": 100, "leader": true}]
func get_main_vid() -> int: return main
func get_entity(_vid: int) -> Dictionary: return entity
func get_party() -> Array: return party
func set_target(_vid: int) -> bool: return true
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: mobile_ui_test (profile / landscape HUD / menu / controls)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
await process_frame
_ck(UiProfile.parse("mobile") == UiProfile.Mode.MOBILE, "profile parses mobile")
_ck(UiProfile.parse("desktop") == UiProfile.Mode.DESKTOP, "profile parses desktop")
_ck(UiProfile.parse("auto") == UiProfile.Mode.AUTO, "profile parses auto")
_ck(MobileUiRoot.requires_landscape(Vector2(844, 390)) == false,
"landscape design size is accepted")
_ck(MobileUiRoot.requires_landscape(Vector2(390, 844)),
"portrait viewport is rejected")
var safe := MobileWindowHost.safe_rect_for(Vector2(844, 390),
Rect2(20, 8, 804, 374))
_ck(safe == Rect2(20, 8, 804, 374), "reported safe area is preserved")
_ck(MobileWindowHost.safe_rect_for(Vector2(844, 390),
Rect2(0, 0, 1920, 1080)) == Rect2(0, 0, 844, 390),
"physical-screen safe area is rejected for a logical viewport")
var client := FakeClient.new()
get_root().add_child(client)
var root := MobileUiRoot.new()
get_root().add_child(root)
root.setup(null, null, client)
root.bind_controls({"client": client})
await process_frame
_ck(root.hud_view is MobileHud, "mobile root owns mobile HUD")
_ck(root.input_overlay != null, "mobile root owns touch overlay")
_ck(root.hud_view._player_name.text == "测试角色", "avatar card uses server player name")
root.set_vitals(50, 100, 20, 40)
_ck(root.hud_view._hp_bar.value == 50 and root.hud_view._mp_bar.value == 20,
"mobile HUD updates HP/SP through compatibility setters")
root.set_target("目标", 35)
_ck(root.hud_view._target_panel.visible and root.hud_view._target_bar.value == 35,
"mobile HUD shows target status")
root.menu_drawer.open()
_ck(root.menu_drawer.is_open(), "mobile menu opens")
_ck(MobileMenuDrawer.CATEGORIES.size() == 5, "mobile menu has five top-level categories")
_ck(not MobileMenuDrawer.ITEMS["社交"].has("队伍"), "social menu has no party entry")
root.menu_drawer.close()
var pc := PlayerCtl.new()
get_root().add_child(pc)
pc.player = Node3D.new()
get_root().add_child(pc.player)
root.input_overlay.bind_controls(pc, null, null, null)
await process_frame
var touch_pos: Vector2 = root.input_overlay._joystick.get_global_rect().get_center()
var ui_down := InputEventScreenTouch.new()
ui_down.index = 21
ui_down.pressed = true
ui_down.position = touch_pos
root.input_overlay._input(ui_down)
_ck(pc._mobile_ui_touches.has(21), "UI touch is registered by world-touch arbiter")
var world_down := InputEventScreenTouch.new()
world_down.index = 22
world_down.pressed = true
world_down.position = Vector2(600, 210)
pc._on_touch(world_down)
var world_up := InputEventScreenTouch.new()
world_up.index = 22
world_up.pressed = false
world_up.position = world_down.position
pc._on_touch(world_up)
_ck(pc._multi_gesture, "joystick plus world touch cannot become tap-to-move")
var ui_up := InputEventScreenTouch.new()
ui_up.index = 21
ui_up.pressed = false
ui_up.position = touch_pos
root.input_overlay._input(ui_up)
_ck(not pc._mobile_ui_touches.has(21), "UI touch arbiter clears on release")
pc.set_mobile_axis(Vector2(2, 0))
_ck(pc._mobile_axis == Vector2(1, 0), "mobile movement axis is normalized")
pc.clear_mobile_input()
_ck(pc._mobile_axis == Vector2.ZERO, "mobile movement axis clears on cancellation")
root.queue_free()
pc.queue_free()
client.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://b1vbbltrqfkes
+74
View File
@@ -0,0 +1,74 @@
# mobile_window_host_test —— 移动窗口安全区/按钮触摸/模态返回自检。
# godot --display-driver headless --rendering-method gl_compatibility --path project --script mobile_window_host_test.gd
extends SceneTree
const UiManager = preload("res://ui/ui_manager.gd")
const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.gd")
var _fail := 0
func _ck(condition: bool, message: String) -> void:
if not condition:
_fail += 1
printerr("FAIL: " + message)
func _init() -> void:
await _run()
if _fail == 0:
print("PASS: mobile_window_host_test (safe area + touch bridge + modal back)")
quit(0)
else:
printerr("%d check(s) failed" % _fail)
quit(1)
func _run() -> void:
var ui: CanvasLayer = UiManager.new()
get_root().add_child(ui)
await process_frame
ui.set_mobile_mode(true)
var win := Panel.new()
win.name = "TouchWindow"
win.size = Vector2(420, 280)
var button := Button.new()
button.text = "触摸按钮"
button.position = Vector2(20, 60)
button.size = Vector2(160, 52)
win.add_child(button)
var count := [0]
button.pressed.connect(func(): count[0] += 1)
ui.open(win)
MobileWindowHost.wire_buttons(win)
var down := InputEventScreenTouch.new()
down.index = 11
down.pressed = true
down.position = Vector2(100, 100)
button.gui_input.emit(down)
var up := InputEventScreenTouch.new()
up.index = 11
up.pressed = false
up.position = down.position
button.gui_input.emit(up)
_ck(count[0] == 1, "standard Button screen touch emits one press")
var modal := Panel.new()
modal.name = "MobileModal"
modal.size = Vector2(200, 100)
modal.set_meta("mobile_modal", true)
modal.visible = true
win.add_child(modal)
_ck(ui.blocks_game_input(down), "mobile modal blocks screen touch from world")
_ck(ui.close_top(), "back closes the visible mobile modal first")
_ck(not modal.visible and ui.top() == win, "modal closes without closing its parent window")
_ck(ui.close_top(), "second back closes hosted feature window")
var native_modal := ConfirmationDialog.new()
native_modal.name = "NativeModal"
var canceled := [0]
native_modal.canceled.connect(func(): canceled[0] += 1)
ui.add_child(native_modal)
ui.track_mobile_modal(native_modal)
native_modal.popup_centered()
_ck(ui.blocks_game_input(down), "tracked native dialog blocks world input")
_ck(ui.close_top(), "back closes tracked native dialog first")
_ck(canceled[0] == 1, "tracked native dialog uses cancel action on back")
native_modal.queue_free()
ui.queue_free()
+1
View File
@@ -0,0 +1 @@
uid://j5tq3hj1d8jk
+1
View File
@@ -0,0 +1 @@
uid://0uv8hln06oex
+1
View File
@@ -0,0 +1 @@
uid://bqvbpbcp4338q
+1
View File
@@ -0,0 +1 @@
uid://b3jl5bspgvnf2
+1
View File
@@ -0,0 +1 @@
uid://cfmkostitpvvu
+1
View File
@@ -0,0 +1 @@
uid://b0tssfrjgab4y
+1
View File
@@ -0,0 +1 @@
uid://cjink4dvgp0qu
+1
View File
@@ -0,0 +1 @@
uid://bwqn6xii0x5xs
+15 -5
View File
@@ -121,11 +121,21 @@ func _run() -> void:
_ck(picked and fc2.selects == [0], "flow: pick char -> select_character(0)")
fc2.entered_game.emit()
_ck(af.state() == AppFlow.GAME, "flow: entered_game -> GAME")
# 在 GAME 时断线 -> 回 LOGINClientVS22 SetLoginPhase 语义)
# build_game_scene=false skips the real GameScene in this headless state
# machine test; provide its live-scene sentinel so disconnect handling is
# exercised with the same ownership contract as the real client.
af._game = Node.new()
af._game.name = "FakeGameScene"
af.add_child(af._game)
# 在 GAME 时断线 -> 保留 GAME,显示重连遮罩;重连后的角色列表静默选回角色
fc2.disconnected.emit("game dropped")
_ck(af.state() == AppFlow.LOGIN, "flow: disconnect in GAME -> LOGIN")
# 再次收到角色列表时允许回到 SELECT
_ck(af.state() == AppFlow.GAME and af._reconnecting_game,
"flow: disconnect in GAME -> reconnecting GAME")
fc2.char_list.emit([{"index": 0, "name": "Hero", "level": 42}])
_ck(af.state() == AppFlow.SELECT, "flow: back to SELECT")
_ck(fc2.selects == [0, 0], "flow: reconnect re-enters previous character")
fc2.entered_game.emit()
_ck(af.state() == AppFlow.GAME and not af._reconnecting_game,
"flow: reconnect entered_game -> GAME")
fc2.disconnected.emit("dropped")
_ck(af.state() == AppFlow.LOGIN, "flow: disconnect in SELECT -> LOGIN")
_ck(af.state() == AppFlow.GAME and af._reconnecting_game,
"flow: second game disconnect keeps GAME")
+22
View File
@@ -231,6 +231,12 @@ func _run() -> void:
fc.friend_invite_ask.emit("Eve")
fu._invite_dialog.canceled.emit()
_ck(fc.calls.has(["friend_answer", "Eve", false]), "friend: reject incoming request")
fu.set_mobile_mode(true)
fc.friend_invite_ask.emit("MobileDave")
await process_frame
_ck(fu._invite_dialog.size == Vector2i(480, 220),
"friend: mobile confirmation uses landscape touch size")
fu._invite_dialog.canceled.emit()
# --- 商店 ---
var su: Node = ShopUI.new()
@@ -285,6 +291,7 @@ func _run() -> void:
var xu: Node = ExchangeUI.new()
get_root().add_child(xu)
xu.setup(fc, canvas)
xu.set_mobile_mode(true)
fc.exchange = {
"active": true, "partner_vid": 4321,
"self_items": [{"slot": 0, "vnum": 19, "count": 1}],
@@ -294,6 +301,12 @@ func _run() -> void:
fc.inventory = [{"cell": 7, "vnum": 901, "count": 1, "anti_flags": 0}]
fc.exchange_changed.emit()
_ck(xu.is_open(), "exchange window opens when active")
_ck(xu._mobile_inventory_scroll.visible and xu._mobile_inventory_list.get_child_count() == 1,
"exchange mobile mode shows one tap-to-offer inventory candidate")
if xu._mobile_inventory_list.get_child_count() == 1:
(xu._mobile_inventory_list.get_child(0) as Button).pressed.emit()
_ck(fc.calls.has(["ex_add_item", 1, 7, 1]),
"exchange mobile candidate tap sends item offer")
_ck(xu._self_box.get_child_count() == 1, "exchange: 1 self item row")
_ck(xu._root.get_node("PeerAccept").text == "对方: 已接受", "exchange: peer accept shown")
_ck(not xu._accept_btn.disabled, "exchange: accept enabled before server accept")
@@ -332,14 +345,23 @@ func _run() -> void:
var bu: Node = SafeboxUI.new()
get_root().add_child(bu)
bu.setup(fc, canvas)
bu.set_mobile_mode(true)
fc.safebox_open = true
fc.safebox_size = 2
fc.safebox_gold = 99999
fc.safebox = [{"cell": 3, "vnum": 27, "count": 10}]
fc.inventory = [{"cell": 4, "vnum": 901, "count": 2}]
fc.safebox_changed.emit()
_ck(bu.is_open(), "safebox opens")
_ck(bu._gold.text.contains("99999"), "safebox: gold shown")
_ck(bu._list.get_child_count() == 1, "safebox: 1 item row")
_ck(bu._mobile_inventory_scroll.visible and bu._mobile_inventory_list.get_child_count() == 1,
"safebox mobile mode shows one tap-to-deposit inventory candidate")
if bu._mobile_inventory_list.get_child_count() == 1:
(bu._mobile_inventory_list.get_child(0) as Button).pressed.emit()
_ck(fc.calls.any(func(c): return c[0] == "sb_checkin" and c[2] == 1 and c[3] == 4),
"safebox mobile candidate tap sends checkin")
_ck(bu._mobile_deposit_pending.has(4), "safebox mobile prevents duplicate checkin taps")
var out_btn: Button = null
for b in bu._list.get_child(0).find_children("*", "Button", true, false):
out_btn = b
+1
View File
@@ -0,0 +1 @@
uid://tceq7sornwqf
+1
View File
@@ -0,0 +1 @@
uid://pqahjhjcoifw
+1
View File
@@ -0,0 +1 @@
uid://t763fdrluvos
+1
View File
@@ -0,0 +1 @@
uid://b6slci25nqllo
+53 -3
View File
@@ -57,6 +57,9 @@ var rotation_speed_deg := ROT_SPEED_DEFAULT_DEG # 上/下马时由 net_play 切
# (§3.2 __EnableSkipCollision,观战 / 服务器纠正时置真)。
var skip_actor_collision := false
var _last_wasd := Vector2.ZERO
var _mobile_axis := Vector2.ZERO
var _mobile_active := false
var _mobile_ui_touches := {}
# --- §3.1 Src/Dst 移动模型(对齐 InstanceBaseMovement.cpp NEW_Goto / NEW_MoveToDirection---
# _is_going = m_isGoing(点地走向 Dst);方向键移动 _is_going 保持 false。
@@ -100,6 +103,34 @@ func set_input_surfaces(cursor: Node, ui: Node = null, ground: Node = null,
cancel_fishing_input = cancel_fishing_cb
cancel_fishing_ground = ground_cancel_fishing_cb
# MobileUiRoot feeds a normalized left-thumb axis directly. Keeping it on the
# controller (instead of synthesizing keyboard events) preserves the existing
# camera-relative movement, collision and animation path for both platforms.
func set_mobile_axis(axis: Vector2) -> void:
_mobile_axis = axis.limit_length(1.0)
func clear_mobile_input() -> void:
_mobile_axis = Vector2.ZERO
_mobile_active = false
_mobile_ui_touches.clear()
if _touch_count == 0:
_multi_gesture = false
## MobileInputOverlay calls this before/after a control consumes its touch.
## World touch handling must know about that finger even though the GUI event
## never reaches _unhandled_input; otherwise a joystick finger plus a world
## finger can look like a single tap-to-move gesture.
func set_mobile_ui_touch(index: int, pressed: bool) -> void:
if pressed:
_mobile_ui_touches[index] = true
if _touch_count > 0:
_multi_gesture = true
_tap_index = -1
else:
_mobile_ui_touches.erase(index)
if _touch_count == 0 and _mobile_ui_touches.is_empty():
_multi_gesture = false
# 程序化下发一个点地目标(脚本化截图 / AI)——等价 NEW_MoveToDestPixelPositionDirection。
func walk_to(world_pos: Vector3) -> void:
_goto(Vector3(world_pos.x, 0.0, world_pos.z))
@@ -136,7 +167,7 @@ func _unhandled_input(e: InputEvent) -> void:
func _on_touch(e: InputEventScreenTouch) -> void:
if e.pressed:
_touch_count += 1
if _touch_count >= 2:
if _touch_count >= 2 or not _mobile_ui_touches.is_empty():
_multi_gesture = true
_tap_index = -1
elif not _multi_gesture:
@@ -151,7 +182,7 @@ func _on_touch(e: InputEventScreenTouch) -> void:
if travel <= _TAP_TRAVEL_MAX and held <= _TAP_TIME_MAX:
_on_click(e.position)
_tap_index = -1
if _touch_count == 0:
if _touch_count == 0 and _mobile_ui_touches.is_empty():
_multi_gesture = false
func _on_click(screen_pos: Vector2) -> void:
@@ -319,8 +350,25 @@ func _process(dt: float) -> void:
_run = force_run or Input.is_key_pressed(RUN_HOLD_KEY)
var wish := Vector3.ZERO
var wasd := _wasd()
var mobile_axis := _mobile_axis
if wasd != Vector2.ZERO:
if mobile_axis != Vector2.ZERO:
# A new thumb gesture has the same fishing-cancel edge as a keyboard move.
if not _mobile_active and cancel_fishing_input.is_valid() \
and bool(cancel_fishing_input.call()):
_mobile_active = true
_last_wasd = Vector2.ZERO
anim_state.emit("wait")
return
_mobile_active = true
_is_going = false
_last_wasd = Vector2.ZERO
var mobile_yaw: float = (camera.heading() if camera and camera.has_method("heading") else 0.0)
var mobile_fwd := Vector3(sin(mobile_yaw), 0, cos(mobile_yaw))
var mobile_right := Vector3(mobile_fwd.z, 0, -mobile_fwd.x)
wish = (mobile_fwd * -mobile_axis.y + mobile_right * mobile_axis.x).normalized()
elif wasd != Vector2.ZERO:
_mobile_active = false
# PythonPlayerInputKeyboard 只在方向键按下时尝试取消钓鱼;记录方向
# 边沿,避免按住键时每帧重复发送 CG_FISHING(0)。
if wasd != _last_wasd and cancel_fishing_input.is_valid() \
@@ -335,12 +383,14 @@ func _process(dt: float) -> void:
var right := Vector3(fwd.z, 0, -fwd.x)
wish = (fwd * -wasd.y + right * wasd.x).normalized()
elif _is_going:
_mobile_active = false
var flat := Vector3(_dst_pos.x - player.position.x, 0, _dst_pos.z - player.position.z)
if flat.length() <= ARRIVE_EPS:
_is_going = false
else:
wish = flat.normalized()
else:
_mobile_active = false
_last_wasd = Vector2.ZERO
var speed := 0.0
+1
View File
@@ -0,0 +1 @@
uid://lqisiv6emkkt
+1
View File
@@ -0,0 +1 @@
uid://s5p8kdn4aywt
+1
View File
@@ -0,0 +1 @@
uid://db06iedi0733r
+26 -2
View File
@@ -5,6 +5,7 @@
extends SceneTree
const PrivateShopUI = preload("res://ui/private_shop_ui.gd")
const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.gd")
class FakeClient extends Node:
signal inventory_changed(window: int, cell: int)
@@ -61,6 +62,8 @@ func _run() -> void:
var pu: Node = PrivateShopUI.new()
get_root().add_child(pu)
pu.setup(fc, ui, fp, assets)
ui.set_mobile_mode(true)
pu.set_mobile_mode(true)
pu.open()
_ck(pu.is_open(), "builder window opened")
if not pu.is_open():
@@ -70,6 +73,7 @@ func _run() -> void:
_ck(pu._inv_list.get_child_count() == 3, "3 inventory candidates")
_ck((pu._inv_list.get_child(0) as Button).has_method("_get_drag_data"),
"inventory candidate supports drag data")
_ck(pu._win["root"].size.x >= 674.0, "mobile builder keeps candidates inside hosted root")
_ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 300, "count": 1,
"anti_flags": (1 << 13)}, 2), "ANTIFLAG_GIVE item cannot enter private shop")
_ck(not pu._drop_mouse_item({"window": 1, "cell": 99, "vnum": 301, "count": 1,
@@ -80,10 +84,30 @@ func _run() -> void:
_ck(not _has_call(fc, "open_shop", func(_c): return true), "empty stock → no send")
# 拿起第 1 件(cell 4)→ 落到格 3,价 250000
(pu._inv_list.get_child(0) as Button).pressed.emit()
var first_candidate := pu._inv_list.get_child(0) as Button
MobileWindowHost.wire_buttons(pu._win["root"])
var candidate_down := InputEventScreenTouch.new()
candidate_down.index = 1
candidate_down.pressed = true
candidate_down.position = Vector2(20, 40)
first_candidate.gui_input.emit(candidate_down)
var candidate_up := InputEventScreenTouch.new()
candidate_up.index = 1
candidate_up.pressed = false
candidate_up.position = candidate_down.position
first_candidate.gui_input.emit(candidate_up)
_ck(pu._picked != null and int(pu._picked["cell"]) == 4, "picked inv cell 4")
pu._on_slot_clicked(3)
var mobile_slot_touch := InputEventScreenTouch.new()
mobile_slot_touch.index = 2
mobile_slot_touch.pressed = true
(pu._cells[3] as Panel).gui_input.emit(mobile_slot_touch)
_ck(is_instance_valid(pu._price_dialog), "price dialog opens on empty slot")
_ck(pu._price_dialog.get_node_or_null("PriceInput") is LineEdit,
"mobile price dialog uses a numeric LineEdit")
ui.close_top()
_ck(pu._price_dialog == null, "mobile back closes price dialog and clears owner state")
_ck(pu._picked != null, "closing price dialog keeps selected item for retry")
pu._ask_price(3)
pu._place(3, 250000)
_ck(pu._stock.has(3) and int(pu._stock[3]["price"]) == 250000, "stock[3] placed")
_ck(pu._picked == null, "pick cleared after place")
+8
View File
@@ -22,8 +22,16 @@ gdscript/warnings/untyped_declaration=1
; double-fire tap-to-move). Desktop still uses real mouse events.
pointing/emulate_mouse_from_touch=false
[mt]
ui/profile="auto"
ui/mobile_scale=1.0
ui/show_touch_debug=false
[display]
window/handheld/orientation=0
window/size/viewport_width=1920
window/size/viewport_height=1080
+1
View File
@@ -0,0 +1 @@
uid://skkm2dyg8jmx
+1
View File
@@ -0,0 +1 @@
uid://ci70o6w2yd5cc
+1
View File
@@ -0,0 +1 @@
uid://dokpyjtoa1s48
+1
View File
@@ -0,0 +1 @@
uid://d4gu3tfgt3q5d
+3
View File
@@ -76,12 +76,15 @@ func _run() -> void:
var cu = CubeUI.new()
get_root().add_child(cu)
cu.setup(fc, host, null)
cu.set_mobile_mode(true)
_ck(not cu.is_open(), "cube: hidden initially")
fc.cube = {"open": true, "npc_vnum": 20383, "recipes": [], "results": [
{"vnum": 72723, "count": 1}, {"vnum": 50001, "count": 5}]}
fc.cube_opened.emit(20383)
await process_frame
_ck(cu.is_open(), "cube: opens on cube_opened")
_ck(cu._root.size == Vector2(720, 380) and cu._material_scroll != null,
"cube mobile mode uses landscape two-column layout")
_ck(_has_call(fc, "cube_rlist", func(c): return c[1] == 20383), "cube: open → request result list")
var recipe_btns: Array = cu._list.get_children().filter(func(n): return n is Button)
_ck(recipe_btns.size() == 2, "cube: 2 result rows")
+1
View File
@@ -0,0 +1 @@
uid://c5rhqrba42pk5
+15
View File
@@ -290,6 +290,21 @@ func _run() -> void:
qb._clear(1)
_ck(fc.quickslot_ops.back() == ["del", 1], "quickslot clear -> CG_QUICKSLOT_DEL")
# 移动技能页:横屏大触控行 + 一键放入当前页快捷栏空位,替代 PC 拖放。
sk.close()
sk.set_mobile_mode(true)
sk.set_mobile_quickbar(qb)
sk.open()
_ck(sk.is_open() and sk._win.size == Vector2(760, 340),
"mobile skill window uses landscape base size")
_ck(sk._win.get_node_or_null("SkillScroll") is ScrollContainer,
"mobile skill list is scrollable")
if sk._rows.has(16):
sk._mobile_assign_skill(16)
_ck(qb._state[0].kind == "skill" and qb._state[0].id == 16,
"mobile skill action assigns to first free quickbar slot")
sk.close()
# 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格
var qb2: Node = Quickbar.new()
get_root().add_child(qb2)
+1
View File
@@ -0,0 +1 @@
uid://dk1va37r3o5b0
+33
View File
@@ -117,6 +117,16 @@ func _run() -> void:
_ck((go2._node("block_party_button") as BaseButton).button_pressed, "reload: block_party button reflects bit 1<<1")
_ck(not (go2._node("block_exchange_button") as BaseButton).button_pressed, "reload: block_exchange button off")
go2.close()
ui.set_mobile_mode(true)
go2.set_mobile_mode(true)
go2.open()
_ck(go2.is_open() and go2._mobile_root.size == Vector2(700, 360),
"mobile game-option window uses landscape base size")
_ck(go2._mobile_root.get_node_or_null("MobileGameOptionScroll") is ScrollContainer,
"mobile game-option settings are scrollable")
go2._toggle_block(1 << 0)
_ck(fc.last_say() == "/setblockmode 3", "mobile block toggle keeps server command")
go2.close()
# ---------------- SystemMenuUI ----------------
fc.says.clear()
@@ -154,5 +164,28 @@ func _run() -> void:
(sm._node("cancel_button") as BaseButton).pressed.emit()
_ck(not sm.is_open(), "cancel_button closes menu")
# 移动端使用独立横屏菜单,仍复用同一组设置、帮助和服务器命令入口。
ui.set_mobile_mode(true)
sm.set_mobile_mode(true)
sm.open()
_ck(sm.is_open() and sm._mobile_root.size == Vector2(700, 330),
"mobile system menu uses landscape base size")
for action in ["帮助", "商城", "系统设置", "游戏设置", "选择角色", "登出", "退出游戏", "取消"]:
_ck(sm._mobile_buttons.has(action), "mobile system menu action: " + action)
(sm._mobile_buttons["商城"] as Button).pressed.emit()
_ck(_has_say(fc, "/in_game_mall"), "mobile mall action -> /in_game_mall")
_ck(not sm.is_open(), "mobile action closes menu")
sm.open()
(sm._mobile_buttons["系统设置"] as Button).pressed.emit()
_ck(sys_opt.opened == 2 and not sm.is_open(),
"mobile system option opens feature and closes menu")
sm.open()
(sm._mobile_buttons["游戏设置"] as Button).pressed.emit()
_ck(game_opt.opened == 2 and not sm.is_open(),
"mobile game option opens feature and closes menu")
sm.open()
(sm._mobile_buttons["取消"] as Button).pressed.emit()
_ck(not sm.is_open(), "mobile cancel closes menu")
if FileAccess.file_exists(_cfg_path()):
DirAccess.remove_absolute(_cfg_path())
+10
View File
@@ -104,6 +104,16 @@ func _run() -> void:
so.close()
_ck(not so.is_open(), "system-option window closed")
# 移动端专用横屏设置页:滑杆与单选项不依赖 PC 固定坐标小控件。
ui.set_mobile_mode(true)
so.set_mobile_mode(true)
so.open()
_ck(so.is_open() and so._mobile_root.size == Vector2(680, 340),
"mobile system-option window uses landscape base size")
_ck(so._mobile_root.find_children("*", "HSlider", true, false).size() == 2,
"mobile system-option exposes music and sound sliders")
so.close()
# --- 두 번째 인스턴스가 cfg 를 읽어 즉시 적용 ---
var au2 := FakeAudio.new()
var cam2 := FakeCam.new()
+1
View File
@@ -0,0 +1 @@
uid://csy6qy363o028
+1
View File
@@ -0,0 +1 @@
uid://ckiaafr7cv48r
+1
View File
@@ -0,0 +1 @@
uid://2wi17v5m0v51
+1
View File
@@ -0,0 +1 @@
uid://xn5klnex8db2
+1
View File
@@ -0,0 +1 @@
uid://bmlnjr2tyws0o
+1
View File
@@ -0,0 +1 @@
uid://d3v7hqq4gtouf
+1
View File
@@ -0,0 +1 @@
uid://c4hgw5bk51xyv
+1
View File
@@ -0,0 +1 @@
uid://bumeoghswlc5e
+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

Some files were not shown because too many files have changed in this diff Show More