From b19d5b5dd319a325e575c07dfc09b19cba8c41bb Mon Sep 17 00:00:00 2001 From: shenlei Date: Fri, 4 Sep 2026 19:54:33 +0900 Subject: [PATCH] feat: complete mobile UI implementation --- .planning/sketches/001-mobile-hud/README.md | 56 ++ .planning/sketches/001-mobile-hud/index.html | 664 ++++++++++++++++++ .planning/sketches/MANIFEST.md | 15 + .planning/sketches/themes/default.css | 40 ++ docs/MOBILE-UI-IMPLEMENTATION.md | 656 +++++++++++++++++ project/ambience_test.gd.uid | 1 + project/app_flow.gd | 51 +- project/app_flow_lifecycle_test.gd | 27 + project/app_flow_lifecycle_test.gd.uid | 1 + project/atlas_test.gd | 48 ++ project/audio_miles_test.gd.uid | 1 + project/char_status_ui_test.gd | 16 + project/chat_mobile_ui_test.gd | 51 ++ project/chat_mobile_ui_test.gd.uid | 1 + project/chat_tail.gd.uid | 1 + project/chat_tail_test.gd.uid | 1 + project/complex_item_drop_test.gd.uid | 1 + project/damage_effect.gd.uid | 1 + project/damage_effect_test.gd.uid | 1 + project/dungeon_result_test.gd.uid | 1 + project/dungeon_state.gd.uid | 1 + project/dungeon_state_test.gd.uid | 1 + project/entity_rules.gd.uid | 1 + project/entity_rules_test.gd.uid | 1 + project/environment_test.gd.uid | 1 + project/equip_rules.gd.uid | 1 + project/equip_rules_test.gd.uid | 1 + project/fishing_water_test.gd.uid | 1 + project/fly_object.gd.uid | 1 + project/fly_test.gd.uid | 1 + project/fx/mde.gd.uid | 1 + project/game_camera_test.gd.uid | 1 + project/game_scene.gd | 96 ++- project/gamescene_test.gd | 15 + project/guild_creation_ui_test.gd | 2 + project/guild_mark_tail_test.gd.uid | 1 + project/guild_war_skill_test.gd | 10 + project/help_ui_test.gd | 10 + project/input_key_test.gd.uid | 1 + project/inventory_ui_test.gd | 23 +- project/item_tooltip_test.gd.uid | 1 + project/mobile_gesture_test.gd | 190 +++++ project/mobile_gesture_test.gd.uid | 1 + project/mobile_ui_test.gd | 116 +++ project/mobile_ui_test.gd.uid | 1 + project/mobile_window_host_test.gd | 74 ++ project/mobile_window_host_test.gd.uid | 1 + project/mouse_controller_test.gd.uid | 1 + project/name_color.gd.uid | 1 + project/name_color_test.gd.uid | 1 + project/name_show.gd.uid | 1 + project/name_show_test.gd.uid | 1 + project/name_tail_layout.gd.uid | 1 + project/name_tail_layout_test.gd.uid | 1 + project/p10_test.gd | 20 +- project/p8_test.gd | 22 + project/part_hiding.gd.uid | 1 + project/part_hiding_test.gd.uid | 1 + project/pick_show.gd.uid | 1 + project/pick_show_test.gd.uid | 1 + project/player_controller.gd | 56 +- project/player_move_test.gd.uid | 1 + project/player_skill.gd.uid | 1 + project/player_skill_test.gd.uid | 1 + project/private_shop_ui_test.gd | 28 +- project/project.godot | 8 + project/quest_event_test.gd.uid | 1 + project/remote_player_test.gd.uid | 1 + project/screen_hp_bar.gd.uid | 1 + project/screen_hp_bar_test.gd.uid | 1 + project/shop_cube_mall_test.gd | 3 + project/shop_rules_test.gd.uid | 1 + project/skill_test.gd | 15 + project/skybox_test.gd.uid | 1 + project/system_menu_ui_test.gd | 33 + project/system_option_ui_test.gd | 10 + project/target_board.gd.uid | 1 + project/target_board_test.gd.uid | 1 + project/text_metrics.gd.uid | 1 + project/text_metrics_test.gd.uid | 1 + project/text_tail.gd.uid | 1 + project/text_tail_arrange.gd.uid | 1 + project/text_tail_arrange_test.gd.uid | 1 + project/text_tail_test.gd.uid | 1 + project/ui/atlas_ui.gd | 49 +- project/ui/char_status_ui.gd | 303 ++++++++ project/ui/chat_ui.gd | 111 ++- project/ui/cube_ui.gd | 48 +- project/ui/cursor_manager.gd.uid | 1 + project/ui/dungeon_result_ui.gd.uid | 1 + project/ui/exchange_ui.gd | 83 ++- project/ui/friend_ui.gd | 95 ++- project/ui/game_option_ui.gd | 143 ++++ project/ui/ground_items.gd | 10 + project/ui/guild_ui.gd | 173 +++-- project/ui/help_ui.gd | 75 ++ project/ui/inventory_ui.gd | 291 +++++++- project/ui/item_tooltip.gd.uid | 1 + project/ui/love_ui.gd | 18 +- project/ui/mall_ui.gd | 37 +- project/ui/minimap.gd | 33 + project/ui/mobile/mobile_hud.gd | 342 +++++++++ project/ui/mobile/mobile_hud.gd.uid | 1 + project/ui/mobile/mobile_input_overlay.gd | 222 ++++++ project/ui/mobile/mobile_input_overlay.gd.uid | 1 + project/ui/mobile/mobile_menu_drawer.gd | 154 ++++ project/ui/mobile/mobile_menu_drawer.gd.uid | 1 + project/ui/mobile/mobile_touch_button.gd | 226 ++++++ project/ui/mobile/mobile_touch_button.gd.uid | 1 + project/ui/mobile/mobile_ui_root.gd | 453 ++++++++++++ project/ui/mobile/mobile_ui_root.gd.uid | 1 + project/ui/mobile/mobile_window_host.gd | 163 +++++ project/ui/mobile/mobile_window_host.gd.uid | 1 + project/ui/mobile/virtual_joystick.gd | 86 +++ project/ui/mobile/virtual_joystick.gd.uid | 1 + project/ui/mouse_controller.gd | 26 +- project/ui/mouse_controller.gd.uid | 1 + project/ui/party_ui.gd | 13 + project/ui/private_shop_ui.gd | 86 ++- project/ui/quest_log.gd | 14 +- project/ui/quickbar.gd | 58 +- project/ui/reconnect_ui.gd | 48 +- project/ui/refine_ui.gd | 57 +- project/ui/remote_player_view.gd.uid | 1 + project/ui/safebox_ui.gd | 130 +++- project/ui/shop_ui.gd | 77 +- project/ui/skill_ui.gd | 238 ++++++- project/ui/system_menu_ui.gd | 129 +++- project/ui/system_option_ui.gd | 106 +++ project/ui/ui_manager.gd | 254 ++++++- project/ui/ui_profile.gd | 48 ++ project/ui/ui_profile.gd.uid | 1 + 132 files changed, 6643 insertions(+), 180 deletions(-) create mode 100644 .planning/sketches/001-mobile-hud/README.md create mode 100644 .planning/sketches/001-mobile-hud/index.html create mode 100644 .planning/sketches/MANIFEST.md create mode 100644 .planning/sketches/themes/default.css create mode 100644 docs/MOBILE-UI-IMPLEMENTATION.md create mode 100644 project/ambience_test.gd.uid create mode 100644 project/app_flow_lifecycle_test.gd.uid create mode 100644 project/audio_miles_test.gd.uid create mode 100644 project/chat_mobile_ui_test.gd create mode 100644 project/chat_mobile_ui_test.gd.uid create mode 100644 project/chat_tail.gd.uid create mode 100644 project/chat_tail_test.gd.uid create mode 100644 project/complex_item_drop_test.gd.uid create mode 100644 project/damage_effect.gd.uid create mode 100644 project/damage_effect_test.gd.uid create mode 100644 project/dungeon_result_test.gd.uid create mode 100644 project/dungeon_state.gd.uid create mode 100644 project/dungeon_state_test.gd.uid create mode 100644 project/entity_rules.gd.uid create mode 100644 project/entity_rules_test.gd.uid create mode 100644 project/environment_test.gd.uid create mode 100644 project/equip_rules.gd.uid create mode 100644 project/equip_rules_test.gd.uid create mode 100644 project/fishing_water_test.gd.uid create mode 100644 project/fly_object.gd.uid create mode 100644 project/fly_test.gd.uid create mode 100644 project/fx/mde.gd.uid create mode 100644 project/game_camera_test.gd.uid create mode 100644 project/guild_mark_tail_test.gd.uid create mode 100644 project/input_key_test.gd.uid create mode 100644 project/item_tooltip_test.gd.uid create mode 100644 project/mobile_gesture_test.gd create mode 100644 project/mobile_gesture_test.gd.uid create mode 100644 project/mobile_ui_test.gd create mode 100644 project/mobile_ui_test.gd.uid create mode 100644 project/mobile_window_host_test.gd create mode 100644 project/mobile_window_host_test.gd.uid create mode 100644 project/mouse_controller_test.gd.uid create mode 100644 project/name_color.gd.uid create mode 100644 project/name_color_test.gd.uid create mode 100644 project/name_show.gd.uid create mode 100644 project/name_show_test.gd.uid create mode 100644 project/name_tail_layout.gd.uid create mode 100644 project/name_tail_layout_test.gd.uid create mode 100644 project/part_hiding.gd.uid create mode 100644 project/part_hiding_test.gd.uid create mode 100644 project/pick_show.gd.uid create mode 100644 project/pick_show_test.gd.uid create mode 100644 project/player_move_test.gd.uid create mode 100644 project/player_skill.gd.uid create mode 100644 project/player_skill_test.gd.uid create mode 100644 project/quest_event_test.gd.uid create mode 100644 project/remote_player_test.gd.uid create mode 100644 project/screen_hp_bar.gd.uid create mode 100644 project/screen_hp_bar_test.gd.uid create mode 100644 project/shop_rules_test.gd.uid create mode 100644 project/skybox_test.gd.uid create mode 100644 project/target_board.gd.uid create mode 100644 project/target_board_test.gd.uid create mode 100644 project/text_metrics.gd.uid create mode 100644 project/text_metrics_test.gd.uid create mode 100644 project/text_tail.gd.uid create mode 100644 project/text_tail_arrange.gd.uid create mode 100644 project/text_tail_arrange_test.gd.uid create mode 100644 project/text_tail_test.gd.uid create mode 100644 project/ui/cursor_manager.gd.uid create mode 100644 project/ui/dungeon_result_ui.gd.uid create mode 100644 project/ui/item_tooltip.gd.uid create mode 100644 project/ui/mobile/mobile_hud.gd create mode 100644 project/ui/mobile/mobile_hud.gd.uid create mode 100644 project/ui/mobile/mobile_input_overlay.gd create mode 100644 project/ui/mobile/mobile_input_overlay.gd.uid create mode 100644 project/ui/mobile/mobile_menu_drawer.gd create mode 100644 project/ui/mobile/mobile_menu_drawer.gd.uid create mode 100644 project/ui/mobile/mobile_touch_button.gd create mode 100644 project/ui/mobile/mobile_touch_button.gd.uid create mode 100644 project/ui/mobile/mobile_ui_root.gd create mode 100644 project/ui/mobile/mobile_ui_root.gd.uid create mode 100644 project/ui/mobile/mobile_window_host.gd create mode 100644 project/ui/mobile/mobile_window_host.gd.uid create mode 100644 project/ui/mobile/virtual_joystick.gd create mode 100644 project/ui/mobile/virtual_joystick.gd.uid create mode 100644 project/ui/mouse_controller.gd.uid create mode 100644 project/ui/remote_player_view.gd.uid create mode 100644 project/ui/ui_profile.gd create mode 100644 project/ui/ui_profile.gd.uid diff --git a/.planning/sketches/001-mobile-hud/README.md b/.planning/sketches/001-mobile-hud/README.md new file mode 100644 index 00000000..ac576cd0 --- /dev/null +++ b/.planning/sketches/001-mobile-hud/README.md @@ -0,0 +1,56 @@ +--- +sketch: 001 +name: mobile-hud +question: "暗黑系 ARPG 横屏战斗 HUD 如何兼顾 MMORPG 的完整系统入口?" +winner: "single" +tags: [mobile, landscape, hud, touch, combat, arpg, mmo, dark-glass] +--- + +# Sketch 001: Mobile HUD + +## Design Question + +在不遮挡 Godot 游戏世界的前提下,如何用暗黑系 ARPG 的横屏战斗框架,让移动、普通攻击、技能、物品、拾取/对话、任务、地图、聊天、队伍、公会以及背包等系统保持易达?本轮重点比较圆形战斗控件、深色金属边框、职业资源条与低频功能收纳方式。 + +## How to View + +直接打开: + +`open .planning/sketches/001-mobile-hud/index.html` + +也可以使用文件链接打开 [index.html](/Users/shenlei/Work/mt/mtgodot-poc/.planning/sketches/001-mobile-hud/index.html)。 + +页面无需构建工具。草图以横屏为基准:手机为 844×390,平板和桌面为 16:9。右下角工具栏切换手机/平板/桌面预览和标注模式;首页下方的“全量 UI 移动端交互图”支持按模块筛选,点击节点可查看入口、触控方式、页面形态和返回路径。 + +## Single Direction + +- **暗黑 ARPG 横屏 HUD** — 左上按“名称→头像→HP/MP/资源短条”纵向排列,顶部中央目标条,右上按“背包→小地图→菜单”排列,地图通过点击小地图进入;左下摇杆、右下环形技能盘,底部中央不放功能栏。角色、装备、技能、坐骑和表情只通过左上头像进入;菜单仅保留“社交、成长、活动、商业、系统”五个一级入口。 + +## Covered Functions + +移动摇杆、相机/地图入口、玩家状态、目标、普攻、技能、药水/物品、拾取、对话、钓鱼、副本入口、任务、背包、角色、技能、地图、队伍、公会、好友、聊天、情侣/爱意、商店、商城、仓库、私人商店、交易、兑换、Cube 制作、精炼、龙魂、坐骑、观战、表情、系统、帮助、登出、角色切换、死亡复活、加载和断线重连。 + +## Interaction Maps + +- 进入游戏:登录 → 服务器/频道 → 角色列表 → 加载 → 首页 HUD。 +- 战斗:移动摇杆 → 目标选择 → 普攻/技能 → 掉落/场景交互 → 死亡/复活。 +- 任务:任务栏 → 任务日志 → 小地图/大地图 → 自动寻路 → NPC 对话 → 副本结算。 +- 角色与物品:头像/菜单 → 角色/装备/技能,以及背包 → 物品详情 → 使用/装备/出售 → 仓库。 +- 社交:右上菜单 → 社交 → 聊天/好友/情侣/公会;组队后队员状态固定在左上头像下方,点击队员查看资料或定位。 +- 经济:NPC/玩家 → 商店/商城/交易/私人商店 → Cube 制作 → 精炼/龙魂。 +- 系统状态:右上菜单 → 设置/帮助/角色切换/登出,以及死亡、加载、断线重连等状态流程。 + +## What to Look For + +- 暗黑系圆形战斗盘是否能让右手拇指快速区分普攻、技能和药水。 +- 战斗控件是否遮挡角色、敌人、掉落物或任务目标。 +- 当前任务条是否足够醒目,点击任务条能否直接进入任务面板。 +- 低频功能是否通过菜单一跳到达,同时不让战斗 HUD 变得拥挤。 +- 名称是否位于头像正上方,HP/MP/资源短条是否位于头像下方且不抢占战斗画面。 +- 右上菜单是否位于最外侧,小地图是否紧挨菜单,背包是否替代原地图按钮。 +- 横屏手机布局在平板/桌面预览下是否有足够的延展空间。 +- 技能冷却、按钮按压、抽屉打开、聊天发送和摇杆复位反馈是否足够明确。 + +## Next Decision + +当前方向已确定为单一方案,交互图已补齐全部功能入口。下一步可按交互图逐条映射到 Godot Control 节点,并优先实现首页 HUD、任务、背包、菜单和战斗操作。草图只借鉴暗黑系 ARPG 的布局语言,不复用参考图中的原始素材。 diff --git a/.planning/sketches/001-mobile-hud/index.html b/.planning/sketches/001-mobile-hud/index.html new file mode 100644 index 00000000..b42f2ac0 --- /dev/null +++ b/.planning/sketches/001-mobile-hud/index.html @@ -0,0 +1,664 @@ + + + + + + 移动端 HUD 草图 · 暗黑 ARPG + + + + +
+
Mobile ARPG Control HUD / Sketch 001
+

暗黑 ARPG 风格 · 横屏 HUD 草图

+

按 844×390 横屏手机设计,保持世界画面优先,战斗操作触手可达;底部中央保持留白,低频功能统一收纳到右上角菜单。点击首页控件或下方交互图节点,查看完整的移动端 UI 流程。

+
+ +
+
+

暗黑 ARPG 横屏 HUD

左上按“名称→头像→HP/MP/资源短条”排列;右上按“背包→小地图→菜单”排列,地图通过点击小地图进入,底部中央保持留白。

+
+
+
+
Azure · 暮光骑士Lv.42
+
+
+ + +
附近:小心精英怪!
+ +
+
+ + + + + +
+
技能冷却中
+ +
+
+
+ +
+ +
+
+
+
Interaction map / all screens
+

全量 UI 移动端交互图

+

每张图表示一条完整操作路径。点击任意节点,查看它的入口位置、触控方式、页面形态和返回路径;任务始终从任务栏进入,组队信息固定显示在左上玩家头像下方。

+
+
+ 常驻 HUD + 右上角菜单 + 全屏面板 + 场景弹窗 +
+
+ +
+ + + + + + +
+ +
+
+

进入游戏与角色入口

01 · ENTRY
+
+ + + + + +
+
+ +
+

首页战斗与掉落

02 · COMBAT
+
+ + + + + + +
+
+ +
+

任务、地图与副本

03 · QUEST
+
+ + + + + + +
+
+ +
+

角色、装备与技能

04 · CHARACTER
+
+ + + + + + +
+
+ +
+

背包、物品与仓库

05 · ITEMS
+
+ + + + + +
+
+ +
+

队伍状态、聊天、好友与公会

06 · SOCIAL
+
+ + + + + + + +
+
+ +
+

商店、交易与制作

07 · ECONOMY
+
+ + + + + + +
+
+ +
+

系统设置与异常状态

08 · SYSTEM
+
+ + + + + + +
+
+
+ + +
+ +
+ 横屏预览 + + +
+
+ + + + diff --git a/.planning/sketches/MANIFEST.md b/.planning/sketches/MANIFEST.md new file mode 100644 index 00000000..e2a050b6 --- /dev/null +++ b/.planning/sketches/MANIFEST.md @@ -0,0 +1,15 @@ +# Sketch Manifest + +## Design Direction + +面向移动端 MMORPG 的暗黑系 ARPG 横屏 HUD:让战斗世界保持清晰,把高频操作放在拇指自然覆盖区,底部中央保持留白,把低频系统收纳进菜单和右上入口。整体采用深色玻璃、暖铜金属边框、血红生命条、紫色职业资源与圆形技能盘;任务通过当前任务条直接进入。角色相关功能固定由左上头像进入;组队后队员信息固定显示在该头像下方,队伍不出现在社交菜单。菜单参考暗黑 ARPG 的分组方式,仅保留社交、成长、活动、商业、系统五个一级入口,其余功能最多第二层进入。 + +## Reference Points + +参考用户提供的暗黑系 ARPG 横屏战斗 HUD:左上按名称、头像、HP/MP/资源短条纵向排列,顶部中央目标条,右上按背包、小地图、菜单排列,左下虚拟摇杆、右下环形技能盘;底部中央保持留白。当前草图以项目已有的移动、相机缩放、战斗、掉落物交互、聊天、背包、任务和社交入口为内容基础,并补充进入游戏、任务副本、角色物品、社交经济、系统状态等全量交互图;使用自绘占位符,不复用原始游戏素材。 + +## Sketches + +| # | Name | Design Question | Winner | Tags | +|---|------|-----------------|--------|------| +| 001 | mobile-hud | 暗黑系 ARPG 横屏战斗 HUD 如何兼顾 MMORPG 的完整系统入口? | single | mobile, landscape, hud, touch, combat, arpg, mmo, dark-glass | diff --git a/.planning/sketches/themes/default.css b/.planning/sketches/themes/default.css new file mode 100644 index 00000000..02aaa39f --- /dev/null +++ b/.planning/sketches/themes/default.css @@ -0,0 +1,40 @@ +:root { + --color-bg: #140e0b; + --color-surface: rgba(29, 20, 16, 0.78); + --color-surface-strong: rgba(17, 12, 10, 0.94); + --color-surface-soft: rgba(178, 132, 73, 0.14); + --color-border: rgba(191, 148, 84, 0.34); + --color-border-bright: rgba(238, 199, 126, 0.72); + --color-text: #f5ead7; + --color-text-muted: #b7a58e; + --color-primary: #dfb86e; + --color-primary-hover: #f5d998; + --color-accent: #f0c979; + --color-danger: #c83f39; + --color-success: #73bb7d; + --color-mana: #7957ed; + --color-energy: #b468f4; + --color-glass-highlight: rgba(255, 231, 185, 0.18); + --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-mono: "SFMono-Regular", Consolas, monospace; + --text-xs: 0.7rem; + --text-sm: 0.78rem; + --text-base: 0.92rem; + --text-lg: 1.1rem; + --text-xl: 1.35rem; + --text-2xl: 1.7rem; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-6: 24px; + --space-8: 32px; + --space-12: 48px; + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 24px; + --radius-full: 9999px; + --shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.2); + --shadow-md: 0 12px 28px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.5); +} diff --git a/docs/MOBILE-UI-IMPLEMENTATION.md b/docs/MOBILE-UI-IMPLEMENTATION.md new file mode 100644 index 00000000..b672a831 --- /dev/null +++ b/docs/MOBILE-UI-IMPLEMENTATION.md @@ -0,0 +1,656 @@ +# 移动端 UI 与现有 PC UI 共存实施方案 + +## 1. 目标与结论 + +本方案用于把横屏移动端 UI 草图落地到现有 Godot 客户端,同时保留当前 PC UI、40250 交互语义、网络协议和玩法逻辑。 + +草图入口:[`../.planning/sketches/001-mobile-hud/index.html`](../.planning/sketches/001-mobile-hud/index.html) + +总体结论: + +- 不重写网络、角色、物品、技能、任务、组队和商店数据逻辑。 +- PC 与移动端共用同一套客户端状态和业务控制器,分别使用不同的 HUD 与窗口布局。 +- 首页 HUD、虚拟摇杆、技能盘、菜单路由和移动端输入仲裁需要新增。 +- 高频窗口需要移动端专用布局;低频窗口可先套移动端容器,随后逐步重排。 +- 服务端或 NPC 上下文驱动的窗口不能被菜单强制打开,菜单只能展示入口条件或导航到对应场景。 +- 整体属于 UI 层中等偏大改动,但不是客户端重写。预计可复用约 70% 的现有功能逻辑。 + +## 2. 已确定的移动端设计规则 + +### 2.1 设备与视觉方向 + +- 横屏为唯一移动端布局方向。 +- 设计基准为 844×390,布局必须适配更宽的 Android 屏幕、平板和刘海/挖孔安全区。 +- 风格采用现代手游半透明 HUD、暗色玻璃、暖铜金边框和低饱和背景。 +- 不直接复制其他游戏素材,只参考暗黑 ARPG 的信息层级、触控半径和功能收纳方式。 + +### 2.2 首页 HUD + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 名称 背包 小地图 菜单 │ +│ 头像 │ +│ HP/MP 目标名称与血条 │ +│ 队员 1 │ +│ 队员 2 当前任务追踪 │ +│ │ +│ 游戏世界 │ +│ │ +│ 移动摇杆 技能 / 普攻 │ +└──────────────────────────────────────────────────────────────┘ +``` + +固定规则: + +- 玩家名称位于左上头像上方。 +- HP、MP 和职业资源条位于头像下方,宽度与头像一致。 +- 点击玩家头像进入角色、装备、技能、坐骑、表情和称号页面。 +- 角色功能不出现在右上菜单。 +- 组队后,队员头像、名称、HP 和距离显示在玩家头像下方;未组队时不占空间。 +- 队伍不出现在社交菜单,也不设置独立按钮。 +- 右上角从左到右为背包、小地图、菜单;点击小地图打开大地图。 +- 当前任务条直接打开任务日志,不放入菜单。 +- 底部中央不设置功能栏,只在左下和右下保留必要战斗控件。 + +### 2.3 右上菜单 + +菜单只保留五个一级分类: + +| 一级分类 | 二级内容 | +|---|---| +| 社交 | 聊天、好友、公会、情侣、观战 | +| 成长 | 精炼、龙魂、Cube 制作 | +| 活动 | 副本、钓鱼 | +| 商业 | 商店、商城、交易、私人商店、仓库、兑换 | +| 系统 | 系统设置、游戏设置、帮助、选择角色、登出、退出 | + +页面层级最多两层。二级页必须提供返回上一级和关闭两个操作。 + +## 3. 当前代码基础 + +### 3.1 UI 装配 + +`project/game_scene.gd` 已负责创建并连接以下模块: + +- `Hud` +- `InventoryUI` +- `SkillUI` +- `CharStatusUI` +- `Quickbar` +- `QuestLog` +- `PartyUI` +- `FriendUI` +- `GuildUI` +- `LoveUI` +- `ShopUI` +- `ExchangeUI` +- `SafeboxUI` +- `MallUI` +- `CubeUI` +- `RefineUI` +- `DragonSoulUI`(当前功能开关关闭) +- `Minimap` +- `AtlasUI` +- `SystemMenuUI` + +这些模块已经连接 `M2Client` 信号并负责现有功能行为。移动端不应复制这一套网络和状态处理。 + +### 3.2 现有 HUD 接口可作为兼容协议 + +`project/net_play.gd` 通过以下方法更新 `Hud`: + +```gdscript +set_vitals(hp, max_hp, sp, max_sp) +set_stamina(value, max_value) +set_exp(xp, next_xp) +set_level(level) +set_energy(value, max_value) +set_target(name, hp_pct) +clear_target() +``` + +移动端 HUD 实现相同的方法后,可以直接作为 `net_play.setup(..., hud_node)` 的 HUD 参数,无需修改网络更新流程。 + +### 3.3 PC 固定布局不能直接缩放成移动端 + +当前工程基准分辨率为 1920×1080,`UiManager.screen` 也默认使用该尺寸。`hud.gd` 中状态条锚定左下且 HP 宽度为 240,现有 Python UI 脚本还包含大量固定坐标。 + +因此不能把整个 PC CanvasLayer 等比缩小后直接作为最终移动端 UI,否则会出现: + +- 字体和触控目标过小。 +- 横屏短边空间不足。 +- 多窗口互相覆盖。 +- 标题栏拖动、右键、鼠标悬停等 PC 交互无法在触屏完成。 +- 刘海、圆角和系统手势区遮挡 UI。 + +### 3.4 当前触摸能力 + +现有代码已经具备: + +- 单指轻点地面移动或选择实体。 +- 单指拖动旋转镜头。 +- 双指缩放镜头。 +- `pointing/emulate_mouse_from_touch=false`,不会把触摸再次模拟成鼠标事件。 + +当前已具备: + +- 虚拟摇杆连续移动,以及普攻、药水和 4 个技能触摸按钮。 +- UI 触点按 index 捕获,摇杆/战斗触点不会被世界点地和镜头误判;窗口打开时由 `UiManager` 阻断世界输入。 +- 双指世界手势不会生成点地移动;切后台、失焦和恢复时会释放摇杆/攻击状态。 +- 背包支持点按选择、再次点按使用、点另一格移动;交易、私人商店、仓库和商城支持移动端候选列表或点按操作。 + +代码已覆盖: + +- 技能长按瞄准、拖动调整方向和松手释放;短按仍沿用现有目标/自动射程逻辑。 +- 角色属性/装备、技能、聊天/私聊、系统设置和游戏设置的移动横屏页面。 +- 物品长按详情与触摸拖放、技能拖入快捷栏、快捷栏槽位交换;无法拖放时仍保留点按替代路径。 + +后续主要是 Android/iOS 真机上的 DPI、键盘、手势区和视觉验收。 + +### 3.5 现有队伍 UI 可直接演进 + +`PartyUI` 已经: + +- 监听 `party_changed` 和生命状态更新。 +- 队伍为空时自动隐藏。 +- 显示成员名字、HP、队长和角色状态。 +- 点击成员可选中对应角色。 +- 支持角色分配、踢人、经验分配和组队治疗。 + +移动端只需要增加紧凑布局模式并更换挂载位置,无需重写队伍逻辑。 + +## 4. 目标架构 + +```text +GameScene +├── M2Client / NetPlay / PlayerController / GameCamera +│ └── PC 与移动端共享,不复制协议和玩法状态 +│ +├── UiManager +│ └── 继续管理功能窗口栈、模态遮罩和关闭顺序 +│ +├── DesktopUiRoot +│ ├── 当前 Hud +│ ├── 当前 Quickbar +│ └── 当前 PC 窗口布局 +│ +└── MobileUiRoot + ├── MobileHud + │ ├── PlayerCard + │ ├── PartyHost + │ ├── TargetCard + │ ├── QuestTracker + │ ├── MinimapHost + │ └── TopRightActions + ├── MobileInputOverlay + │ ├── VirtualJoystick + │ ├── AttackButton + │ ├── SkillWheel + │ └── ContextAction + ├── MobileMenuDrawer + ├── MobileWindowHost + └── MobileUiRoot(当前同时承担路由职责) +``` + +职责边界: + +- `MobileHud` 只显示状态并发出点击信号,不直接发送网络包。 +- `MobileInputOverlay` 把触控动作转换为现有控制器调用。 +- `MobileUiRoot` 把头像、背包、小地图、任务和菜单项路由到现有 UI 控制器;后续如路由继续膨胀,再拆出独立 `MobileUiRouter`。 +- `MobileWindowHost` 负责安全区、全屏面板、返回键和过渡动画。 +- 原有 UI 控制器继续处理数据、服务器信号和业务动作。 + +## 5. UI 模式选择 + +新增 `UiProfile`,支持 `AUTO`、`DESKTOP`、`MOBILE` 三种模式。 + +自动选择建议: + +```gdscript +enum Mode { AUTO, DESKTOP, MOBILE } + +static func resolve(mode: Mode) -> Mode: + if mode != Mode.AUTO: + return mode + return Mode.MOBILE if OS.has_feature("mobile") else Mode.DESKTOP +``` + +要求: + +- Android 和 iOS 默认移动 UI。 +- macOS 默认 PC UI。 +- 桌面开发时允许通过项目设置或启动参数强制移动 UI,便于调试。 +- 自动判断只在进入游戏时执行,切换 UI 模式需要重建 UI 根节点,避免运行中残留窗口。 + +建议新增项目配置: + +```text +mt/ui/profile = auto | desktop | mobile +mt/ui/mobile_scale = 1.0 +mt/ui/show_touch_debug = false +``` + +## 6. 建议文件结构 + +```text +project/ui/ +├── mobile/ +│ ├── mobile_ui_root.gd +│ ├── mobile_hud.gd +│ ├── mobile_input_overlay.gd +│ ├── mobile_menu_drawer.gd +│ ├── mobile_ui_router.gd +│ ├── mobile_window_host.gd +│ └── components/ +│ ├── player_card.gd +│ ├── party_member_strip.gd +│ ├── quest_tracker.gd +│ ├── virtual_joystick.gd +│ ├── skill_button.gd +│ └── item_touch_slot.gd +├── desktop/ +│ └── 可后续把当前 HUD 迁入,首期不要求移动文件 +└── ui_profile.gd +``` + +首期不要搬动所有现有文件,以减少路径变化和回归风险。 + +## 7. 现有功能与移动端入口映射 + +### 7.1 首页直达入口 + +| 移动端入口 | 现有目标 | 接入方式 | 改动等级 | +|---|---|---|---| +| 玩家头像 | `CharStatusUI`、`SkillUI` | 头像打开角色容器;容器内调用现有角色/技能状态 | 中 | +| 背包 | `InventoryUI.toggle()` | 右上按钮经路由器调用 | 低 | +| 小地图 | `AtlasUI.toggle()` | 小地图自身保持显示,点击打开大地图 | 低 | +| 当前任务 | `QuestLog.toggle()` | 任务条点击调用;不进入菜单 | 低 | +| 队员条 | `PartyUI` | 增加 compact 模式,挂载到玩家头像下 | 中低 | +| 普攻 | `NetPlay.set_attack_key()` | 按下设为 true,抬起设为 false | 中 | +| 技能 | `Quickbar.activate(slot)` | 技能按钮使用现有槽位和冷却逻辑 | 中 | +| 拾取/交互 | `GroundItems`、`NetPlay` | 使用现有拾取、实体选择和场景交互路径 | 中 | + +### 7.2 右上菜单路由 + +| 分类 | 功能 | 当前可否直接打开 | 移动端行为 | +|---|---|---|---| +| 社交 | 聊天 | 可以 | 调用现有聊天日志入口 | +| 社交 | 好友 | 可以 | 调用 `FriendUI.toggle()` | +| 社交 | 公会 | 可以 | 调用 `GuildUI.toggle()` | +| 社交 | 情侣 | 状态驱动 | 展示情侣状态;无关系时显示空状态 | +| 社交 | 观战 | 场景状态驱动 | 展示观战说明或当前观战状态 | +| 成长 | 精炼 | 服务器询问驱动 | 显示“前往精炼 NPC”,收到 `refine_ask` 后打开窗口 | +| 成长 | 龙魂 | 当前禁用 | 在 `ENABLE_DRAGON_SOUL` 开启前隐藏或显示未开放 | +| 成长 | Cube 制作 | NPC/服务器驱动 | 导航到制作 NPC;收到 `cube_opened` 后打开 | +| 活动 | 副本 | 任务/场景驱动 | 展示目标与导航,不绕过服务端进入条件 | +| 活动 | 钓鱼 | 场景驱动 | 展示说明;到达钓点后由情境按钮开始 | +| 商业 | NPC 商店 | NPC 驱动 | 导航或提示靠近 NPC,不能本地强制打开 | +| 商业 | 商城 | 服务器状态驱动 | 有商城会话时显示内容,否则显示不可用状态 | +| 商业 | 玩家交易 | 玩家交互驱动 | 选择附近玩家后发起交易 | +| 商业 | 私人商店 | 玩家/摊位驱动 | 点击摊位浏览;自己的商店进入管理模式 | +| 商业 | 仓库 | NPC/服务器驱动 | 导航到仓库 NPC,服务器打开后显示 | +| 商业 | 兑换 | NPC/任务上下文驱动 | 显示导航,不伪造兑换上下文 | +| 系统 | 设置/帮助 | 可以 | 使用移动端全屏设置页或现有控制器适配页 | +| 系统 | 选择角色/登出/退出 | 可以 | 二次确认后调用现有系统操作 | + +菜单只是功能入口,不改变原服务端授权条件。 + +## 8. HUD 实施细节 + +### 8.1 `MobileHud` 兼容接口 + +`MobileHud` 实现与当前 `Hud` 相同的状态更新方法,并额外提供以下信号: + +```gdscript +signal avatar_pressed +signal inventory_pressed +signal minimap_pressed +signal menu_pressed +signal quest_pressed(quest_id: int) +signal party_member_pressed(vid: int) +signal context_action_pressed(action: String) +``` + +`GameScene` 根据 UI 模式创建 `Hud` 或 `MobileHud`,并把最终对象继续传给 `NetPlay`。 + +### 8.2 避免重复 HUD + +当前 `hud.gd` 自己创建状态条、小地图占位、快捷栏和背包占位,同时 `GameScene` 还创建正式 `Quickbar`、`InventoryUI` 和 `Minimap`。 + +移动模式必须明确只有一个拥有者: + +- `MobileHud` 拥有首页状态、目标、任务、顶部按钮和触控布局。 +- `Quickbar` 继续拥有技能槽数据、冷却和激活逻辑,但其 PC 可视节点在移动模式隐藏。 +- `InventoryUI` 继续拥有背包数据和动作,但其 PC 入口隐藏。 +- `Minimap` 继续提供地图数据/绘制能力,移动模式把可视根节点挂入 `MobileHud.MinimapHost` 或增加 compact 布局。 + +不能同时显示 PC HUD 和移动 HUD。 + +### 8.3 安全区与缩放 + +- 根布局使用 Godot anchors,不使用整页固定坐标。 +- 使用 `DisplayServer.get_display_safe_area()` 获取安全区。 +- 左上玩家区和右上按钮组至少保留安全区边距。 +- 基准触控目标不小于 48dp;普攻主按钮建议 80–96dp。 +- HUD 缩放按短边计算,并设置最小/最大范围,避免平板按钮过大。 +- 字体单独分级,不跟随 PC 窗口做纯比例缩放。 + +## 9. 移动输入方案 + +### 9.1 虚拟摇杆 + +在 `PlayerController` 中增加移动轴输入,不模拟键盘事件: + +```gdscript +var _mobile_axis := Vector2.ZERO + +func set_mobile_axis(axis: Vector2) -> void: + _mobile_axis = axis.limit_length(1.0) +``` + +移动优先级建议: + +```text +虚拟摇杆 > PC 键盘 > 点地移动 > 无移动 +``` + +摇杆输入不为零时: + +- 取消当前点地目标 `_is_going`。 +- 沿用现有相机相对方向、速度、阻挡、Actor 碰撞和动画逻辑。 +- 摇杆松开后 `_mobile_axis` 清零。 +- 受击、锁定、移动技能等现有移动门继续生效。 + +### 9.2 普攻与技能 + +- 普攻按钮按下调用 `NetPlay.set_attack_key(true)`,抬起或触摸取消调用 `false`。 +- 技能按钮点击调用 `Quickbar.activate(slot)`。 +- 技能长按进入瞄准态,拖动调整方向,松手调用 `Quickbar.activate_aimed()`;短按仍调用 `Quickbar.activate()`。 +- 方向计算、技能冷却、不可使用原因和目标选择继续由现有 `Quickbar`/`NetPlay` 处理。 +- 触摸取消必须清理攻击按下状态,避免切后台后持续攻击。 + +### 9.3 触摸仲裁 + +当前 `PlayerController` 和 `GameCamera` 都接收 `InputEventScreenTouch/Drag`。新增移动控件后必须防止同一个触点同时操作 UI 和世界。 + +建议规则: + +1. `MobileInputOverlay` 在 `_gui_input` 中记录被 UI 捕获的 touch index。 +2. 被捕获的 index 不传给玩家点地和镜头控制。 +3. 摇杆、技能、菜单、窗口区域调用 `get_viewport().set_input_as_handled()`。 +4. 未被捕获的单指仍用于点地/选中或镜头拖动。 +5. 双指缩放只统计世界区域中的触点。 +6. 模态窗口打开时阻止所有世界输入。 +7. 应用暂停、失焦、死亡和过场时强制释放全部触点状态。 + +建议把捕获状态集中到一个 `TouchRouter`,不要让每个按钮各自维护全局触点。 + +## 10. 功能窗口适配策略 + +### 10.1 三种适配级别 + +#### A. 直接复用控制器并更换入口 + +适用:背包开关、大地图、任务日志、好友、公会、系统菜单。 + +首期由 `MobileUiRoot` 调用现有 `toggle/open/close` 方法。 + +#### B. 同一控制器增加移动布局 + +适用:队伍、快捷栏、小地图、聊天、系统设置。 + +保留数据和信号,给 `_build()` 增加 `layout_mode`,分别构建 desktop/mobile 视图。 + +#### C. 拆分逻辑和视图 + +适用:背包、角色、技能、商店、交易、仓库、Cube 等固定坐标和拖放较重的窗口。 + +逐步拆成: + +```text +FeatureController:数据、信号、网络操作、校验 +DesktopFeatureView:现有 PC 布局 +MobileFeatureView:横屏全屏布局 +``` + +首期允许保留现有控制器,并只在真正重做对应页面时拆分,避免一次性重构全部 UI。 + +### 10.2 PC 专属交互的移动替代 + +| PC 交互 | 移动端替代 | +|---|---| +| 鼠标悬停 tooltip | 短按选中、长按详情 | +| 右键使用/出售 | 选中后显示主操作按钮 | +| 标题栏拖动窗口 | 固定全屏或右侧面板 | +| 键盘 I/K/C/N/M | 首页入口或五类菜单 | +| 数字键/F1–F4 技能 | 右下技能盘 | +| 鼠标拖动物品 | 长按进入拖动,松手落位 | +| ESC 关闭 | 系统返回、面板关闭按钮、遮罩点击 | + +## 11. `GameScene` 接入方案 + +建议把 UI 创建拆成三个阶段: + +```gdscript +func _build_shared_feature_controllers() -> void: + # 创建背包、技能、任务、社交、商店等共享控制器 + +func _build_desktop_presentation() -> void: + # 创建当前 Hud、PC Quickbar、PC 窗口入口 + +func _build_mobile_presentation() -> void: + # 创建 MobileUiRoot、MobileHud、触控层和移动菜单 +``` + +接入顺序: + +1. 创建 `UiManager` 和共享功能控制器。 +2. 检测 `UiProfile`。 +3. 创建 Desktop 或 Mobile 表现层。 +4. 把当前 HUD 实例传给 `NetPlay`。 +5. 将 `Quickbar`、`PlayerController`、`GameCamera`、`GroundItems` 注入 `MobileInputOverlay`。 +6. 将现有功能控制器注入 `MobileUiRoot` 的路由绑定。 +7. 地图切换或世界重载时,只重载世界相关 HUD 数据,不重建所有功能控制器。 + +## 12. 开发阶段 + +### 阶段 M1:双 UI 骨架(已完成,待真机验收) + +目标:PC UI 完全不变,桌面可强制预览移动 UI。 + +- 增加 `UiProfile`。 +- 增加 `MobileUiRoot`、安全区和横屏约束。 +- `GameScene` 能在 Desktop/Mobile 表现层之间选择。 +- 移动 HUD 使用假数据完成布局,但不重复显示 PC HUD。 + +验收: + +- macOS 默认仍显示现有 PC UI。 +- 强制 mobile 时只显示移动 HUD。 +- Android/iOS 自动选择移动 UI。 + +### 阶段 M2:首页状态与入口(已完成,待真机验收) + +- 接入玩家名称、HP、MP、资源、经验、等级和状态效果。 +- 接入目标名称与目标 HP。 +- 接入背包、小地图、头像、任务条和五类菜单。 +- 接入左上队伍紧凑条。 +- 接入死亡、重连、副本目标等覆盖状态。 + +验收:所有首页入口打开正确功能,返回后恢复场景输入。 + +### 阶段 M3:移动与战斗触控(代码完成,待真机验收) + +- 新增虚拟摇杆连续移动。 +- 新增普攻、技能、药水和情境交互按钮。 +- 处理触摸 index 捕获、取消、切后台和模态窗口。 +- 接入技能冷却、不可使用原因和目标选择反馈。 + +验收:移动、镜头、攻击、技能可同时使用且不串触摸。 + +### 阶段 M4:高频功能页(部分完成) + +- 角色/装备/技能全屏页:角色属性/装备、技能分类和技能升级已有横屏专用布局;角色表情也提供移动触控页。 +- 背包和物品详情:背包点按操作、长按详情、数字键盘数量输入、跨窗口长按拖放已完成;仍需真机确认系统手势区和键盘共存体验。 +- 任务日志和大地图:已有移动托管;大地图支持单指拖动并在双指时冻结单指拖动。 +- 聊天与好友:聊天/私聊已有横屏输入页、触控按钮和弹窗返回处理,真机键盘体验待验收。 + +验收:不依赖右键、鼠标悬停和键盘即可完成完整操作。 + +### 阶段 M5:商业、制作与系统(部分完成) + +- 商店、商城、交易、私人商店、仓库和兑换:移动端入口/候选列表/点按操作已覆盖;服务端上下文仍需联机验收。 +- Cube、精炼、龙魂入口状态:Cube 配方/材料/制作和精炼按钮已有横屏触控布局,龙魂仍按功能开关控制。 +- 系统设置、游戏设置、帮助、角色切换、登出和退出:系统设置/游戏设置/帮助已有横屏专用布局;系统菜单增加移动端专用横屏入口,好友、公会创建、公会邀请、商城/仓库密码、商店确认等确认弹窗已扩大触控尺寸并接入返回处理。 +- 确保所有上下文窗口只在服务端允许时打开。 + +### 阶段 M6:真机验收与视觉统一(未完成) + +- Android 和 iOS 安全区。 +- 不同屏幕比例、分辨率和 DPI。 +- 多指触控、后台恢复和断线重连(代码已接入,仍需真机/联机验收)。 +- GPU/CPU/内存与触控延迟。 +- 统一暗色玻璃、铜金边框、字号、按钮和动画。 + +## 13. 测试方案 + +### 13.1 自动化测试(已实现) + +以下移动端专项测试已经加入项目,并与现有 PC/业务测试一起运行: + +- `mobile_ui_test.gd`:模式解析、横屏保护、安全区、HUD 状态、菜单分类和 UI/世界触点仲裁。 +- `mobile_window_host_test.gd`:返回键、模态阻断和窗口栈。 +- `mobile_gesture_test.gd`:长按/拖动触摸状态、技能瞄准、技能拖入快捷栏、快捷栏交换和物品触摸拖放。 +- `chat_mobile_ui_test.gd`:横屏聊天日志、频道切换、私聊目标和数字键盘友好的输入页。 +- `gamescene_test.gd`:PC/移动 UI 装配互斥和移动根节点接线。 +- `char_status_ui_test.gd`、`inventory_ui_test.gd`:角色页、背包详情、数量输入和触摸操作。 +- `p8_test.gd`:交易候选列表、仓库取出和移动端仓库存入候选列表。 +- `shop_cube_mall_test.gd`:Cube 配方、材料添加/删除、制作按钮和移动端横屏布局。 +- `guild_creation_ui_test.gd`、`guild_war_skill_test.gd`、`help_ui_test.gd`:公会、帮助和确认弹窗的移动布局。 +- `system_menu_ui_test.gd`、`system_option_ui_test.gd`、`private_shop_ui_test.gd`:系统入口、设置和商业窗口的移动交互。 + +完整测试集当前共 78 个 `*_test.gd`,现有 PC UI、业务、网络、战斗和移动端测试均通过。推荐命令: + +```bash +for test in $(rg --files project -g '*test.gd' | sort); do + /opt/homebrew/bin/godot --headless \ + --rendering-method gl_compatibility \ + --rendering-driver opengl3 \ + --audio-driver Dummy \ + --log-file "/tmp/mtgodot-${test##*/}.log" \ + --path project --script "${test#project/}" || exit 1 +done +``` + +### 13.2 桌面模拟验收 + +- 844×390、16:9 平板、超宽屏三档。 +- 鼠标模拟触控验证所有移动入口。 +- 强制 mobile profile 后走完登录、角色选择和游戏内 UI。 +- 同一进程切换地图、死亡复活和断线重连。 + +### 13.3 Android/iOS 真机验收 + +- 刘海和系统手势区不遮挡按钮。 +- 左手持续移动时,右手可选目标、普攻和连续释放技能。 +- 两指缩放不会触发点地或技能。 +- 拖动物品时不会移动角色或旋转镜头。 +- 来电/切后台后,摇杆和普攻状态正确释放。 +- 队伍成员变化、HP 和距离能实时更新。 +- 商店、仓库、制作等页面不会绕过服务器上下文。 + +## 14. 完成标准 + +移动端 UI 可认为完成,必须同时满足: + +1. PC UI 默认行为和快捷键无回归。 +2. Android/iOS 自动进入移动 UI,桌面可强制预览。 +3. 首页严格符合已确认草图布局。 +4. 角色只从玩家头像进入。 +5. 队伍只在头像下方显示,不在社交菜单出现。 +6. 任务、小地图和背包保持首页直达。 +7. 右上菜单只有社交、成长、活动、商业、系统五类。 +8. 不使用键盘、鼠标右键或悬停,也能完成所有移动端操作。 +9. 所有功能继续使用现有服务器授权和网络包流程。 +10. 自动化测试、桌面模拟和 Android/iOS 真机测试均通过。 + +## 15. 改动规模与主要风险 + +### 15.1 改动规模 + +| 层级 | 改动规模 | 说明 | +|---|---|---| +| 网络协议与服务端 | 小/无 | 继续使用现有 `M2Client` 与 40250 流程 | +| 游戏状态与战斗逻辑 | 小 | 增加移动输入入口,不重写校验和攻击流程 | +| 首页 HUD | 大 | 新建移动端表现层 | +| 输入系统 | 中到大 | 摇杆、技能盘、多指仲裁是核心新增 | +| 高频功能页 | 中到大 | 背包、角色、技能、任务需重排 | +| 低频窗口 | 中 | 可分阶段适配 | +| PC UI | 小 | 原样保留,只增加模式分流 | + +### 15.2 主要风险 + +1. **触摸穿透**:技能或摇杆触摸同时触发地面移动/镜头。 + - 对策:集中式 `TouchRouter`,按 touch index 捕获。 +2. **重复 HUD**:旧 HUD、Quickbar、Minimap 与新 HUD 同时显示。 + - 对策:明确 Desktop/Mobile 表现层互斥创建。 +3. **固定坐标窗口过小**:PC Python UI 在移动端不可操作。 + - 对策:高频页优先重排,低频页先用安全区容器过渡。 +4. **菜单绕过上下文**:直接打开商店、仓库、精炼等造成无效状态。 + - 对策:`MobileUiRoot` 区分“可直接打开”和“需要 NPC/服务器状态”。 +5. **PC 回归**:共享控制器重构影响键盘和鼠标行为。 + - 对策:首期新增并行表现层,拆分控制器时补双端测试。 +6. **横屏安全区差异**:按钮被刘海或系统边缘手势遮挡。 + - 对策:运行时读取安全区并保留可配置边距。 + +## 16. 推荐首个实现切片 + +首个可真机验证的垂直切片已经完成,当前作为移动端联调基线: + +```text +UI 模式选择 +→ 移动 HUD +→ 头像/背包/地图/任务/菜单入口 +→ 左上队伍条 +→ 虚拟摇杆 +→ 普攻 + 4 个技能 +→ Android 真机战斗验证 +``` + +该切片已让客户端具备可用的移动端首页和核心战斗能力;角色、背包、技能等全屏页也已按使用频率完成移动布局,剩余工作集中在真机/联机验收和视觉微调。 + +## 17. 当前实现状态 + +截至 2026-09-04,代码已完成首个垂直切片,并继续补齐了部分商业/制作页面: + +- `project/ui/ui_profile.gd`:`AUTO/DESKTOP/MOBILE` 模式选择,支持移动设备自动识别、`mt/ui/profile` 配置和 `--mobile-ui` / `--desktop-ui` 预览参数。 +- `project/ui/mobile/mobile_ui_root.gd`:移动端 CanvasLayer 根节点,按 844×390 横屏设计基准自适应缩放并处理功能路由。 +- `project/ui/mobile/mobile_hud.gd`:名称、头像、同宽 HP/MP、目标、队伍紧凑条、任务入口、背包/小地图/菜单入口。 +- `project/ui/mobile/mobile_input_overlay.gd`、`virtual_joystick.gd`、`mobile_touch_button.gd`:虚拟摇杆、普攻、药水、4 个技能触控按钮、触摸取消和失焦释放。 +- `project/ui/mobile/mobile_input_overlay.gd`、`project/player_controller.gd`:集中记录移动 UI 触点,避免“摇杆 + 世界触点”触发点地移动。 +- `project/ui/mobile/mobile_menu_drawer.gd`:社交、成长、活动、商业、系统五类两级菜单。 +- `project/player_controller.gd`:新增移动轴输入,沿用现有相机相对移动、阻挡、动画和状态门。 +- `project/ui/char_status_ui.gd`:角色属性/装备和表情移动横屏页面,属性点仍走原 `/stat` 命令。 +- `project/ui/skill_ui.gd`、`project/ui/quickbar.gd`:技能移动横屏列表、升级和一键放入快捷栏空位。 +- `project/ui/inventory_ui.gd`:物品长按详情、使用/选择操作和移动端数字键盘数量输入。 +- `project/ui/chat_ui.gd`:横屏聊天日志、频道切换和私聊输入页。 +- `project/ui/system_option_ui.gd`、`project/ui/game_option_ui.gd`:横屏系统/游戏设置页,保留原配置和服务器命令。 +- `project/ui/help_ui.gd`、`project/ui/system_menu_ui.gd`:横屏帮助页和移动模式下的帮助路由。 +- `project/ui/minimap.gd`:复用现有小地图绘制和数据,移动模式挂载到右上 HUD;点击地图仍打开大地图。 +- `project/ui/party_ui.gd`、`quickbar.gd`、`quest_log.gd`:移动模式隐藏 PC 表现层,保留原数据和业务控制器。 +- `project/game_scene.gd`:PC/移动表现层互斥装配,移动端控制器注入统一路由;PC HUD 默认路径保持不变。 +- `project/ui/safebox_ui.gd`:移动端背包候选列表,点按存入、点按取出和重复请求保护。 +- `project/ui/cube_ui.gd`:移动端横屏双列滚动布局,配方、材料添加/删除和制作按钮均可触摸。 +- `project/ui/refine_ui.gd`:移动端横屏精炼信息与操作布局。 +- `project/ui/guild_ui.gd`:移动端横屏公会四页布局、成员/技能/公会战/公告的触控按钮和创建公会弹窗。 +- `project/ui/system_menu_ui.gd`:保留桌面原始系统菜单,移动端使用横屏专用系统菜单,入口和服务器命令保持一致。 +- `project/ui/friend_ui.gd`、`project/game_scene.gd`、`project/ui/shop_ui.gd`、`project/ui/mall_ui.gd`、`project/ui/safebox_ui.gd`:确认弹窗移动触控尺寸与 Android 返回优先取消处理。 +- `project/ui/ui_manager.gd`、`project/ui/mobile/mobile_window_host.gd`:动态 Button 触摸桥接、原生确认弹窗登记、模态阻断、返回顺序和安全区变化重排。 +- `project/ui/reconnect_ui.gd`、`project/app_flow.gd`:游戏内断线保留 `GameScene`,显示移动重连遮罩,自动重新进入上次角色。 +- `project/project.godot`:增加移动 UI 配置项和横屏方向设置。 + +当前验证结果: + +- 全部 78 个 `*_test.gd` 通过,包括 `app_flow_lifecycle_test.gd`、`p10_test.gd`、`mobile_ui_test.gd`、`mobile_window_host_test.gd` 和 `mobile_gesture_test.gd`;Godot 脚本解析检查通过。 + +尚未完成的部分包括:Android/iOS 真机安全区、不同屏幕比例、多指、后台恢复、性能与触控延迟验收,以及商业页面在真实服务器上下文下的联机验收。断线重连的代码路径已经完成:游戏中断线会保留当前 `GameScene`、显示遮罩并自动重新进入上次角色,但仍需要在真机和真实服务器上验证网络切换、后台恢复及重连失败边界。低频页面仍需要在真机上做字号、键盘和安全区的视觉微调;当前不能据此视为最终移动端交付完成。 diff --git a/project/ambience_test.gd.uid b/project/ambience_test.gd.uid new file mode 100644 index 00000000..486f7efd --- /dev/null +++ b/project/ambience_test.gd.uid @@ -0,0 +1 @@ +uid://p7skimuw8kud diff --git a/project/app_flow.gd b/project/app_flow.gd index 8eb8ca7a..2bf105d9 100644 --- a/project/app_flow.gd +++ b/project/app_flow.gd @@ -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) diff --git a/project/app_flow_lifecycle_test.gd b/project/app_flow_lifecycle_test.gd index 1b9a6983..1cb6310c 100644 --- a/project/app_flow_lifecycle_test.gd +++ b/project/app_flow_lifecycle_test.gd @@ -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: diff --git a/project/app_flow_lifecycle_test.gd.uid b/project/app_flow_lifecycle_test.gd.uid new file mode 100644 index 00000000..e870e329 --- /dev/null +++ b/project/app_flow_lifecycle_test.gd.uid @@ -0,0 +1 @@ +uid://dn635j1v44kp5 diff --git a/project/atlas_test.gd b/project/atlas_test.gd index 0113f886..fb71f066 100644 --- a/project/atlas_test.gd +++ b/project/atlas_test.gd @@ -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 diff --git a/project/audio_miles_test.gd.uid b/project/audio_miles_test.gd.uid new file mode 100644 index 00000000..b31ca114 --- /dev/null +++ b/project/audio_miles_test.gd.uid @@ -0,0 +1 @@ +uid://ctbdjmg33pe67 diff --git a/project/char_status_ui_test.gd b/project/char_status_ui_test.gd index 38eee5d9..79f430e4 100644 --- a/project/char_status_ui_test.gd +++ b/project/char_status_ui_test.gd @@ -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") diff --git a/project/chat_mobile_ui_test.gd b/project/chat_mobile_ui_test.gd new file mode 100644 index 00000000..405c5c99 --- /dev/null +++ b/project/chat_mobile_ui_test.gd @@ -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() diff --git a/project/chat_mobile_ui_test.gd.uid b/project/chat_mobile_ui_test.gd.uid new file mode 100644 index 00000000..daf41aa9 --- /dev/null +++ b/project/chat_mobile_ui_test.gd.uid @@ -0,0 +1 @@ +uid://bhyev28dml0wh diff --git a/project/chat_tail.gd.uid b/project/chat_tail.gd.uid new file mode 100644 index 00000000..a754d48d --- /dev/null +++ b/project/chat_tail.gd.uid @@ -0,0 +1 @@ +uid://bilb10438dl5i diff --git a/project/chat_tail_test.gd.uid b/project/chat_tail_test.gd.uid new file mode 100644 index 00000000..7a599a8d --- /dev/null +++ b/project/chat_tail_test.gd.uid @@ -0,0 +1 @@ +uid://dh4ucte7pxc3u diff --git a/project/complex_item_drop_test.gd.uid b/project/complex_item_drop_test.gd.uid new file mode 100644 index 00000000..40259550 --- /dev/null +++ b/project/complex_item_drop_test.gd.uid @@ -0,0 +1 @@ +uid://x2vuateiv3w1 diff --git a/project/damage_effect.gd.uid b/project/damage_effect.gd.uid new file mode 100644 index 00000000..8582a3f7 --- /dev/null +++ b/project/damage_effect.gd.uid @@ -0,0 +1 @@ +uid://dlpn6gv5jrh14 diff --git a/project/damage_effect_test.gd.uid b/project/damage_effect_test.gd.uid new file mode 100644 index 00000000..06e7730e --- /dev/null +++ b/project/damage_effect_test.gd.uid @@ -0,0 +1 @@ +uid://c77cvj7gtqck4 diff --git a/project/dungeon_result_test.gd.uid b/project/dungeon_result_test.gd.uid new file mode 100644 index 00000000..d2c1c45c --- /dev/null +++ b/project/dungeon_result_test.gd.uid @@ -0,0 +1 @@ +uid://icsacgdld3gb diff --git a/project/dungeon_state.gd.uid b/project/dungeon_state.gd.uid new file mode 100644 index 00000000..cfd90350 --- /dev/null +++ b/project/dungeon_state.gd.uid @@ -0,0 +1 @@ +uid://da5ixd8n62gyh diff --git a/project/dungeon_state_test.gd.uid b/project/dungeon_state_test.gd.uid new file mode 100644 index 00000000..2016e7da --- /dev/null +++ b/project/dungeon_state_test.gd.uid @@ -0,0 +1 @@ +uid://exadl2wqkpmh diff --git a/project/entity_rules.gd.uid b/project/entity_rules.gd.uid new file mode 100644 index 00000000..b6f2e6dc --- /dev/null +++ b/project/entity_rules.gd.uid @@ -0,0 +1 @@ +uid://7g66aavury6r diff --git a/project/entity_rules_test.gd.uid b/project/entity_rules_test.gd.uid new file mode 100644 index 00000000..bd6fc790 --- /dev/null +++ b/project/entity_rules_test.gd.uid @@ -0,0 +1 @@ +uid://b8hnrnm5whxx diff --git a/project/environment_test.gd.uid b/project/environment_test.gd.uid new file mode 100644 index 00000000..fcb6a534 --- /dev/null +++ b/project/environment_test.gd.uid @@ -0,0 +1 @@ +uid://crv0udnsb76ik diff --git a/project/equip_rules.gd.uid b/project/equip_rules.gd.uid new file mode 100644 index 00000000..26acc4ae --- /dev/null +++ b/project/equip_rules.gd.uid @@ -0,0 +1 @@ +uid://dq43hpy353dab diff --git a/project/equip_rules_test.gd.uid b/project/equip_rules_test.gd.uid new file mode 100644 index 00000000..92ac5e42 --- /dev/null +++ b/project/equip_rules_test.gd.uid @@ -0,0 +1 @@ +uid://dl4x22ievekvx diff --git a/project/fishing_water_test.gd.uid b/project/fishing_water_test.gd.uid new file mode 100644 index 00000000..de8739b4 --- /dev/null +++ b/project/fishing_water_test.gd.uid @@ -0,0 +1 @@ +uid://faq2wm5101df diff --git a/project/fly_object.gd.uid b/project/fly_object.gd.uid new file mode 100644 index 00000000..5a869567 --- /dev/null +++ b/project/fly_object.gd.uid @@ -0,0 +1 @@ +uid://ub000mncflac diff --git a/project/fly_test.gd.uid b/project/fly_test.gd.uid new file mode 100644 index 00000000..b5d6ab50 --- /dev/null +++ b/project/fly_test.gd.uid @@ -0,0 +1 @@ +uid://312cgt6dprj8 diff --git a/project/fx/mde.gd.uid b/project/fx/mde.gd.uid new file mode 100644 index 00000000..d3e5f003 --- /dev/null +++ b/project/fx/mde.gd.uid @@ -0,0 +1 @@ +uid://bs8t4ae40g81s diff --git a/project/game_camera_test.gd.uid b/project/game_camera_test.gd.uid new file mode 100644 index 00000000..eb1652e0 --- /dev/null +++ b/project/game_camera_test.gd.uid @@ -0,0 +1 @@ +uid://vei6jny5w1m2 diff --git a/project/game_scene.gd b/project/game_scene.gd index 20155f2d..6260eaa6 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -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 → 震屏; diff --git a/project/gamescene_test.gd b/project/gamescene_test.gd index 4579b304..8c24670f 100644 --- a/project/gamescene_test.gd +++ b/project/gamescene_test.gd @@ -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") diff --git a/project/guild_creation_ui_test.gd b/project/guild_creation_ui_test.gd index 076a8471..73b942e6 100644 --- a/project/guild_creation_ui_test.gd +++ b/project/guild_creation_ui_test.gd @@ -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() diff --git a/project/guild_mark_tail_test.gd.uid b/project/guild_mark_tail_test.gd.uid new file mode 100644 index 00000000..7cb4a9a1 --- /dev/null +++ b/project/guild_mark_tail_test.gd.uid @@ -0,0 +1 @@ +uid://ch0kvrxf13dnh diff --git a/project/guild_war_skill_test.gd b/project/guild_war_skill_test.gd index 2e8b7b28..aeb3f5ba 100644 --- a/project/guild_war_skill_test.gd +++ b/project/guild_war_skill_test.gd @@ -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), + "移动公会页签使用大触控目标") diff --git a/project/help_ui_test.gd b/project/help_ui_test.gd index e13e2797..a2bb4ddc 100644 --- a/project/help_ui_test.gd +++ b/project/help_ui_test.gd @@ -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() diff --git a/project/input_key_test.gd.uid b/project/input_key_test.gd.uid new file mode 100644 index 00000000..28d5c4f6 --- /dev/null +++ b/project/input_key_test.gd.uid @@ -0,0 +1 @@ +uid://iiiuyg47tudg diff --git a/project/inventory_ui_test.gd b/project/inventory_ui_test.gd index a6f57de2..ba7d23a7 100644 --- a/project/inventory_ui_test.gd +++ b/project/inventory_ui_test.gd @@ -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() diff --git a/project/item_tooltip_test.gd.uid b/project/item_tooltip_test.gd.uid new file mode 100644 index 00000000..7d844a8c --- /dev/null +++ b/project/item_tooltip_test.gd.uid @@ -0,0 +1 @@ +uid://bmarcd40nexwg diff --git a/project/mobile_gesture_test.gd b/project/mobile_gesture_test.gd new file mode 100644 index 00000000..a7de769c --- /dev/null +++ b/project/mobile_gesture_test.gd @@ -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") diff --git a/project/mobile_gesture_test.gd.uid b/project/mobile_gesture_test.gd.uid new file mode 100644 index 00000000..0cc60f18 --- /dev/null +++ b/project/mobile_gesture_test.gd.uid @@ -0,0 +1 @@ +uid://j1ufi6gp5au7 diff --git a/project/mobile_ui_test.gd b/project/mobile_ui_test.gd new file mode 100644 index 00000000..a1310921 --- /dev/null +++ b/project/mobile_ui_test.gd @@ -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() diff --git a/project/mobile_ui_test.gd.uid b/project/mobile_ui_test.gd.uid new file mode 100644 index 00000000..effadd22 --- /dev/null +++ b/project/mobile_ui_test.gd.uid @@ -0,0 +1 @@ +uid://b1vbbltrqfkes diff --git a/project/mobile_window_host_test.gd b/project/mobile_window_host_test.gd new file mode 100644 index 00000000..20cdb282 --- /dev/null +++ b/project/mobile_window_host_test.gd @@ -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() diff --git a/project/mobile_window_host_test.gd.uid b/project/mobile_window_host_test.gd.uid new file mode 100644 index 00000000..e51e806c --- /dev/null +++ b/project/mobile_window_host_test.gd.uid @@ -0,0 +1 @@ +uid://j5tq3hj1d8jk diff --git a/project/mouse_controller_test.gd.uid b/project/mouse_controller_test.gd.uid new file mode 100644 index 00000000..7879326b --- /dev/null +++ b/project/mouse_controller_test.gd.uid @@ -0,0 +1 @@ +uid://0uv8hln06oex diff --git a/project/name_color.gd.uid b/project/name_color.gd.uid new file mode 100644 index 00000000..8247bf34 --- /dev/null +++ b/project/name_color.gd.uid @@ -0,0 +1 @@ +uid://bqvbpbcp4338q diff --git a/project/name_color_test.gd.uid b/project/name_color_test.gd.uid new file mode 100644 index 00000000..cccc3343 --- /dev/null +++ b/project/name_color_test.gd.uid @@ -0,0 +1 @@ +uid://b3jl5bspgvnf2 diff --git a/project/name_show.gd.uid b/project/name_show.gd.uid new file mode 100644 index 00000000..6f2ddee4 --- /dev/null +++ b/project/name_show.gd.uid @@ -0,0 +1 @@ +uid://cfmkostitpvvu diff --git a/project/name_show_test.gd.uid b/project/name_show_test.gd.uid new file mode 100644 index 00000000..7332ba7d --- /dev/null +++ b/project/name_show_test.gd.uid @@ -0,0 +1 @@ +uid://b0tssfrjgab4y diff --git a/project/name_tail_layout.gd.uid b/project/name_tail_layout.gd.uid new file mode 100644 index 00000000..377acbdf --- /dev/null +++ b/project/name_tail_layout.gd.uid @@ -0,0 +1 @@ +uid://cjink4dvgp0qu diff --git a/project/name_tail_layout_test.gd.uid b/project/name_tail_layout_test.gd.uid new file mode 100644 index 00000000..84938e28 --- /dev/null +++ b/project/name_tail_layout_test.gd.uid @@ -0,0 +1 @@ +uid://bwqn6xii0x5xs diff --git a/project/p10_test.gd b/project/p10_test.gd index 6ecb8174..5ff5ffe6 100644 --- a/project/p10_test.gd +++ b/project/p10_test.gd @@ -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 时断线 -> 回 LOGIN(ClientVS22 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") diff --git a/project/p8_test.gd b/project/p8_test.gd index 0602856e..f1bcdcc1 100644 --- a/project/p8_test.gd +++ b/project/p8_test.gd @@ -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 diff --git a/project/part_hiding.gd.uid b/project/part_hiding.gd.uid new file mode 100644 index 00000000..b743cf5f --- /dev/null +++ b/project/part_hiding.gd.uid @@ -0,0 +1 @@ +uid://tceq7sornwqf diff --git a/project/part_hiding_test.gd.uid b/project/part_hiding_test.gd.uid new file mode 100644 index 00000000..4d8f7acd --- /dev/null +++ b/project/part_hiding_test.gd.uid @@ -0,0 +1 @@ +uid://pqahjhjcoifw diff --git a/project/pick_show.gd.uid b/project/pick_show.gd.uid new file mode 100644 index 00000000..ef3c3dad --- /dev/null +++ b/project/pick_show.gd.uid @@ -0,0 +1 @@ +uid://t763fdrluvos diff --git a/project/pick_show_test.gd.uid b/project/pick_show_test.gd.uid new file mode 100644 index 00000000..8811753d --- /dev/null +++ b/project/pick_show_test.gd.uid @@ -0,0 +1 @@ +uid://b6slci25nqllo diff --git a/project/player_controller.gd b/project/player_controller.gd index 5c442384..b6841039 100644 --- a/project/player_controller.gd +++ b/project/player_controller.gd @@ -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 diff --git a/project/player_move_test.gd.uid b/project/player_move_test.gd.uid new file mode 100644 index 00000000..10e05e87 --- /dev/null +++ b/project/player_move_test.gd.uid @@ -0,0 +1 @@ +uid://lqisiv6emkkt diff --git a/project/player_skill.gd.uid b/project/player_skill.gd.uid new file mode 100644 index 00000000..664bf28d --- /dev/null +++ b/project/player_skill.gd.uid @@ -0,0 +1 @@ +uid://s5p8kdn4aywt diff --git a/project/player_skill_test.gd.uid b/project/player_skill_test.gd.uid new file mode 100644 index 00000000..d771f6a1 --- /dev/null +++ b/project/player_skill_test.gd.uid @@ -0,0 +1 @@ +uid://db06iedi0733r diff --git a/project/private_shop_ui_test.gd b/project/private_shop_ui_test.gd index 1fbf84fe..e4fc6f2a 100644 --- a/project/private_shop_ui_test.gd +++ b/project/private_shop_ui_test.gd @@ -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") diff --git a/project/project.godot b/project/project.godot index b22a3412..5e5794f2 100644 --- a/project/project.godot +++ b/project/project.godot @@ -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 diff --git a/project/quest_event_test.gd.uid b/project/quest_event_test.gd.uid new file mode 100644 index 00000000..ad4d53d1 --- /dev/null +++ b/project/quest_event_test.gd.uid @@ -0,0 +1 @@ +uid://skkm2dyg8jmx diff --git a/project/remote_player_test.gd.uid b/project/remote_player_test.gd.uid new file mode 100644 index 00000000..c5be663b --- /dev/null +++ b/project/remote_player_test.gd.uid @@ -0,0 +1 @@ +uid://ci70o6w2yd5cc diff --git a/project/screen_hp_bar.gd.uid b/project/screen_hp_bar.gd.uid new file mode 100644 index 00000000..6f9e1f37 --- /dev/null +++ b/project/screen_hp_bar.gd.uid @@ -0,0 +1 @@ +uid://dokpyjtoa1s48 diff --git a/project/screen_hp_bar_test.gd.uid b/project/screen_hp_bar_test.gd.uid new file mode 100644 index 00000000..150e6f6b --- /dev/null +++ b/project/screen_hp_bar_test.gd.uid @@ -0,0 +1 @@ +uid://d4gu3tfgt3q5d diff --git a/project/shop_cube_mall_test.gd b/project/shop_cube_mall_test.gd index ae7c2755..98f02bf4 100644 --- a/project/shop_cube_mall_test.gd +++ b/project/shop_cube_mall_test.gd @@ -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") diff --git a/project/shop_rules_test.gd.uid b/project/shop_rules_test.gd.uid new file mode 100644 index 00000000..977bfe33 --- /dev/null +++ b/project/shop_rules_test.gd.uid @@ -0,0 +1 @@ +uid://c5rhqrba42pk5 diff --git a/project/skill_test.gd b/project/skill_test.gd index f9494d08..46e75751 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -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) diff --git a/project/skybox_test.gd.uid b/project/skybox_test.gd.uid new file mode 100644 index 00000000..148e8c42 --- /dev/null +++ b/project/skybox_test.gd.uid @@ -0,0 +1 @@ +uid://dk1va37r3o5b0 diff --git a/project/system_menu_ui_test.gd b/project/system_menu_ui_test.gd index d1b6a509..86a6ecf9 100644 --- a/project/system_menu_ui_test.gd +++ b/project/system_menu_ui_test.gd @@ -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()) diff --git a/project/system_option_ui_test.gd b/project/system_option_ui_test.gd index 20c8ceaa..5ad5017b 100644 --- a/project/system_option_ui_test.gd +++ b/project/system_option_ui_test.gd @@ -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() diff --git a/project/target_board.gd.uid b/project/target_board.gd.uid new file mode 100644 index 00000000..eeff6f64 --- /dev/null +++ b/project/target_board.gd.uid @@ -0,0 +1 @@ +uid://csy6qy363o028 diff --git a/project/target_board_test.gd.uid b/project/target_board_test.gd.uid new file mode 100644 index 00000000..4c14f739 --- /dev/null +++ b/project/target_board_test.gd.uid @@ -0,0 +1 @@ +uid://ckiaafr7cv48r diff --git a/project/text_metrics.gd.uid b/project/text_metrics.gd.uid new file mode 100644 index 00000000..6f9175b4 --- /dev/null +++ b/project/text_metrics.gd.uid @@ -0,0 +1 @@ +uid://2wi17v5m0v51 diff --git a/project/text_metrics_test.gd.uid b/project/text_metrics_test.gd.uid new file mode 100644 index 00000000..6c3b73c4 --- /dev/null +++ b/project/text_metrics_test.gd.uid @@ -0,0 +1 @@ +uid://xn5klnex8db2 diff --git a/project/text_tail.gd.uid b/project/text_tail.gd.uid new file mode 100644 index 00000000..6018f39f --- /dev/null +++ b/project/text_tail.gd.uid @@ -0,0 +1 @@ +uid://bmlnjr2tyws0o diff --git a/project/text_tail_arrange.gd.uid b/project/text_tail_arrange.gd.uid new file mode 100644 index 00000000..24803d7f --- /dev/null +++ b/project/text_tail_arrange.gd.uid @@ -0,0 +1 @@ +uid://d3v7hqq4gtouf diff --git a/project/text_tail_arrange_test.gd.uid b/project/text_tail_arrange_test.gd.uid new file mode 100644 index 00000000..d5d0a010 --- /dev/null +++ b/project/text_tail_arrange_test.gd.uid @@ -0,0 +1 @@ +uid://c4hgw5bk51xyv diff --git a/project/text_tail_test.gd.uid b/project/text_tail_test.gd.uid new file mode 100644 index 00000000..c1de01de --- /dev/null +++ b/project/text_tail_test.gd.uid @@ -0,0 +1 @@ +uid://bumeoghswlc5e diff --git a/project/ui/atlas_ui.gd b/project/ui/atlas_ui.gd index a7a469eb..4bf459b9 100644 --- a/project/ui/atlas_ui.gd +++ b/project/ui/atlas_ui.gd @@ -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() diff --git a/project/ui/char_status_ui.gd b/project/ui/char_status_ui.gd index a7b86bab..52e363bd 100644 --- a/project/ui/char_status_ui.gd +++ b/project/ui/char_status_ui.gd @@ -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") # --- EPointTypes(m2dev 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 diff --git a/project/ui/chat_ui.gd b/project/ui/chat_ui.gd index 4c785fd5..8eddcae3 100644 --- a/project/ui/chat_ui.gd +++ b/project/ui/chat_ui.gd @@ -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 是独立窗口,不应把私聊目标写回公共 diff --git a/project/ui/cube_ui.gd b/project/ui/cube_ui.gd index 53f69444..cab59cc2 100644 --- a/project/ui/cube_ui.gd +++ b/project/ui/cube_ui.gd @@ -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() diff --git a/project/ui/cursor_manager.gd.uid b/project/ui/cursor_manager.gd.uid new file mode 100644 index 00000000..659068a7 --- /dev/null +++ b/project/ui/cursor_manager.gd.uid @@ -0,0 +1 @@ +uid://ku3jcc4x80r diff --git a/project/ui/dungeon_result_ui.gd.uid b/project/ui/dungeon_result_ui.gd.uid new file mode 100644 index 00000000..91c02dc1 --- /dev/null +++ b/project/ui/dungeon_result_ui.gd.uid @@ -0,0 +1 @@ +uid://bwp78bnxnfvvx diff --git a/project/ui/exchange_ui.gd b/project/ui/exchange_ui.gd index 0977e8bc..28f6a3c3 100644 --- a/project/ui/exchange_ui.gd +++ b/project/ui/exchange_ui.gd @@ -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() diff --git a/project/ui/friend_ui.gd b/project/ui/friend_ui.gd index d8412ff1..be7c97b6 100644 --- a/project/ui/friend_ui.gd +++ b/project/ui/friend_ui.gd @@ -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) diff --git a/project/ui/game_option_ui.gd b/project/ui/game_option_ui.gd index d26aca73..dd05c9f6 100644 --- a/project/ui/game_option_ui.gd +++ b/project/ui/game_option_ui.gd @@ -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 diff --git a/project/ui/ground_items.gd b/project/ui/ground_items.gd index 85d278cd..d6a094e4 100644 --- a/project/ui/ground_items.gd +++ b/project/ui/ground_items.gd @@ -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 diff --git a/project/ui/guild_ui.gd b/project/ui/guild_ui.gd index ce80b05c..60c82508 100644 --- a/project/ui/guild_ui.gd +++ b/project/ui/guild_ui.gd @@ -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) diff --git a/project/ui/help_ui.gd b/project/ui/help_ui.gd index ce19db2f..1dc70276 100644 --- a/project/ui/help_ui.gd +++ b/project/ui/help_ui.gd @@ -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 # Locale(res://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 diff --git a/project/ui/inventory_ui.gd b/project/ui/inventory_ui.gd index a4c47a25..cce9c641 100644 --- a/project/ui/inventory_ui.gd +++ b/project/ui/inventory_ui.gd @@ -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) diff --git a/project/ui/item_tooltip.gd.uid b/project/ui/item_tooltip.gd.uid new file mode 100644 index 00000000..4eb71dce --- /dev/null +++ b/project/ui/item_tooltip.gd.uid @@ -0,0 +1 @@ +uid://dqv4o4srhhl45 diff --git a/project/ui/love_ui.gd b/project/ui/love_ui.gd index a5bae333..c67b6cf0 100644 --- a/project/ui/love_ui.gd +++ b/project/ui/love_ui.gd @@ -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" diff --git a/project/ui/mall_ui.gd b/project/ui/mall_ui.gd index 467cec8e..4707d177 100644 --- a/project/ui/mall_ui.gd +++ b/project/ui/mall_ui.gd @@ -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() diff --git a/project/ui/minimap.gd b/project/ui/minimap.gd index fbec8351..90578edc 100644 --- a/project/ui/minimap.gd +++ b/project/ui/minimap.gd @@ -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() diff --git a/project/ui/mobile/mobile_hud.gd b/project/ui/mobile/mobile_hud.gd new file mode 100644 index 00000000..3b9a8ae2 --- /dev/null +++ b/project/ui/mobile/mobile_hud.gd @@ -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 diff --git a/project/ui/mobile/mobile_hud.gd.uid b/project/ui/mobile/mobile_hud.gd.uid new file mode 100644 index 00000000..eff42e5b --- /dev/null +++ b/project/ui/mobile/mobile_hud.gd.uid @@ -0,0 +1 @@ +uid://bh0jqy5qycvad diff --git a/project/ui/mobile/mobile_input_overlay.gd b/project/ui/mobile/mobile_input_overlay.gd new file mode 100644 index 00000000..1bb56bf5 --- /dev/null +++ b/project/ui/mobile/mobile_input_overlay.gd @@ -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 diff --git a/project/ui/mobile/mobile_input_overlay.gd.uid b/project/ui/mobile/mobile_input_overlay.gd.uid new file mode 100644 index 00000000..8eebca62 --- /dev/null +++ b/project/ui/mobile/mobile_input_overlay.gd.uid @@ -0,0 +1 @@ +uid://bqyoh0sj72ro1 diff --git a/project/ui/mobile/mobile_menu_drawer.gd b/project/ui/mobile/mobile_menu_drawer.gd new file mode 100644 index 00000000..9bd78225 --- /dev/null +++ b/project/ui/mobile/mobile_menu_drawer.gd @@ -0,0 +1,154 @@ +# MobileMenuDrawer —— compact two-level menu for the top-right menu button. +# +# The drawer only routes actions. Feature nodes remain the owners of data, +# server state and permission checks. +extends Control + +const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") + +signal item_selected(category: String, item: String) +signal opened +signal closed + +const CATEGORIES := ["社交", "成长", "活动", "商业", "系统"] +const ITEMS := { + "社交": ["聊天", "好友", "公会", "情侣", "观战"], + "成长": ["精炼", "龙魂", "Cube 制作"], + "活动": ["副本", "钓鱼"], + "商业": ["商店", "商城", "交易", "私人商店", "仓库", "兑换"], + "系统": ["系统设置", "游戏设置", "帮助", "选择角色", "登出", "退出游戏"], +} + +var _backdrop: ColorRect +var _panel: Panel +var _category_row: HBoxContainer +var _item_scroll: ScrollContainer +var _items: VBoxContainer +var _title: Label +var _hint: Label +var _active_category := "社交" + +func setup() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + _build() + visible = false + +func is_open() -> bool: + return visible + +func toggle() -> void: + if visible: + close() + else: + open() + +func open() -> void: + if visible: + return + visible = true + _refresh_items() + opened.emit() + +func close() -> void: + if not visible: + return + visible = false + closed.emit() + +func _build() -> void: + _backdrop = ColorRect.new() + _backdrop.color = Color(0.01, 0.02, 0.035, 0.38) + _backdrop.set_anchors_preset(Control.PRESET_FULL_RECT) + _backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + _backdrop.gui_input.connect(func(event: InputEvent): + if event is InputEventScreenTouch and not event.pressed: + close() + elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: + close()) + add_child(_backdrop) + + _panel = Panel.new() + _panel.set_anchors_preset(Control.PRESET_TOP_RIGHT) + _panel.position = Vector2(-366, 72) + _panel.size = Vector2(350, 300) + _panel.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.add_theme_stylebox_override("panel", _panel_style()) + add_child(_panel) + + _title = Label.new() + _title.position = Vector2(18, 13) + _title.add_theme_font_size_override("font_size", 18) + _title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.52)) + _panel.add_child(_title) + + var close_button := TouchButton.new() + close_button.setup("×", Color(0.6, 0.75, 0.96, 0.9)) + close_button.set_rect_style() + close_button.position = Vector2(310, 7) + close_button.size = Vector2(32, 32) + close_button.custom_minimum_size = Vector2(32, 32) + close_button.pressed.connect(close) + _panel.add_child(close_button) + + _category_row = HBoxContainer.new() + _category_row.position = Vector2(14, 48) + _category_row.size = Vector2(322, 38) + _category_row.add_theme_constant_override("separation", 4) + _panel.add_child(_category_row) + for category in CATEGORIES: + var button := TouchButton.new() + button.setup(category, Color(0.45, 0.7, 0.95, 0.9)) + button.set_rect_style() + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size = Vector2(0, 36) + var selected: String = String(category) + button.pressed.connect(func(): + _active_category = selected + _refresh_items()) + _category_row.add_child(button) + + _item_scroll = ScrollContainer.new() + _item_scroll.position = Vector2(16, 96) + _item_scroll.size = Vector2(318, 160) + _item_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _item_scroll.mouse_filter = Control.MOUSE_FILTER_STOP + _panel.add_child(_item_scroll) + _items = VBoxContainer.new() + _items.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _items.add_theme_constant_override("separation", 5) + _item_scroll.add_child(_items) + + _hint = Label.new() + _hint.position = Vector2(18, 264) + _hint.size = Vector2(314, 26) + _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _hint.add_theme_font_size_override("font_size", 11) + _hint.add_theme_color_override("font_color", Color(0.66, 0.72, 0.8)) + _panel.add_child(_hint) + +func _refresh_items() -> void: + if _panel == null: + return + _title.text = _active_category + _hint.text = "功能入口仍受当前场景和服务器状态限制。" + for child in _items.get_children(): + child.queue_free() + for item in ITEMS.get(_active_category, []): + var button := TouchButton.new() + button.setup(" " + String(item), Color(0.42, 0.65, 0.9, 0.8)) + button.set_rect_style() + button.custom_minimum_size = Vector2(0, 34) + var selected: String = String(item) + button.pressed.connect(func(): item_selected.emit(_active_category, selected)) + _items.add_child(button) + +func _panel_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.045, 0.065, 0.1, 0.96) + style.border_color = Color(0.52, 0.68, 0.9, 0.82) + style.set_border_width_all(1) + style.set_corner_radius_all(12) + style.shadow_color = Color(0, 0, 0, 0.45) + style.shadow_size = 12 + return style diff --git a/project/ui/mobile/mobile_menu_drawer.gd.uid b/project/ui/mobile/mobile_menu_drawer.gd.uid new file mode 100644 index 00000000..8000b9a8 --- /dev/null +++ b/project/ui/mobile/mobile_menu_drawer.gd.uid @@ -0,0 +1 @@ +uid://c2ut2qatd5mbu diff --git a/project/ui/mobile/mobile_touch_button.gd b/project/ui/mobile/mobile_touch_button.gd new file mode 100644 index 00000000..d1103158 --- /dev/null +++ b/project/ui/mobile/mobile_touch_button.gd @@ -0,0 +1,226 @@ +# MobileTouchButton —— a mouse and screen-touch friendly action surface. +# +# BaseButton's mouse emulation is intentionally disabled in project.godot, so +# the mobile layer owns the small amount of press/release handling it needs. +# This keeps a touch index alive until release and gives gameplay a reliable +# cancellation path when the app loses focus. +extends Control + +signal pressed +signal released +signal press_state(down: bool) +signal long_pressed +signal drag_started +signal drag_changed(delta: Vector2) +signal drag_released(delta: Vector2) + +var label_text := "" +var accent := Color(0.36, 0.64, 0.95, 0.95) +var idle_modulate := Color.WHITE +var circular := true +var active := false +var _touch_index := -1 +var _caption: Label +var _press_position := Vector2.ZERO +var _last_position := Vector2.ZERO +var _drag_delta := Vector2.ZERO +var _long_press_token := 0 +var _long_pressed := false +var _dragging := false +var _aiming := false +const LONG_PRESS_SECONDS := 0.55 +const DRAG_THRESHOLD := 10.0 + +func setup(text: String, color: Color = Color(0.36, 0.64, 0.95, 0.95)) -> void: + label_text = text + accent = color + mouse_filter = Control.MOUSE_FILTER_STOP + custom_minimum_size = Vector2(56, 56) + _caption = Label.new() + _caption.text = label_text + _caption.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _caption.set_anchors_preset(Control.PRESET_FULL_RECT) + _caption.mouse_filter = Control.MOUSE_FILTER_IGNORE + _caption.add_theme_font_size_override("font_size", 13) + _caption.add_theme_color_override("font_color", Color(0.94, 0.97, 1.0)) + _caption.add_theme_color_override("font_outline_color", Color(0.02, 0.03, 0.05, 0.95)) + _caption.add_theme_constant_override("outline_size", 3) + add_child(_caption) + queue_redraw() + +func set_caption(text: String) -> void: + label_text = text + if _caption: + _caption.text = text + +func set_idle_modulate(value: Color) -> void: + idle_modulate = value + modulate = value if not active else Color(0.82, 0.9, 1.0, value.a) + +## Enables the long-press/drag gesture used by mobile skill aiming and +## inventory movement. The ordinary pressed/released signals remain intact +## for buttons that do not need a gesture. +func set_gesture_enabled(enabled: bool) -> void: + set_meta("gesture_enabled", enabled) + +func was_long_pressed() -> bool: + return _long_pressed + +func is_dragging() -> bool: + return _dragging + +func drag_total() -> Vector2: + return _drag_delta + +func drag_end_position() -> Vector2: + return _last_position + +func set_aiming(value: bool) -> void: + _aiming = value + queue_redraw() + +func set_rect_style() -> void: + circular = false + queue_redraw() + +func _ready() -> void: + set_process_unhandled_input(true) + queue_redraw() + +func _gui_input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + if event.pressed: + if _touch_index < 0: + _touch_index = event.index + _begin_press(_event_position(event)) + else: + if event.index == _touch_index: + _touch_index = -1 + _end_press(_event_position(event)) + accept_event() + return + if event is InputEventScreenDrag and event.index == _touch_index: + _update_drag(_event_position(event)) + accept_event() + return + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _touch_index = -2 + _begin_press(_event_position(event)) + else: + _touch_index = -1 + _end_press(_event_position(event)) + accept_event() + return + if event is InputEventMouseMotion and _touch_index == -2: + _update_drag(_event_position(event)) + accept_event() + +func _event_position(event: InputEvent) -> Vector2: + if event is InputEventMouse: + return (event as InputEventMouse).position + if event is InputEventScreenTouch: + return (event as InputEventScreenTouch).position + if event is InputEventScreenDrag: + return (event as InputEventScreenDrag).position + return Vector2.ZERO + +func _begin_press(position: Vector2) -> void: + if active: + return + active = true + _press_position = position + _last_position = position + _drag_delta = Vector2.ZERO + _long_pressed = false + _dragging = false + _aiming = false + _long_press_token += 1 + var token := _long_press_token + if bool(get_meta("gesture_enabled", false)) and get_tree() != null: + get_tree().create_timer(LONG_PRESS_SECONDS).timeout.connect(func(): + if token != _long_press_token or not active or _long_pressed: + return + _long_pressed = true + _dragging = true + long_pressed.emit() + queue_redraw()) + modulate = Color(0.82, 0.9, 1.0, idle_modulate.a) + pressed.emit() + press_state.emit(true) + queue_redraw() + +func _update_drag(position: Vector2) -> void: + if not active: + return + var delta := position - _last_position + _last_position = position + _drag_delta = position - _press_position + if not bool(get_meta("gesture_enabled", false)): + return + if not _long_pressed: + if _drag_delta.length() > DRAG_THRESHOLD: + _long_press_token += 1 + return + if not _dragging: + _dragging = true + drag_started.emit() + drag_changed.emit(delta) + queue_redraw() + +func _end_press(position: Vector2) -> void: + if not active: + return + _update_drag(position) + _long_press_token += 1 + if _dragging and _long_pressed: + drag_released.emit(_drag_delta) + active = false + modulate = idle_modulate + released.emit() + press_state.emit(false) + queue_redraw() + +func cancel_press() -> void: + _long_press_token += 1 + _touch_index = -1 + if active: + # A cancelled gesture must never look like a completed drag to its + # receiver. Clear the gesture flags before ending the visual press. + _dragging = false + _long_pressed = false + _aiming = false + _end_press(_last_position) + else: + _long_pressed = false + _dragging = false + _aiming = false + +func _unhandled_input(event: InputEvent) -> void: + # A focus loss / window pause can leave a touch-up event outside the + # control. MobileUiRoot also calls cancel_all() for the full overlay. + if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index: + _touch_index = -1 + _end_press(_last_position) + +func _draw() -> void: + if not circular: + var rect_style := StyleBoxFlat.new() + rect_style.bg_color = Color(0.06, 0.1, 0.16, 0.86) + rect_style.border_color = accent + rect_style.set_border_width_all(1) + rect_style.set_corner_radius_all(6) + draw_style_box(rect_style, Rect2(Vector2.ZERO, size)) + return + var center := size * 0.5 + var radius := minf(size.x, size.y) * 0.5 - 3.0 + if radius <= 0.0: + return + draw_circle(center, radius, Color(0.025, 0.04, 0.07, 0.68)) + draw_arc(center, radius, 0.0, TAU, 48, accent, 3.0 if active or _aiming else 2.0) + draw_arc(center, radius - 5.0, 0.0, TAU, 48, Color(accent, 0.22), 1.0) + if _aiming: + draw_circle(center, 5.0, Color(1.0, 0.88, 0.52, 0.9)) + draw_arc(center, radius - 12.0, -PI * 0.5, PI * 0.5, 24, + Color(1.0, 0.88, 0.52, 0.8), 2.0) diff --git a/project/ui/mobile/mobile_touch_button.gd.uid b/project/ui/mobile/mobile_touch_button.gd.uid new file mode 100644 index 00000000..72d9e8d1 --- /dev/null +++ b/project/ui/mobile/mobile_touch_button.gd.uid @@ -0,0 +1 @@ +uid://dpg433tb2c1qn diff --git a/project/ui/mobile/mobile_ui_root.gd b/project/ui/mobile/mobile_ui_root.gd new file mode 100644 index 00000000..5690b1a2 --- /dev/null +++ b/project/ui/mobile/mobile_ui_root.gd @@ -0,0 +1,453 @@ +# MobileUiRoot —— the mobile presentation root. +# +# It owns only mobile layout, routing and touch surfaces. Existing feature +# controllers are injected after GameScene has created them and remain the +# source of truth for server-backed state. +extends CanvasLayer + +const MobileHud := preload("res://ui/mobile/mobile_hud.gd") +const MobileInputOverlay := preload("res://ui/mobile/mobile_input_overlay.gd") +const MobileMenuDrawer := preload("res://ui/mobile/mobile_menu_drawer.gd") +const TouchButton := preload("res://ui/mobile/mobile_touch_button.gd") +const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd") +const DESIGN_SIZE := Vector2(844, 390) + +var world: Node +var player: Node3D +var client: Node +var hud_view: Control +var input_overlay: Control +var menu_drawer: Control +var _avatar_panel: Panel +var _toast: Label +var _bindings := {} +var _content_root: Control +var _orientation_guard: ColorRect +var _orientation_label: Label +var _last_viewport_size := Vector2.ZERO +var _last_safe_rect := Rect2(-1, -1, 0, 0) +var ui: Node + +func setup(metin_world: Node, local_player: Node3D, m2client: Node = null) -> void: + world = metin_world + player = local_player + client = m2client + layer = 20 + _build() + _layout_root() + set_process(true) + if client: + hud_view.bind_client(client) + +func bind_controls(bindings: Dictionary) -> void: + _bindings = bindings + client = bindings.get("client", client) + ui = bindings.get("ui", ui) + if ui and ui.has_method("set_mobile_mode"): + ui.set_mobile_mode(true) + if hud_view: + hud_view.bind_client(client) + if input_overlay: + input_overlay.bind_controls(bindings.get("player_controller"), + bindings.get("net_play"), bindings.get("quickbar"), bindings.get("ground_items")) + if bindings.get("party_ui") and bindings["party_ui"].has_method("set_mobile_mode"): + bindings["party_ui"].set_mobile_mode(true) + if bindings.get("quickbar") and bindings["quickbar"].has_method("set_mobile_mode"): + bindings["quickbar"].set_mobile_mode(true) + if bindings.get("skills") and bindings["skills"].has_method("set_mobile_quickbar"): + bindings["skills"].set_mobile_quickbar(bindings.get("quickbar")) + if bindings.get("inventory") and bindings["inventory"].has_method("set_mobile_mode"): + bindings["inventory"].set_mobile_mode(true) + if bindings.get("quest_log") and bindings["quest_log"].has_method("set_mobile_mode"): + bindings["quest_log"].set_mobile_mode(true) + if bindings.get("chat") and bindings["chat"].has_method("set_mobile_mode"): + bindings["chat"].set_mobile_mode(true) + for key in ["char_status_ui", "skills", "friend_ui", "guild_ui", "shop_ui", "exchange_ui", "safebox_ui", "mall_ui", "cube_ui", "private_shop_ui", "refine_ui", "love_ui", "system_option_ui", "game_option_ui", "system_menu_ui"]: + var feature: Node = bindings.get(key) + if feature and feature.has_method("set_mobile_mode"): + feature.set_mobile_mode(true) + _register_mobile_windows() + var minimap: Node = bindings.get("minimap") + if minimap and minimap.has_method("mount_mobile") and hud_view.has_method("get_minimap_host"): + minimap.mount_mobile(hud_view.get_minimap_host()) + _wire_signals() + _connect_lifecycle() + +# NetPlay/HUD compatibility surface ------------------------------------- + +func set_vitals(hp: int, max_hp: int, sp: int, max_sp: int) -> void: + if hud_view: + hud_view.set_vitals(hp, max_hp, sp, max_sp) + +func set_exp(xp: int, next_xp: int) -> void: + if hud_view: + hud_view.set_exp(xp, next_xp) + +func set_level(value: int) -> void: + if hud_view: + hud_view.set_level(value) + +func set_energy(value: int, max_value: int = 100) -> void: + if hud_view: + hud_view.set_energy(value, max_value) + +func set_stamina(value: int, max_value: int) -> void: + if hud_view: + hud_view.set_stamina(value, max_value) + +func set_affects(values: Array) -> void: + if hud_view: + hud_view.set_affects(values) + +func set_target(name: String, hp_pct: int) -> void: + if hud_view: + hud_view.set_target(name, hp_pct) + +func clear_target() -> void: + if hud_view: + hud_view.clear_target() + +func set_channel(value: int) -> void: + if hud_view: + hud_view.set_channel(value) + +func set_dungeon_destination(active: bool, world_pos: Vector3) -> void: + if hud_view: + hud_view.set_dungeon_destination(active, world_pos) + +func cancel_touch_state() -> void: + if input_overlay and input_overlay.has_method("cancel_all"): + input_overlay.cancel_all() + +static func requires_landscape(viewport_size: Vector2) -> bool: + return viewport_size.y > viewport_size.x and viewport_size.x > 0.0 + +func _process(_delta: float) -> void: + _layout_root() + _refresh_context_action() + +func _layout_root() -> void: + if _content_root == null or get_viewport() == null: + return + var viewport_size := get_viewport().get_visible_rect().size + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return + var safe := _safe_viewport_rect(viewport_size) + var portrait := requires_landscape(viewport_size) + var geometry_changed := not viewport_size.is_equal_approx(_last_viewport_size) \ + or not safe.is_equal_approx(_last_safe_rect) + if _orientation_guard: + _orientation_guard.visible = portrait + if _content_root: + _content_root.visible = not portrait + _last_viewport_size = viewport_size + _last_safe_rect = safe + if portrait: + # The project requests landscape on mobile. The guard is still needed + # for rotation races and desktop previews so controls never collapse into + # a portrait layout for one or two frames. + cancel_touch_state() + return + if not geometry_changed: + return + var fit := minf(safe.size.x / DESIGN_SIZE.x, safe.size.y / DESIGN_SIZE.y) + var configured := float(ProjectSettings.get_setting("mt/ui/mobile_scale", 1.0)) + fit = maxf(0.1, fit * maxf(0.5, configured)) + _content_root.size = DESIGN_SIZE + _content_root.scale = Vector2.ONE * fit + _content_root.position = safe.position + (safe.size - DESIGN_SIZE * fit) * 0.5 + +func _safe_viewport_rect(viewport_size: Vector2) -> Rect2: + return MobileWindowHost.safe_rect_for(viewport_size, DisplayServer.get_display_safe_area()) + +func _refresh_context_action() -> void: + var ground: Node = _bindings.get("ground_items") + if ground == null or not ground.has_method("has_nearby_item"): + return + var available := bool(ground.has_nearby_item()) + if hud_view and hud_view.has_method("set_context_action"): + hud_view.set_context_action("拾取", available) + +func _build() -> void: + _content_root = Control.new() + _content_root.set_anchors_preset(Control.PRESET_TOP_LEFT) + _content_root.size = DESIGN_SIZE + _content_root.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_content_root) + + hud_view = MobileHud.new() + hud_view.setup() + _content_root.add_child(hud_view) + + input_overlay = MobileInputOverlay.new() + input_overlay.setup() + _content_root.add_child(input_overlay) + + menu_drawer = MobileMenuDrawer.new() + menu_drawer.setup() + _content_root.add_child(menu_drawer) + + hud_view.avatar_pressed.connect(_on_avatar_pressed) + hud_view.inventory_pressed.connect(_on_inventory_pressed) + hud_view.minimap_pressed.connect(_on_minimap_pressed) + hud_view.menu_pressed.connect(_on_menu_pressed) + hud_view.quest_pressed.connect(_on_quest_pressed) + hud_view.party_member_pressed.connect(_on_party_member_pressed) + hud_view.context_action_pressed.connect(_on_context_action_pressed) + menu_drawer.item_selected.connect(_on_menu_item) + _build_avatar_panel(_content_root) + _build_orientation_guard() + +func _build_orientation_guard() -> void: + _orientation_guard = ColorRect.new() + _orientation_guard.name = "LandscapeRequired" + _orientation_guard.color = Color(0.015, 0.025, 0.045, 0.98) + _orientation_guard.set_anchors_preset(Control.PRESET_FULL_RECT) + _orientation_guard.mouse_filter = Control.MOUSE_FILTER_STOP + _orientation_guard.visible = false + _orientation_guard.z_index = 200 + add_child(_orientation_guard) + _orientation_label = Label.new() + _orientation_label.text = "请横屏使用" + _orientation_label.set_anchors_preset(Control.PRESET_CENTER) + _orientation_label.position = Vector2(-120, -20) + _orientation_label.size = Vector2(240, 40) + _orientation_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _orientation_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _orientation_label.add_theme_font_size_override("font_size", 20) + _orientation_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) + _orientation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + _orientation_guard.add_child(_orientation_label) + +func _wire_signals() -> void: + # Calling bind_controls twice is safe during a preview rebuild; signals are + # connected once because this method is guarded by metadata. + if get_meta("mobile_signals_wired", false): + return + set_meta("mobile_signals_wired", true) + var np: Node = _bindings.get("net_play") + if np and np.has_signal("cannot_act"): + np.cannot_act.connect(func(code: String): _show_message(code)) + var quickbar: Node = _bindings.get("quickbar") + if quickbar and quickbar.has_signal("skill_rejected"): + quickbar.skill_rejected.connect(func(_sid: int, code: String): _show_message(code)) + +func _connect_lifecycle() -> void: + var life: Node = _bindings.get("lifecycle") + if life == null or get_meta("lifecycle_wired", false): + return + set_meta("lifecycle_wired", true) + if life.has_signal("back_requested"): + life.back_requested.connect(_on_back_requested) + if life.has_signal("resumed"): + life.resumed.connect(func(): cancel_touch_state()) + +func _on_back_requested() -> void: + if ui and ui.has_method("close_top") and bool(ui.close_top()): + return + if menu_drawer and menu_drawer.has_method("is_open") and menu_drawer.is_open(): + menu_drawer.close() + return + if _avatar_panel and _avatar_panel.visible: + _avatar_panel.visible = false + return + cancel_touch_state() + +func _register_mobile_windows() -> void: + if ui == null or not ui.has_method("track_mobile_window"): + return + _register_window(_bindings.get("quest_log"), "任务日志") + _register_window(_bindings.get("friend_ui"), "好友") + _register_window(_bindings.get("guild_ui"), "公会") + _register_window(_bindings.get("chat"), "聊天") + _register_window(_bindings.get("shop_ui"), "商店") + _register_window(_bindings.get("exchange_ui"), "交易") + _register_window(_bindings.get("safebox_ui"), "仓库") + _register_window(_bindings.get("mall_ui"), "商城仓库") + _register_window(_bindings.get("cube_ui"), "制作") + _register_window(_bindings.get("private_shop_ui"), "私人商店") + _register_window(_bindings.get("refine_ui"), "精炼") + +func _register_window(target: Node, title: String) -> void: + if target == null: + return + var close_cb := Callable(target, "close") if target.has_method("close") else Callable() + if target.has_method("get_mobile_windows"): + for root in target.get_mobile_windows(): + if root is Control: + ui.track_mobile_window(root, title, close_cb) + elif target.has_method("get_mobile_window"): + var root: Control = target.get_mobile_window() + if root: + ui.track_mobile_window(root, title, close_cb) + +func _build_avatar_panel(parent: Control) -> void: + _avatar_panel = Panel.new() + _avatar_panel.position = Vector2(112, 18) + _avatar_panel.size = Vector2(170, 218) + _avatar_panel.visible = false + _avatar_panel.mouse_filter = Control.MOUSE_FILTER_STOP + _avatar_panel.add_theme_stylebox_override("panel", _panel_style()) + parent.add_child(_avatar_panel) + var title := Label.new() + title.text = "角色" + title.position = Vector2(14, 10) + title.add_theme_font_size_override("font_size", 16) + title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) + _avatar_panel.add_child(title) + var close := TouchButton.new() + close.setup("×", Color(0.6, 0.75, 0.96, 0.9)) + close.set_rect_style() + close.position = Vector2(132, 5) + close.size = Vector2(30, 28) + close.pressed.connect(func(): _avatar_panel.visible = false) + _avatar_panel.add_child(close) + for data in [["角色 / 装备", "status"], ["技能", "skills"], ["坐骑", "mount"], ["表情", "emoticon"]]: + var button := TouchButton.new() + button.setup(String(data[0]), Color(0.45, 0.7, 0.95, 0.9)) + button.set_rect_style() + button.position = Vector2(12, 42 + (data[1] as String).length() * 0) + button.size = Vector2(146, 34) + button.position.y = 42 + _avatar_panel.get_child_count() * 0 + var action := String(data[1]) + button.pressed.connect(func(): _open_avatar_feature(action)) + _avatar_panel.add_child(button) + # Reposition after the title/close children have been added. + for i in range(4): + var button := _avatar_panel.get_child(2 + i) as TouchButton + if button: + button.position = Vector2(12, 44 + i * 39) + +func _on_avatar_pressed() -> void: + _avatar_panel.visible = not _avatar_panel.visible + +func _open_avatar_feature(action: String) -> void: + _avatar_panel.visible = false + var cs: Node = _bindings.get("char_status_ui") + var skills: Node = _bindings.get("skills") + match action: + "status": + if cs and cs.has_method("open"): cs.open("STATUS") + "emoticon": + if cs and cs.has_method("open"): cs.open("EMOTICON") + "skills", "mount": + if skills: + if skills.has_method("set_job"): + skills.set_job(_job_name()) + if skills.has_method("open"): skills.open() + +func _on_inventory_pressed() -> void: + var inventory: Node = _bindings.get("inventory") + if inventory and inventory.has_method("toggle"): + inventory.toggle() + +func _on_minimap_pressed() -> void: + var atlas: Node = _bindings.get("atlas_ui") + if atlas and atlas.has_method("open"): + atlas.open() + +func _on_menu_pressed() -> void: + if menu_drawer: + menu_drawer.toggle() + +func _on_quest_pressed() -> void: + var quests: Node = _bindings.get("quest_log") + if quests and quests.has_method("toggle"): + quests.toggle() + +func _on_party_member_pressed(vid: int) -> void: + if client and vid > 0 and client.has_method("set_target"): + client.set_target(vid) + +func _on_context_action_pressed() -> void: + var ground: Node = _bindings.get("ground_items") + if ground and ground.has_method("try_pickup") and int(ground.try_pickup()) != 0: + return + _show_message("附近没有可拾取的物品") + +func _on_menu_item(category: String, item: String) -> void: + if menu_drawer: + menu_drawer.close() + var target: Node + match category: + "社交": + match item: + "聊天": target = _bindings.get("chat"); _call_toggle(target, "toggle_log") + "好友": target = _bindings.get("friend_ui"); _call_toggle(target) + "公会": target = _bindings.get("guild_ui"); _call_toggle(target) + "情侣": target = _bindings.get("love_ui"); _call_toggle(target) + _: _show_message("观战功能将在进入观战状态后可用") + "成长": + if item == "龙魂": + _show_message("龙魂功能当前未开放") + else: + _show_message("请靠近对应 NPC 后使用:" + item) + "活动": + if item == "钓鱼": + var np: Node = _bindings.get("net_play") + if np and np.has_method("activate_fishing"): + _show_message(String(np.activate_fishing())) + else: + _show_message("请从任务或活动区域进入副本") + "商业": + if item == "商城": + if client and client.has_method("say"): client.say(0, "/in_game_mall") + else: + _show_message("请靠近对应 NPC 或摊位后使用:" + item) + "系统": + _system_action(item) + +func _system_action(item: String) -> void: + var system_menu: Node = _bindings.get("system_menu_ui") + var sys_opt: Node = _bindings.get("system_option_ui") + var game_opt: Node = _bindings.get("game_option_ui") + match item: + "系统设置": _call_open(sys_opt) + "游戏设置": _call_open(game_opt) + "帮助": + if system_menu and system_menu.has_method("open_help"): system_menu.open_help() + "选择角色": + if client and client.has_method("say"): client.say(0, "/phase_select") + "登出": + if client and client.has_method("say"): client.say(0, "/logout") + "退出游戏": get_tree().quit() + +func _call_toggle(target: Node, method := "toggle") -> void: + if target and target.has_method(method): + target.call(method) + +func _call_open(target: Node) -> void: + if target and target.has_method("open"): + target.open() + +func _job_name() -> String: + if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): + return "WARRIOR" + var race := int(client.get_entity(client.get_main_vid()).get("race", 0)) & 3 + return ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"][race] + +func _show_message(message: String) -> void: + if _toast == null: + _toast = Label.new() + _toast.set_anchors_preset(Control.PRESET_CENTER_BOTTOM) + _toast.position = Vector2(-170, -62) + _toast.size = Vector2(340, 34) + _toast.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _toast.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _toast.add_theme_font_size_override("font_size", 12) + _toast.add_theme_color_override("font_color", Color(1.0, 0.86, 0.58)) + _toast.mouse_filter = Control.MOUSE_FILTER_IGNORE + _content_root.add_child(_toast) + _toast.text = message + _toast.visible = true + var tween := create_tween() + tween.tween_interval(1.8) + tween.tween_callback(func(): if _toast: _toast.visible = false) + +func _panel_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.04, 0.065, 0.1, 0.96) + style.border_color = Color(0.55, 0.7, 0.92, 0.9) + style.set_border_width_all(1) + style.set_corner_radius_all(10) + return style diff --git a/project/ui/mobile/mobile_ui_root.gd.uid b/project/ui/mobile/mobile_ui_root.gd.uid new file mode 100644 index 00000000..27a79ad1 --- /dev/null +++ b/project/ui/mobile/mobile_ui_root.gd.uid @@ -0,0 +1 @@ +uid://d0etc5yr8cuuy diff --git a/project/ui/mobile/mobile_window_host.gd b/project/ui/mobile/mobile_window_host.gd new file mode 100644 index 00000000..7f933aab --- /dev/null +++ b/project/ui/mobile/mobile_window_host.gd @@ -0,0 +1,163 @@ +# MobileWindowHost —— touch-safe presentation for existing feature windows. +# +# Feature controllers keep ownership of data, packets and permissions. This +# helper only gives their Control roots a landscape-safe frame, a predictable +# back/close affordance and a screen-touch bridge for legacy Button nodes. +extends RefCounted + +const NAV_HEIGHT := 40.0 +const EDGE_MARGIN := 12.0 +const MIN_SCALE := 0.42 + +static func safe_rect(viewport: Viewport) -> Rect2: + if viewport == null: + return Rect2(Vector2.ZERO, Vector2(844, 390)) + var viewport_size := viewport.get_visible_rect().size + return safe_rect_for(viewport_size, DisplayServer.get_display_safe_area()) + +## Resolve a platform-reported safe area against the current logical viewport. +## Keeping this pure makes the edge cases testable without an Android/iOS +## device and avoids trusting desktop backends that return physical-screen +## coordinates unrelated to the current viewport. +static func safe_rect_for(viewport_size: Vector2, reported: Rect2) -> Rect2: + if viewport_size.x <= 0.0 or viewport_size.y <= 0.0: + return Rect2(Vector2.ZERO, viewport_size) + if reported.size.x <= 0.0 or reported.size.y <= 0.0: + return Rect2(Vector2.ZERO, viewport_size) + var result := reported + # Some desktop backends report a screen-space safe area larger than the + # current viewport. Treat that as unavailable rather than moving windows + # outside the viewport. + if result.size.x < 1.0 or result.size.y < 1.0: + return Rect2(Vector2.ZERO, viewport_size) + if result.position.x < 0.0 or result.position.y < 0.0 \ + or result.end.x > viewport_size.x + 1.0 \ + or result.end.y > viewport_size.y + 1.0: + return Rect2(Vector2.ZERO, viewport_size) + return result + +static func content_size(root: Control) -> Vector2: + if root == null: + return Vector2(320, 240) + var result := root.size + if result.x <= 1.0 or result.y <= 1.0: + result = root.get_combined_minimum_size() + if result.x <= 1.0 or result.y <= 1.0: + result = Vector2(320, 240) + return result + +static func install(root: Control, area: Rect2, title: String, + close_cb: Callable, back_cb: Callable = Callable()) -> Dictionary: + if root == null: + return {} + var base_size := content_size(root) + var available := area.size - Vector2(EDGE_MARGIN * 2.0, EDGE_MARGIN * 2.0) + var scale_value := minf(available.x / base_size.x, available.y / base_size.y) + scale_value = maxf(MIN_SCALE, minf(1.0, scale_value)) + root.set_anchors_preset(Control.PRESET_TOP_LEFT) + root.size = base_size + root.pivot_offset = Vector2.ZERO + root.scale = Vector2.ONE * scale_value + root.position = area.position + (area.size - base_size * scale_value) * 0.5 + root.set_meta("mobile_hosted", true) + root.set_meta("mobile_base_size", base_size) + root.set_meta("mobile_scale", scale_value) + + var nav := root.get_node_or_null("__mobile_nav") as Control + if nav == null: + nav = Panel.new() + nav.name = "__mobile_nav" + nav.mouse_filter = Control.MOUSE_FILTER_STOP + nav.z_index = 100 + var nav_style := StyleBoxFlat.new() + nav_style.bg_color = Color(0.025, 0.045, 0.08, 0.96) + nav_style.border_color = Color(0.36, 0.6, 0.92, 0.82) + nav_style.border_width_bottom = 1 + nav.add_theme_stylebox_override("panel", nav_style) + root.add_child(nav) + nav.position = Vector2.ZERO + nav.size = Vector2(base_size.x, NAV_HEIGHT) + nav.visible = true + + var title_label := nav.get_node_or_null("Title") as Label + if title_label == null: + title_label = Label.new() + title_label.name = "Title" + title_label.position = Vector2(88, 0) + title_label.size = Vector2(maxf(80.0, base_size.x - 176.0), NAV_HEIGHT) + title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + title_label.add_theme_font_size_override("font_size", 15) + title_label.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) + title_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + nav.add_child(title_label) + title_label.text = title if title != "" else String(root.name) + + var back := nav.get_node_or_null("Back") as Button + if back == null: + back = Button.new() + back.name = "Back" + back.text = "返回" + back.position = Vector2(8, 4) + back.size = Vector2(70, 32) + back.add_theme_font_size_override("font_size", 12) + nav.add_child(back) + if not back.has_meta("mobile_host_wired"): + back.set_meta("mobile_host_wired", true) + back.pressed.connect(func(): + if back_cb.is_valid(): + back_cb.call() + elif close_cb.is_valid(): + close_cb.call()) + + var close := nav.get_node_or_null("Close") as Button + if close == null: + close = Button.new() + close.name = "Close" + close.text = "×" + close.add_theme_font_size_override("font_size", 18) + nav.add_child(close) + if not close.has_meta("mobile_host_wired"): + close.set_meta("mobile_host_wired", true) + close.pressed.connect(func(): if close_cb.is_valid(): close_cb.call()) + close.position = Vector2(maxf(78.0, base_size.x - 78.0), 4) + close.size = Vector2(70, 32) + + _wire_buttons(root) + return {"size": base_size, "scale": scale_value, "area": area} + +static func uninstall(root: Control) -> void: + if root == null: + return + var nav := root.get_node_or_null("__mobile_nav") as Control + if nav: + nav.visible = false + root.set_meta("mobile_hosted", false) + +static func wire_buttons(root: Node) -> void: + if root: + _wire_buttons(root) + +static func _wire_buttons(root: Node) -> void: + for node in root.find_children("*", "BaseButton", true, false): + var button := node as BaseButton + if button == null or button.has_meta("mobile_touch_wired"): + continue + button.set_meta("mobile_touch_wired", true) + button.gui_input.connect(func(event: InputEvent): + _handle_button_touch(button, event)) + +static func _handle_button_touch(button: BaseButton, event: InputEvent) -> void: + if button == null or not is_instance_valid(button): + return + if event is InputEventScreenTouch: + var touch := event as InputEventScreenTouch + if touch.pressed: + if int(button.get_meta("mobile_touch_index", -1)) < 0: + button.set_meta("mobile_touch_index", touch.index) + else: + if int(button.get_meta("mobile_touch_index", -1)) == touch.index: + button.set_meta("mobile_touch_index", -1) + if not button.disabled: + button.pressed.emit() + button.accept_event() diff --git a/project/ui/mobile/mobile_window_host.gd.uid b/project/ui/mobile/mobile_window_host.gd.uid new file mode 100644 index 00000000..69a90f50 --- /dev/null +++ b/project/ui/mobile/mobile_window_host.gd.uid @@ -0,0 +1 @@ +uid://cqvgte43jbj2a diff --git a/project/ui/mobile/virtual_joystick.gd b/project/ui/mobile/virtual_joystick.gd new file mode 100644 index 00000000..c5792589 --- /dev/null +++ b/project/ui/mobile/virtual_joystick.gd @@ -0,0 +1,86 @@ +# VirtualJoystick —— continuous left-thumb movement input. +extends Control + +signal axis_changed(axis: Vector2) + +const DEAD_ZONE := 0.12 +const KNOB_RADIUS := 29.0 + +var _touch_index := -1 +var _axis := Vector2.ZERO +var _knob := Vector2.ZERO + +func setup(diameter := 164.0) -> void: + custom_minimum_size = Vector2(diameter, diameter) + size = Vector2(diameter, diameter) + mouse_filter = Control.MOUSE_FILTER_STOP + queue_redraw() + +func axis() -> Vector2: + return _axis + +func _gui_input(event: InputEvent) -> void: + if event is InputEventScreenTouch: + if event.pressed and _touch_index < 0: + _touch_index = event.index + _update_from_position(event.position) + elif not event.pressed and event.index == _touch_index: + _touch_index = -1 + _set_axis(Vector2.ZERO) + accept_event() + return + if event is InputEventScreenDrag and event.index == _touch_index: + _update_from_position(event.position) + accept_event() + return + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT: + if event.pressed: + _touch_index = -2 + _update_from_position(event.position) + else: + _touch_index = -1 + _set_axis(Vector2.ZERO) + accept_event() + return + if event is InputEventMouseMotion and _touch_index == -2: + _update_from_position(event.position) + accept_event() + +func _update_from_position(pos: Vector2) -> void: + var center := size * 0.5 + var radius := maxf(1.0, minf(size.x, size.y) * 0.5 - KNOB_RADIUS - 8.0) + var delta := pos - center + var raw := delta / radius + var magnitude := minf(raw.length(), 1.0) + var direction := raw.normalized() if magnitude > 0.001 else Vector2.ZERO + var output := direction * magnitude + if output.length() < DEAD_ZONE: + output = Vector2.ZERO + else: + output = output.normalized() * ((output.length() - DEAD_ZONE) / (1.0 - DEAD_ZONE)) + _knob = direction * radius * magnitude + _set_axis(output) + queue_redraw() + +func _set_axis(value: Vector2) -> void: + var next := value.limit_length(1.0) + if next.is_equal_approx(_axis): + return + _axis = next + axis_changed.emit(_axis) + queue_redraw() + +func cancel_press() -> void: + _touch_index = -1 + _knob = Vector2.ZERO + _set_axis(Vector2.ZERO) + +func _draw() -> void: + var center := size * 0.5 + var outer := minf(size.x, size.y) * 0.5 - 5.0 + if outer <= 0.0: + return + draw_circle(center, outer, Color(0.025, 0.04, 0.07, 0.38)) + draw_arc(center, outer, 0.0, TAU, 48, Color(0.72, 0.83, 0.96, 0.42), 2.0) + draw_circle(center + _knob, KNOB_RADIUS, Color(0.18, 0.3, 0.48, 0.78)) + draw_arc(center + _knob, KNOB_RADIUS, 0.0, TAU, 32, Color(0.7, 0.85, 1.0, 0.8), 2.0) diff --git a/project/ui/mobile/virtual_joystick.gd.uid b/project/ui/mobile/virtual_joystick.gd.uid new file mode 100644 index 00000000..5847d2d8 --- /dev/null +++ b/project/ui/mobile/virtual_joystick.gd.uid @@ -0,0 +1 @@ +uid://snrdki713in1 diff --git a/project/ui/mouse_controller.gd b/project/ui/mouse_controller.gd index 98c89b8a..3d6ef2cc 100644 --- a/project/ui/mouse_controller.gd +++ b/project/ui/mouse_controller.gd @@ -14,6 +14,8 @@ var _icon: TextureRect var _label: Label var _attached: Dictionary = {} var _targets: Array[Dictionary] = [] +var _touch_index := -1 +var _touch_position := Vector2.ZERO func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) -> void: cursor = cursor_manager @@ -41,11 +43,15 @@ func setup(parent: Node, cursor_manager: Node = null, audio_node: Node = null) - set_process_input(true) func attach_item(window: int, cell: int, vnum: int, count: int, - texture: Texture2D = null, source := "inventory", metadata: Dictionary = {}) -> bool: + texture: Texture2D = null, source := "inventory", metadata: Dictionary = {}, + touch_index := -1, touch_position := Vector2(-1, -1)) -> bool: if vnum <= 0 or window < 0 or cell < 0: return false _attached = {"window": window, "cell": cell, "vnum": vnum, "count": maxi(1, count), "source": source} + _touch_index = touch_index + if touch_position.x >= 0.0 and touch_position.y >= 0.0: + _touch_position = touch_position # Keep the instance fields that the reference mouse module exposes to the # receiving window (anti flags, sockets, attributes, ...), while retaining # the canonical source coordinates supplied by the caller. @@ -70,6 +76,8 @@ func attach_money(amount: int, source := "inventory") -> bool: return false _attached = {"window": -1, "cell": -1, "vnum": -1, "count": amount, "source": source, "money": true} + _touch_index = -1 + _touch_position = Vector2.ZERO if _icon: _icon.texture = null _icon.visible = false @@ -85,6 +93,8 @@ func attach_money(amount: int, source := "inventory") -> bool: func cancel() -> void: _attached.clear() + _touch_index = -1 + _touch_position = Vector2.ZERO if _overlay: _overlay.visible = false if cursor and cursor.has_method("reset"): @@ -108,7 +118,10 @@ func unregister_owner(owner: Node) -> void: func _process(_dt: float) -> void: if _overlay and _overlay.visible: - _overlay.global_position = get_viewport().get_mouse_position() + Vector2(12, 12) + var position := get_viewport().get_mouse_position() + if _touch_index >= 0: + position = _touch_position + _overlay.global_position = position + Vector2(12, 12) # 清理已被窗口销毁的目标,防止拖放回调落到旧 UI。 for i in range(_targets.size() - 1, -1, -1): var c: Control = _targets[i].get("control", null) @@ -129,6 +142,15 @@ func _input(event: InputEvent) -> void: if event is InputEventMouseButton and not event.pressed and event.button_index == MOUSE_BUTTON_LEFT: _drop_at(event.position) get_viewport().set_input_as_handled() + return + if event is InputEventScreenDrag and event.index == _touch_index: + _touch_position = event.position + get_viewport().set_input_as_handled() + return + if event is InputEventScreenTouch and not event.pressed and event.index == _touch_index: + _touch_position = event.position + _drop_at(event.position) + get_viewport().set_input_as_handled() func _drop_at(position: Vector2) -> void: var payload := attached() diff --git a/project/ui/mouse_controller.gd.uid b/project/ui/mouse_controller.gd.uid new file mode 100644 index 00000000..088b4184 --- /dev/null +++ b/project/ui/mouse_controller.gd.uid @@ -0,0 +1 @@ +uid://c7exn3c2tcjfj diff --git a/project/ui/party_ui.gd b/project/ui/party_ui.gd index 577c6058..8a7102f5 100644 --- a/project/ui/party_ui.gd +++ b/project/ui/party_ui.gd @@ -44,6 +44,7 @@ var _dist_btn: Button var _heal_btn: Button var _notice: Label var _role_popup: Control = null +var _mobile_mode := false func setup(m2client: Node, parent: Node, dlg: Node = null) -> void: client = m2client @@ -66,6 +67,15 @@ func close() -> void: if _root: _root.visible = false +# MobileUiRoot owns the compact party strip beneath the player avatar. Keep +# this controller alive for server updates, but hide its desktop window. +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + if enabled: + _dismiss_role_popup() + if _root: + _root.visible = false + # --- 로컬 플레이어가 파티장인가 --- func _local_is_leader() -> bool: if client == null or not client.has_method("get_main_vid"): @@ -86,6 +96,9 @@ func refresh() -> void: _list.remove_child(c) c.queue_free() var members: Array = client.get_party() + if _mobile_mode: + _root.visible = false + return _root.visible = not members.is_empty() if members.is_empty(): return diff --git a/project/ui/private_shop_ui.gd b/project/ui/private_shop_ui.gd index 5dafb715..25ba7826 100644 --- a/project/ui/private_shop_ui.gd +++ b/project/ui/private_shop_ui.gd @@ -55,6 +55,7 @@ var _price_dialog: Control = null var _server_prices: Dictionary = {} # vnum -> last price returned by MyShopPriceList var item_mouse: Node const INVENTORY_WINDOW := 1 +var _mobile_mode := false func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, assets := "") -> void: client = m2client @@ -80,6 +81,15 @@ func setup(m2client: Node, ui_manager: CanvasLayer, proto_node: Node = null, ass func is_open() -> bool: return not _win.is_empty() and is_instance_valid(_win.get("root")) +func get_mobile_window() -> Control: + return _win.get("root") if not _win.is_empty() else null + +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + if is_open(): + _add_mobile_width(_win["root"]) + _refresh() + func toggle() -> void: if is_open(): close() else: open() @@ -91,7 +101,14 @@ func close() -> void: if item_mouse.has_method("is_attached") and item_mouse.is_attached(): item_mouse.cancel() if is_open(): - ui.close(_win["root"]) + var root: Control = _win["root"] + # An external mobile window is owned by UiManager's presentation stack; + # calling ui.close() here would call this method again through its close + # callback. Hide it and let UiManager restore the original parent. + if _mobile_mode and bool(root.get_meta("mobile_external", false)): + root.visible = false + else: + ui.close(root) _win = {} _cells.clear() _stock.clear() @@ -113,6 +130,8 @@ func open() -> void: _index_cells(root) _overlay_name_edit(root) _add_inv_panel(root) + if _mobile_mode and ui and ui.has_method("reflow_mobile_window"): + ui.reflow_mobile_window(root) _wire_buttons(root) _refresh() @@ -132,6 +151,8 @@ func _index_cells(root: Node) -> void: var idx := slot n.gui_input.connect(func(e: InputEvent) -> void: if e is InputEventMouseButton and e.button_index == MOUSE_BUTTON_LEFT and e.pressed: + _on_slot_clicked(idx) + elif e is InputEventScreenTouch and e.pressed: _on_slot_clicked(idx)) # 同时支持从右侧背包候选区拖到空货位;点击选取仍保留给触屏/无鼠标场景。 n.set_drag_forwarding(Callable(self, "_no_drag_data"), @@ -201,6 +222,7 @@ func _add_inv_panel(root: Control) -> void: bg.set_border_width_all(1) panel.add_theme_stylebox_override("panel", bg) root.add_child(panel) + _add_mobile_width(root) var title := Label.new() title.text = "배낭 (클릭 → 집기)" title.position = Vector2(10, 8) @@ -219,6 +241,13 @@ func _add_inv_panel(root: Control) -> void: _status.add_theme_color_override("font_color", Color(1, 0.9, 0.5)) panel.add_child(_status) +func _add_mobile_width(root: Control) -> void: + if not _mobile_mode or root == null: + return + # Keep the candidate list inside the hosted/scaled root instead of leaving + # it as an overflow panel outside the mobile safe area. + root.size.x = maxf(root.size.x, 674.0) + # --- 명칭 -------------------------------------------------------------- func _name_of(vnum: int) -> String: @@ -375,9 +404,16 @@ func _on_slot_clicked(slot: int) -> void: func _ask_price(slot: int) -> void: _dismiss_price_dialog() var dlg := Panel.new() - dlg.set_anchors_preset(Control.PRESET_CENTER) - dlg.position = Vector2(-120, -70) + dlg.set_anchors_preset(Control.PRESET_TOP_LEFT) dlg.size = Vector2(240, 128) + dlg.position = (_win["root"].size - dlg.size) * 0.5 + dlg.z_index = 200 + dlg.mouse_filter = Control.MOUSE_FILTER_STOP + dlg.set_meta("mobile_modal", _mobile_mode) + # UiManager closes mobile modals before the feature window itself. Keep the + # controller's reference in sync when Android back/ESC hides this custom + # panel instead of going through the Cancel button. + dlg.set_meta("mobile_modal_close", Callable(self, "_dismiss_price_dialog")) var sb := StyleBoxFlat.new() sb.bg_color = Color(0.06, 0.06, 0.09, 0.98) sb.border_color = Color(0.7, 0.55, 0.22, 0.9) @@ -387,30 +423,54 @@ func _ask_price(slot: int) -> void: t.text = "판매 가격" t.position = Vector2(12, 10) dlg.add_child(t) - var spin := SpinBox.new() - spin.min_value = 1 - spin.max_value = 2000000000 - spin.step = 1 var default_price := int(_server_prices.get(int(_picked["vnum"]), 1)) if _stock.has(slot): default_price = int(_stock[slot].get("price", default_price)) - spin.value = default_price - spin.position = Vector2(12, 40) - spin.size = Vector2(216, 28) - dlg.add_child(spin) + var price_input: Control + if _mobile_mode: + var edit := LineEdit.new() + edit.name = "PriceInput" + edit.text = str(default_price) + edit.placeholder_text = "输入价格" + edit.max_length = 10 + edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER + edit.position = Vector2(12, 40) + edit.size = Vector2(216, 32) + edit.text_submitted.connect(func(_text: String) -> void: + _place(slot, _parse_price(edit))) + price_input = edit + else: + var spin := SpinBox.new() + spin.min_value = 1 + spin.max_value = 2000000000 + spin.step = 1 + spin.value = default_price + spin.position = Vector2(12, 40) + spin.size = Vector2(216, 28) + price_input = spin + dlg.add_child(price_input) var ok := Button.new() ok.text = "확인" ok.position = Vector2(60, 86) - ok.pressed.connect(func() -> void: _place(slot, int(spin.value))) + ok.pressed.connect(func() -> void: + _place(slot, _parse_price(price_input))) dlg.add_child(ok) var cancel := Button.new() cancel.text = "취소" cancel.position = Vector2(130, 86) cancel.pressed.connect(_dismiss_price_dialog) dlg.add_child(cancel) - _win["root"].get_parent().add_child(dlg) + _win["root"].add_child(dlg) _price_dialog = dlg +func _parse_price(input: Control) -> int: + if input is SpinBox: + return int((input as SpinBox).value) + if input is LineEdit: + var raw := (input as LineEdit).text.strip_edges() + return int(raw) if raw.is_valid_int() else 0 + return 0 + func _dismiss_price_dialog() -> void: if is_instance_valid(_price_dialog): _price_dialog.queue_free() diff --git a/project/ui/quest_log.gd b/project/ui/quest_log.gd index 668dcf9d..42e32650 100644 --- a/project/ui/quest_log.gd +++ b/project/ui/quest_log.gd @@ -42,6 +42,7 @@ var _quest_button_order: Array[int] = [] # 最新在前:BINARY_RecvQuest 的 var _party_shown := false var _party_probe: Callable = Callable() var _screen_size_override := Vector2.ZERO # >0 时覆盖 viewport 尺寸(离线回归用) +var _mobile_mode := false var _clock_remaining: Dictionary = {} var _clock_labels: Dictionary = {} var _clock_accumulator := 0.0 @@ -66,6 +67,9 @@ func set_item_sources(proto_node: Node, item_list: RefCounted, assets_root: Stri func is_open() -> bool: return _root != null and _root.visible +func get_mobile_window() -> Control: + return _root + func close() -> void: if _root: _root.visible = false @@ -120,6 +124,13 @@ func set_party_shown(shown: bool) -> void: _party_shown = shown _refresh_quest_buttons() +# MobileHud supplies one compact task entry at the right side of the +# landscape HUD. Keep the quest-letter data here, but hide the desktop +# absolute-positioned buttons while mobile mode is active. +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + _update_button_visibility() + func set_screen_size_override(size: Vector2) -> void: _screen_size_override = size _refresh_quest_buttons() @@ -425,4 +436,5 @@ func _start_quest(index: int) -> void: func _update_button_visibility() -> void: if _button_root: - _button_root.visible = _quest_buttons_visible and not _quest_buttons_suppressed and not _quest_button_data.is_empty() + _button_root.visible = not _mobile_mode and _quest_buttons_visible \ + and not _quest_buttons_suppressed and not _quest_button_data.is_empty() diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd index 46676ad0..0baadd98 100644 --- a/project/ui/quickbar.gd +++ b/project/ui/quickbar.gd @@ -8,7 +8,9 @@ # # 输入:数字 1..4 -> 本页 0..3,F1..F4 -> 本页 4..7。 # # 技能施放:先发送 M2Client.use_skill(skill_id, target_vid),再同步 -# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(朝向 = 玩家 yaw)。 +# M2Client.cast_skill(motion_idx, heading, x_cm, y_cm)(默认朝向 = 玩家 yaw)。 +# 移动端按住技能按钮时,activate_aimed() 会把拖动方向转换为相同的 +# server heading;校验、冷却和目标选择仍走 activate() 的同一条链路。 # 冷却:本地预测时长由 SkillTable 提供;GC_SKILL_COOLTIME_END 可提前解锁。 extends Node @@ -35,6 +37,7 @@ var _slots := [] # 当前页 UI:[{btn, cd, lbl}] var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] var _page := 0 var _move_from := -1 +var _mobile_mode := false func setup(m2client: Node, skill_table: RefCounted, parent: Node, player_getter: Callable) -> void: client = m2client @@ -84,7 +87,38 @@ func assign(slot: int, kind: String, id: int, persist := true) -> bool: _refresh_slot(slot) return true +# Mobile skill pages cannot rely on drag-and-drop into the small PC quickbar. +# Put a selected skill/item/emote into the first free slot on the current page +# while keeping the same server-backed CG_QUICKSLOT_ADD path. +func assign_mobile(kind: String, id: int) -> bool: + for slot in SLOTS_PER_PAGE: + if _state[_global_slot(slot)].kind == "": + return assign(slot, kind, id) + return false + +func mobile_slot_state(slot: int) -> Dictionary: + if slot < 0 or slot >= SLOTS_PER_PAGE: + return {} + return _state[_global_slot(slot)].duplicate(true) + +func mobile_swap(local_a: int, local_b: int) -> bool: + if local_a < 0 or local_a >= SLOTS_PER_PAGE or local_b < 0 or local_b >= SLOTS_PER_PAGE: + return false + if local_a == local_b: + return true + _swap(local_a, local_b) + return true + func activate(slot: int) -> void: + _activate_slot(slot, Vector2.ZERO) + +## Mobile long-press skill release. `screen_direction` uses screen space: +## right is +X and up is -Y. A zero vector intentionally falls back to the +## player's current facing, so a long press without a drag is still usable. +func activate_aimed(slot: int, screen_direction: Vector2) -> void: + _activate_slot(slot, screen_direction) + +func _activate_slot(slot: int, screen_direction: Vector2) -> void: if slot < 0 or slot >= SLOTS_PER_PAGE: return var global := _global_slot(slot) @@ -101,6 +135,8 @@ func activate(slot: int) -> void: if p: yaw = fposmod(90.0 - rad_to_deg(p.rotation.y), 360.0) xy = Vector2(p.position.x * CM, -p.position.z * CM) + if screen_direction.length() > 0.01: + yaw = _aim_heading(p, screen_direction, yaw) var target_vid := 0 if client.has_method("get_target"): target_vid = int(client.get_target().get("vid", 0)) @@ -159,6 +195,18 @@ func activate(slot: int) -> void: elif s.kind == "emote" and client.has_method("send_emoticon"): client.send_emoticon(s.id) +func _aim_heading(player_node: Node3D, screen_direction: Vector2, fallback: float) -> float: + # Keep the historical POC heading convention as the base and add the + # camera-relative drag angle. This makes an upward drag equal to the + # existing one-tap cast, while right/left drags rotate it predictably. + var angle_from_up := rad_to_deg(atan2(screen_direction.x, -screen_direction.y)) + var camera_heading := player_node.rotation.y + if net_play and "camera" in net_play and net_play.camera \ + and net_play.camera.has_method("heading"): + camera_heading = float(net_play.camera.heading()) + var camera_delta := rad_to_deg(camera_heading - player_node.rotation.y) + return fposmod(fallback + camera_delta + angle_from_up, 360.0) + # net_play 的 MODE_USE_SKILL 预约进射程后回调(use_skill_hook)——slot 为全局快捷栏 # 索引。返回是否确实发起了施法(对齐参考端 `if (__UseSkill(slot)) __ClearReservedAction()`)。 func activate_reserved(global_slot: int) -> bool: @@ -193,6 +241,14 @@ func toggle_visible() -> void: if _root: _root.visible = not _root.visible +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + if _root: + _root.visible = not enabled + +func is_mobile_mode() -> bool: + return _mobile_mode + func is_visible() -> bool: return _root != null and _root.visible diff --git a/project/ui/reconnect_ui.gd b/project/ui/reconnect_ui.gd index ef0a18d1..ea65a9c3 100644 --- a/project/ui/reconnect_ui.gd +++ b/project/ui/reconnect_ui.gd @@ -9,6 +9,8 @@ # 按钮:立即重连 / 停止(不再自动)。 extends CanvasLayer +const MobileWindowHost := preload("res://ui/mobile/mobile_window_host.gd") + const AUTO_DELAY := 5.0 const MAX_TRIES := 5 @@ -20,6 +22,9 @@ var _left := 0.0 var _tries := 0 var _auto := true var _armed := false +var _mobile_mode := false +var _box: VBoxContainer +var _last_viewport_size := Vector2.ZERO func setup(m2client: Node) -> void: client = m2client @@ -31,8 +36,26 @@ func setup(m2client: Node) -> void: client.entered_game.connect(func(): _dismiss()) if client.has_signal("phase_changed"): client.phase_changed.connect(func(p): - if String(p).to_lower() not in ["offline", "", "close"]: + # Keep the reconnect surface visible while auth/game handshaking is in + # progress. Hiding it on the first "login" phase left players with a + # dead-looking world and no retry feedback on mobile. + if not _armed and String(p).to_lower() not in ["offline", "", "close", "failed"]: _dismiss()) + MobileWindowHost.wire_buttons(self) + set_process(true) + +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + _apply_layout() + +func dismiss() -> void: + _dismiss() + +func set_error(reason: String) -> void: + if _msg: + _msg.text = "重连失败:%s" % reason + if _count: + _count.text = "将自动重试" func is_showing() -> bool: return _panel.visible @@ -41,8 +64,10 @@ func _on_disconnected(reason: String) -> void: _msg.text = "连接断开:%s" % reason _panel.visible = true _armed = true + _auto = true _tries = 0 _left = AUTO_DELAY + _apply_layout() func _dismiss() -> void: _panel.visible = false @@ -59,6 +84,7 @@ func _retry_now() -> void: _count.text = "重连失败,%0.0f 秒后再试" % AUTO_DELAY func _process(dt: float) -> void: + _apply_layout() if not _panel.visible or not _armed: return if not _auto: @@ -79,6 +105,7 @@ func _build() -> void: _panel.visible = false add_child(_panel) var box := VBoxContainer.new() + _box = box box.set_anchors_preset(Control.PRESET_CENTER) box.position = Vector2(-160, -70) box.custom_minimum_size = Vector2(320, 0) @@ -98,9 +125,28 @@ func _build() -> void: box.add_child(row) var now_btn := Button.new() now_btn.text = "立即重连" + now_btn.custom_minimum_size = Vector2(150, 46) now_btn.pressed.connect(_retry_now) row.add_child(now_btn) var stop_btn := Button.new() stop_btn.text = "停止" + stop_btn.custom_minimum_size = Vector2(120, 46) stop_btn.pressed.connect(func(): _auto = false) row.add_child(stop_btn) + +func _apply_layout() -> void: + if _panel == null or get_viewport() == null: + return + var viewport_size := get_viewport().get_visible_rect().size + if viewport_size.is_equal_approx(_last_viewport_size) and not _mobile_mode: + return + _last_viewport_size = viewport_size + if not _mobile_mode: + return + var area := MobileWindowHost.safe_rect(get_viewport()) + var width := minf(520.0, maxf(300.0, area.size.x - 32.0)) + _box.position = area.position + Vector2((area.size.x - width) * 0.5, + (area.size.y - 190.0) * 0.5) + _box.size = Vector2(width, 190.0) + _msg.add_theme_font_size_override("font_size", 18) + _count.add_theme_font_size_override("font_size", 15) diff --git a/project/ui/refine_ui.gd b/project/ui/refine_ui.gd index 1f5f070c..45f7a348 100644 --- a/project/ui/refine_ui.gd +++ b/project/ui/refine_ui.gd @@ -13,6 +13,10 @@ var proto: Node var _root: Control var _text: RichTextLabel var _cur := {} +var _mobile_mode := false +var _action_row: HBoxContainer +var _refine_button: Button +var _cancel_button: Button func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: client = m2client @@ -26,6 +30,13 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: func is_open() -> bool: return _root != null and _root.visible +func get_mobile_window() -> Control: + return _root + +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + _apply_mobile_layout() + func close() -> void: if _root: _root.visible = false @@ -65,6 +76,28 @@ func _on_result(ok: bool) -> void: _text.text = "[b]精炼成功[/b]" if ok else "[b]精炼失败[/b]" _root.visible = true +func _apply_mobile_layout() -> void: + if _root == null or _text == null or _action_row == null: + return + if _mobile_mode: + _root.size = Vector2(460, 300) + _root.position = Vector2(-230, -150) + _text.position = Vector2(18, 52) + _text.size = Vector2(424, 178) + _text.custom_minimum_size = Vector2(424, 178) + _action_row.position = Vector2(18, 242) + _refine_button.custom_minimum_size = Vector2(204, 44) + _cancel_button.custom_minimum_size = Vector2(204, 44) + else: + _root.size = Vector2(320, 260) + _root.position = Vector2(-160, -130) + _text.position = Vector2(14, 34) + _text.size = Vector2(292, 160) + _text.custom_minimum_size = Vector2(292, 160) + _action_row.position = Vector2(14, 208) + _refine_button.custom_minimum_size = Vector2(120, 30) + _cancel_button.custom_minimum_size = Vector2(120, 30) + func _build(parent: Node) -> void: _root = Control.new() _root.set_anchors_preset(Control.PRESET_CENTER) @@ -90,17 +123,19 @@ func _build(parent: Node) -> void: _text.custom_minimum_size = Vector2(292, 160) _text.size = Vector2(292, 160) _root.add_child(_text) - var row := HBoxContainer.new() + _action_row = HBoxContainer.new() + var row := _action_row row.position = Vector2(14, 208) row.add_theme_constant_override("separation", 12) _root.add_child(row) - var ok := Button.new() - ok.text = "精炼" - ok.custom_minimum_size = Vector2(120, 30) - ok.pressed.connect(_do_refine) - row.add_child(ok) - var cancel := Button.new() - cancel.text = "取消" - cancel.custom_minimum_size = Vector2(120, 30) - cancel.pressed.connect(func(): _root.visible = false) - row.add_child(cancel) + _refine_button = Button.new() + _refine_button.text = "精炼" + _refine_button.custom_minimum_size = Vector2(120, 30) + _refine_button.pressed.connect(_do_refine) + row.add_child(_refine_button) + _cancel_button = Button.new() + _cancel_button.text = "取消" + _cancel_button.custom_minimum_size = Vector2(120, 30) + _cancel_button.pressed.connect(func(): _root.visible = false) + row.add_child(_cancel_button) + _apply_mobile_layout() diff --git a/project/ui/remote_player_view.gd.uid b/project/ui/remote_player_view.gd.uid new file mode 100644 index 00000000..baabfea2 --- /dev/null +++ b/project/ui/remote_player_view.gd.uid @@ -0,0 +1 @@ +uid://bf22s7b7sym1 diff --git a/project/ui/safebox_ui.gd b/project/ui/safebox_ui.gd index 1b34a57d..44d4c260 100644 --- a/project/ui/safebox_ui.gd +++ b/project/ui/safebox_ui.gd @@ -25,9 +25,15 @@ var _cells: Dictionary = {} var _page_label: Label var _page := 0 var item_mouse: Node +var _mobile_mode := false +var _mobile_inventory_title: Label +var _mobile_inventory_scroll: ScrollContainer +var _mobile_inventory_list: VBoxContainer +var _mobile_deposit_pending := {} const SAFE_PAGE_SLOTS := 45 const SAFE_WINDOW := 3 +const INVENTORY_WINDOW := 1 func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: client = m2client @@ -47,6 +53,14 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void: func is_open() -> bool: return _root != null and _root.visible +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + _apply_mobile_layout() + _refresh_mobile_inventory() + +func get_mobile_window() -> Control: + return _root + func close() -> void: if item_mouse and item_mouse.has_method("unregister_owner"): item_mouse.unregister_owner(self) @@ -55,6 +69,7 @@ func close() -> void: if _root: _root.visible = false _page = 0 + _mobile_deposit_pending.clear() if is_instance_valid(_password_dialog): _password_dialog.queue_free() _password_dialog = null @@ -62,12 +77,22 @@ func close() -> void: func deposit(inv_window: int, inv_cell: int) -> void: if not is_open() or inv_window < 0 or inv_cell < 0: return + if inv_window == INVENTORY_WINDOW and _mobile_deposit_pending.has(inv_cell): + _status.text = "该物品的存入请求已发送" + return var safe_pos := _next_free_slot() if safe_pos < 0: _status.text = "仓库已满" return - if client.has_method("safebox_checkin") and not client.safebox_checkin(safe_pos, inv_window, inv_cell): + if not client.has_method("safebox_checkin"): _status.text = "存入请求发送失败" + return + if not client.safebox_checkin(safe_pos, inv_window, inv_cell): + _status.text = "存入请求发送失败" + return + if _mobile_mode and inv_window == INVENTORY_WINDOW: + _mobile_deposit_pending[inv_cell] = true + _status.text = "已发送存入请求" func _next_free_slot() -> int: var used := {} @@ -114,9 +139,10 @@ func refresh() -> void: var e := Label.new() e.text = "(空)" _list.add_child(e) - return - for it in items: - _list.add_child(_row(it)) + else: + for it in items: + _list.add_child(_row(it)) + _refresh_mobile_inventory() func _render_grid() -> void: if _grid == null: @@ -171,6 +197,71 @@ func _on_grid_input(local_pos: int, event: InputEvent) -> void: item_mouse.attach_item(SAFE_WINDOW, _page * SAFE_PAGE_SLOTS + local_pos, vnum, int(cell.get_meta("count", 1)), null, "safebox") +func _on_mobile_grid_tap(local_pos: int) -> void: + if not _mobile_mode or client == null or not _cells.has(local_pos): + return + var cell: Button = _cells[local_pos] + if int(cell.get_meta("vnum", 0)) == 0 or not client.has_method("safebox_checkout"): + return + var target := _first_free_inv() + if target < 0: + _status.text = "背包已满" + return + if not client.safebox_checkout(_page * SAFE_PAGE_SLOTS + local_pos, 1, target): + _status.text = "取出请求发送失败" + +func _apply_mobile_layout() -> void: + if _root == null: + return + if _mobile_mode: + # The source-bag candidates stay inside the same hosted surface as the + # warehouse grid, so both obey one safe-area scale and touch boundary. + _root.size = Vector2(688, 400) + _root.position = Vector2(-344, -200) + _grid.position = Vector2(12, 54) + if _mobile_inventory_title: + _mobile_inventory_title.visible = true + _mobile_inventory_title.position = Vector2(350, 48) + if _mobile_inventory_scroll: + _mobile_inventory_scroll.visible = true + _mobile_inventory_scroll.position = Vector2(350, 76) + _mobile_inventory_scroll.size = Vector2(326, 292) + else: + _root.size = Vector2(340, 400) + _root.position = Vector2(-170, -200) + _grid.position = Vector2(12, 54) + if _mobile_inventory_title: + _mobile_inventory_title.visible = false + if _mobile_inventory_scroll: + _mobile_inventory_scroll.visible = false + +func _refresh_mobile_inventory() -> void: + if not _mobile_mode or _mobile_inventory_list == null or not is_open(): + return + for child in _mobile_inventory_list.get_children(): + child.queue_free() + var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] + var shown := 0 + for item in inventory: + var cell := int(item.get("cell", -1)) + var vnum := int(item.get("vnum", 0)) + if cell < 0 or vnum <= 0 or _mobile_deposit_pending.has(cell): + continue + var button := Button.new() + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.text = "%s ×%d · 点按存入" % [_name_of(vnum), int(item.get("count", 1))] + button.custom_minimum_size = Vector2(316, 34) + button.tooltip_text = _tooltip_for(item) + var captured_cell := cell + button.pressed.connect(func() -> void: deposit(INVENTORY_WINDOW, captured_cell)) + _mobile_inventory_list.add_child(button) + shown += 1 + if shown == 0: + var empty := Label.new() + empty.text = "(没有可存入物品)" + empty.add_theme_font_size_override("font_size", 12) + _mobile_inventory_list.add_child(empty) + func _change_page(delta: int) -> void: var pages := maxi(1, int(client.get_safebox_size())) _page = clampi(_page + delta, 0, pages - 1) @@ -188,7 +279,8 @@ func _ask_password(kind: String) -> void: edit.secret = true edit.max_length = 6 edit.placeholder_text = "密码" - edit.custom_minimum_size = Vector2(220, 28) + edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER + edit.custom_minimum_size = Vector2(360, 44) if _mobile_mode else Vector2(220, 28) _password_dialog.add_child(edit) _password_dialog.confirmed.connect(func() -> void: var sent: bool = client.safebox_password(edit.text) if kind == "safebox" else client.mall_password(edit.text) @@ -199,8 +291,16 @@ func _ask_password(kind: String) -> void: _password_dialog.canceled.connect(func() -> void: _password_dialog.queue_free() _password_dialog = null) - _root.get_parent().add_child(_password_dialog) - _password_dialog.popup_centered() + if _mobile_mode: + _password_dialog.set_meta("mobile_modal", true) + _password_dialog.set_meta("mobile_title", "商城密码" if kind == "mall" else "仓库密码") + _password_dialog.min_size = Vector2i(460, 230) + _password_dialog.size = Vector2i(460, 230) + _root.add_child(_password_dialog) + if _mobile_mode: + _password_dialog.popup_centered(Vector2i(460, 230)) + else: + _password_dialog.popup_centered() func _row(it: Dictionary) -> Control: var row := HBoxContainer.new() @@ -296,8 +396,24 @@ func _build(parent: Node) -> void: cell.add_theme_font_size_override("font_size", 9) cell.set_meta("safe_pos", pos) cell.gui_input.connect(func(e: InputEvent): _on_grid_input(pos, e)) + cell.pressed.connect(func(): _on_mobile_grid_tap(pos)) if item_mouse and item_mouse.has_method("register_target"): item_mouse.register_target(cell, func(payload: Dictionary): return _drop_to_slot(payload, pos), self) _grid.add_child(cell) _cells[pos] = cell + _mobile_inventory_title = Label.new() + _mobile_inventory_title.text = "背包 · 点按存入" + _mobile_inventory_title.add_theme_font_size_override("font_size", 12) + _mobile_inventory_title.add_theme_color_override("font_color", Color(0.95, 0.84, 0.58)) + _mobile_inventory_title.visible = false + _root.add_child(_mobile_inventory_title) + _mobile_inventory_scroll = ScrollContainer.new() + _mobile_inventory_scroll.name = "MobileInventory" + _mobile_inventory_scroll.visible = false + _mobile_inventory_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _mobile_inventory_list = VBoxContainer.new() + _mobile_inventory_list.add_theme_constant_override("separation", 4) + _mobile_inventory_scroll.add_child(_mobile_inventory_list) + _root.add_child(_mobile_inventory_scroll) + _apply_mobile_layout() diff --git a/project/ui/shop_ui.gd b/project/ui/shop_ui.gd index 970be037..57f6a834 100644 --- a/project/ui/shop_ui.gd +++ b/project/ui/shop_ui.gd @@ -34,6 +34,9 @@ var _sell_mode_button: Button var _sell_drop_target: Button var _sell_confirm: ConfirmationDialog var _buy_confirm: ConfirmationDialog +var _mobile_mode := false +var _mobile_sell_scroll: ScrollContainer +var _mobile_sell_list: VBoxContainer func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCounted = null) -> void: client = m2client @@ -52,6 +55,14 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null, ilist: RefCoun func is_open() -> bool: return _root != null and _root.visible +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + if _root and _mobile_mode: + _set_mode(_mode) + +func get_mobile_window() -> Control: + return _root + func close() -> void: _close() @@ -181,11 +192,15 @@ func _ask_sell_confirmation(payload: Dictionary, amount: int, price: int) -> voi _sell_confirm = null dialog.hide() dialog.queue_free()) + _prepare_mobile_confirmation(dialog, "确认出售") _root.add_child(dialog) if dialog.is_inside_tree(): - dialog.popup_centered() + _popup_confirmation(dialog) else: - dialog.call_deferred("popup_centered") + if _mobile_mode: + dialog.call_deferred("popup_centered", Vector2i(480, 220)) + else: + dialog.call_deferred("popup_centered") func _play_ui(name: String) -> void: if audio and audio.has_method("play_ui"): @@ -201,6 +216,10 @@ func _set_mode(mode: int) -> void: _sell_drop_target.visible = _mode == 2 if cursor_manager and cursor_manager.has_method("set_cursor"): cursor_manager.set_cursor(CursorManager.SELL if _mode == 2 else CursorManager.BUY) + if _mobile_sell_scroll: + _mobile_sell_scroll.visible = _mobile_mode and _mode == 2 + _grid.visible = not (_mobile_mode and _mode == 2) + _refresh_mobile_sell() func _name_of(vnum: int) -> String: if proto and proto.has_method("item"): @@ -256,6 +275,30 @@ func refresh() -> void: for slot in SHOP_SLOT_COUNT: var it: Dictionary = by_pos.get(slot, {}) _grid.add_child(_slot(it, base + slot)) + _refresh_mobile_sell() + +func _refresh_mobile_sell() -> void: + if not _mobile_mode or _mobile_sell_list == null: + return + for child in _mobile_sell_list.get_children(): + child.queue_free() + var inv: Array = client.get_inventory() if client and client.has_method("get_inventory") else [] + if inv.is_empty(): + var empty := Label.new() + empty.text = "(背包为空)" + _mobile_sell_list.add_child(empty) + return + for item in inv: + var cell := int(item.get("cell", -1)) + var vnum := int(item.get("vnum", 0)) + if cell < 0 or vnum <= 0: + continue + var button := Button.new() + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.text = "%s ×%d · 点击出售" % [_name_of(vnum), int(item.get("count", 1))] + button.custom_minimum_size = Vector2(330, 30) + button.pressed.connect(func(): sell(cell, int(_sell_quantity.value))) + _mobile_sell_list.add_child(button) func _slot(it: Dictionary, pos: int) -> Button: var slot := Button.new() @@ -301,11 +344,29 @@ func _buy_slot(pos: int, item: Dictionary) -> void: _buy_confirm = null dialog.hide() dialog.queue_free()) + _prepare_mobile_confirmation(dialog, "确认购买") _root.add_child(dialog) if dialog.is_inside_tree(): - dialog.popup_centered() + _popup_confirmation(dialog) else: - dialog.call_deferred("popup_centered") + if _mobile_mode: + dialog.call_deferred("popup_centered", Vector2i(480, 220)) + else: + dialog.call_deferred("popup_centered") + +func _prepare_mobile_confirmation(dialog: ConfirmationDialog, title: String) -> void: + if not _mobile_mode: + return + dialog.set_meta("mobile_modal", true) + dialog.set_meta("mobile_title", title) + dialog.min_size = Vector2i(480, 220) + dialog.size = Vector2i(480, 220) + +func _popup_confirmation(dialog: ConfirmationDialog) -> void: + if _mobile_mode: + dialog.popup_centered(Vector2i(480, 220)) + else: + dialog.popup_centered() func _on_error(kind: String) -> void: var tbl := { @@ -395,6 +456,14 @@ func _build(parent: Node) -> void: _grid.add_theme_constant_override("h_separation", 3) _grid.add_theme_constant_override("v_separation", 2) _root.add_child(_grid) + _mobile_sell_scroll = ScrollContainer.new() + _mobile_sell_scroll.position = Vector2(12, 58) + _mobile_sell_scroll.size = Vector2(350, 252) + _mobile_sell_scroll.visible = false + _root.add_child(_mobile_sell_scroll) + _mobile_sell_list = VBoxContainer.new() + _mobile_sell_list.add_theme_constant_override("separation", 4) + _mobile_sell_scroll.add_child(_mobile_sell_list) var close_btn := Button.new() close_btn.text = "离开" close_btn.position = Vector2(300, 360) diff --git a/project/ui/skill_ui.gd b/project/ui/skill_ui.gd index 9a0acb8b..b4ee9bd1 100644 --- a/project/ui/skill_ui.gd +++ b/project/ui/skill_ui.gd @@ -14,6 +14,7 @@ # 辅助页 HIDE_SUPPORT_SKILL_POINT 整页隐藏。升级结果仍由 GC_SKILL_LEVEL 回包驱动。 extends Node +const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") const TABS := ["主动", "辅助", "坐骑"] const MASTER_SUFFIX := ["", " M", " G", " P"] # master_type 0..3 @@ -28,6 +29,11 @@ var ui: CanvasLayer # UiManager var job := "WARRIOR" var drag_skill_id := 0 var _tab := 0 +var _mobile_mode := false +var _mobile_quickbar: Node +var _mobile_hint: Label +var _mobile_drop_slots: Array[MobileTouchButton] = [] +var _mobile_drag_quickbar_slot := -1 var _win: Control var _rows := {} # skill_id -> {lv, up} @@ -44,6 +50,15 @@ func set_job(j: String) -> void: if is_open(): _rebuild() +func set_mobile_mode(enabled: bool) -> void: + _mobile_mode = enabled + if _mobile_mode and _win: + close() + +func set_mobile_quickbar(quickbar: Node) -> void: + _mobile_quickbar = quickbar + _refresh_mobile_quickbar_targets() + func is_open() -> bool: return _win != null and is_instance_valid(_win) @@ -56,6 +71,8 @@ func close() -> void: ui.close(_win) _win = null _rows.clear() + _mobile_drop_slots.clear() + _mobile_drag_quickbar_slot = -1 func _cat_key() -> String: match _tab: @@ -67,9 +84,16 @@ func open() -> void: if is_open() or table == null: return _win = Control.new() - _win.set_anchors_preset(Control.PRESET_CENTER_RIGHT) - _win.position = Vector2(-300, -240) - _win.size = Vector2(290, 470) + _win.name = "MobileSkillWindow" if _mobile_mode else "SkillWindow" + if _mobile_mode: + _win.set_anchors_preset(Control.PRESET_TOP_LEFT) + _win.position = Vector2.ZERO + _win.size = Vector2(760, 340) + _win.set_meta("mobile_title", "技能") + else: + _win.set_anchors_preset(Control.PRESET_CENTER_RIGHT) + _win.position = Vector2(-300, -240) + _win.size = Vector2(290, 470) _win.set_meta("is_titlebar", true) var panel := Panel.new() panel.set_anchors_preset(Control.PRESET_FULL_RECT) @@ -80,27 +104,54 @@ func open() -> void: _win.add_child(panel) var title := Label.new() title.text = "技能" - title.position = Vector2(12, 8) + title.position = Vector2(12, 8) if not _mobile_mode else Vector2(14, 48) + title.visible = not _mobile_mode + if _mobile_mode: + title.add_theme_font_size_override("font_size", 15) _win.add_child(title) # 分类页签 var tabrow := HBoxContainer.new() tabrow.name = "tabs" - tabrow.position = Vector2(10, 30) + tabrow.position = Vector2(10, 30) if not _mobile_mode else Vector2(14, 48) + if _mobile_mode: + tabrow.size = Vector2(360, 40) + tabrow.add_theme_constant_override("separation", 6) _win.add_child(tabrow) for i in TABS.size(): var b := Button.new() b.text = TABS[i] b.toggle_mode = true b.button_pressed = (i == _tab) + if _mobile_mode: + b.custom_minimum_size = Vector2(110, 38) + b.add_theme_font_size_override("font_size", 12) var ti: int = i b.pressed.connect(func(): _switch_tab(ti)) tabrow.add_child(b) var vb := VBoxContainer.new() vb.name = "list" - vb.position = Vector2(10, 60) - vb.custom_minimum_size = Vector2(270, 0) + vb.position = Vector2(10, 60) if not _mobile_mode else Vector2(0, 0) + vb.custom_minimum_size = Vector2(270, 0) if not _mobile_mode else Vector2(720, 0) vb.add_theme_constant_override("separation", 2) - _win.add_child(vb) + if _mobile_mode: + var scroll := ScrollContainer.new() + scroll.name = "SkillScroll" + scroll.position = Vector2(14, 92) + scroll.size = Vector2(732, 214) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + scroll.mouse_filter = Control.MOUSE_FILTER_STOP + scroll.add_child(vb) + _win.add_child(scroll) + _mobile_hint = Label.new() + _mobile_hint.position = Vector2(14, 310) + _mobile_hint.size = Vector2(720, 22) + _mobile_hint.add_theme_font_size_override("font_size", 10) + _mobile_hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84)) + _mobile_hint.text = "可用点数与技能等级由服务器实时确认" + _win.add_child(_mobile_hint) + _build_mobile_quickbar_targets() + else: + _win.add_child(vb) ui.open(_win) _rebuild() @@ -113,7 +164,11 @@ func _switch_tab(i: int) -> void: _rebuild() func _rebuild() -> void: - var vb: VBoxContainer = _win.get_node("list") + var vb: VBoxContainer = _win.get_node_or_null("list") as VBoxContainer + if vb == null and _mobile_mode: + vb = _win.get_node_or_null("SkillScroll/list") as VBoxContainer + if vb == null: + return for c in vb.get_children(): c.queue_free() _rows.clear() @@ -125,19 +180,39 @@ func _rebuild() -> void: return for id in ids: var row := HBoxContainer.new() - row.custom_minimum_size = Vector2(268, 26) - var nm := Label.new() - nm.text = table.name_of(id) - nm.custom_minimum_size = Vector2(140, 0) - nm.add_theme_font_size_override("font_size", 12) + row.custom_minimum_size = Vector2(720, 46) if _mobile_mode else Vector2(268, 26) + var nm: Control + if _mobile_mode: + var skill_name := MobileTouchButton.new() + skill_name.setup(table.name_of(id), Color(0.34, 0.58, 0.88, 0.88)) + skill_name.set_rect_style() + skill_name.set_gesture_enabled(true) + skill_name.custom_minimum_size = Vector2(300, 42) + skill_name.size = Vector2(300, 42) + var skill_button := skill_name + skill_button.long_pressed.connect(func(): _mobile_begin_skill_drag(id, skill_button)) + skill_button.drag_changed.connect(func(_delta: Vector2): + _mobile_update_skill_drag(id, skill_button)) + skill_button.drag_released.connect(func(_delta: Vector2): + _mobile_end_skill_drag(id, skill_button)) + nm = skill_name + else: + var skill_label := Label.new() + skill_label.text = table.name_of(id) + skill_label.custom_minimum_size = Vector2(140, 0) + skill_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + skill_label.add_theme_font_size_override("font_size", 12) + nm = skill_label row.add_child(nm) var lv := Label.new() lv.name = "lv" - lv.custom_minimum_size = Vector2(58, 0) - lv.add_theme_font_size_override("font_size", 12) + lv.custom_minimum_size = Vector2(100, 42) if _mobile_mode else Vector2(58, 0) + lv.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + lv.add_theme_font_size_override("font_size", 13 if _mobile_mode else 12) row.add_child(lv) var tag := Label.new() - tag.custom_minimum_size = Vector2(40, 0) + tag.custom_minimum_size = Vector2(76, 42) if _mobile_mode else Vector2(40, 0) + tag.vertical_alignment = VERTICAL_ALIGNMENT_CENTER tag.add_theme_font_size_override("font_size", 10) if table.is_passive(id): tag.text = "被动" @@ -150,17 +225,138 @@ func _rebuild() -> void: if table.can_level_up(id) and not table.is_passive(id): var up := Button.new() up.text = "+" - up.custom_minimum_size = Vector2(28, 22) + up.custom_minimum_size = Vector2(48, 42) if _mobile_mode else Vector2(28, 22) + if _mobile_mode: + up.add_theme_font_size_override("font_size", 18) up.pressed.connect(func(): client.skill_up(sid)) row.add_child(up) _rows[id] = {"lv": lv, "up": up} else: _rows[id] = {"lv": lv, "up": null} - nm.gui_input.connect(func(e: InputEvent): - if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: - drag_skill_id = sid) + if not _mobile_mode: + nm.gui_input.connect(func(e: InputEvent): + if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: + drag_skill_id = sid + if _mobile_hint: _mobile_hint.text = "已选择:%s;点击快捷栏按钮自动放入空位" % table.name_of(sid) + nm.accept_event()) + if _mobile_mode: + var assign := Button.new() + assign.text = "快捷栏" + assign.custom_minimum_size = Vector2(92, 42) + assign.add_theme_font_size_override("font_size", 11) + assign.pressed.connect(func(): _mobile_assign_skill(sid)) + row.add_child(assign) vb.add_child(row) refresh() + _refresh_mobile_quickbar_targets() + +func _build_mobile_quickbar_targets() -> void: + var title := Label.new() + title.text = "快捷栏" + title.position = Vector2(390, 48) + title.size = Vector2(60, 38) + title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + title.add_theme_font_size_override("font_size", 12) + title.add_theme_color_override("font_color", Color(0.95, 0.82, 0.54)) + title.mouse_filter = Control.MOUSE_FILTER_IGNORE + _win.add_child(title) + for i in 8: + var target := MobileTouchButton.new() + target.setup("—", Color(0.42, 0.65, 0.92, 0.9)) + target.set_rect_style() + target.set_gesture_enabled(true) + target.position = Vector2(448 + i * 38, 48) + target.size = Vector2(34, 38) + target.custom_minimum_size = Vector2(34, 38) + var slot := i + target.pressed.connect(func(): _mobile_drop_on_quickbar(slot)) + target.long_pressed.connect(func(): _mobile_begin_quickbar_drag(slot)) + target.drag_released.connect(func(_delta: Vector2): _mobile_end_quickbar_drag(slot, target)) + _win.add_child(target) + _mobile_drop_slots.append(target) + +func _mobile_begin_skill_drag(skill_id: int, _button: MobileTouchButton) -> void: + drag_skill_id = skill_id + if _mobile_hint: + _mobile_hint.text = "拖到上方快捷栏槽位,松手放入" + +func _mobile_update_skill_drag(skill_id: int, _button: MobileTouchButton) -> void: + if drag_skill_id == skill_id and _mobile_hint: + _mobile_hint.text = "拖到上方快捷栏槽位,松手放入" + +func _mobile_end_skill_drag(skill_id: int, button: MobileTouchButton) -> void: + if drag_skill_id != skill_id: + return + var target := _mobile_drop_slot_at(button.drag_end_position()) + if target >= 0 and _mobile_quickbar and _mobile_quickbar.has_method("assign"): + if bool(_mobile_quickbar.assign(target, "skill", skill_id)): + drag_skill_id = 0 + _refresh_mobile_quickbar_targets() + if _mobile_hint: _mobile_hint.text = "已放入快捷栏第 %d 格" % (target + 1) + return + if _mobile_hint: + _mobile_hint.text = "已选择:%s;可拖到快捷栏" % table.name_of(skill_id) + +func _mobile_drop_on_quickbar(slot: int) -> void: + if _mobile_quickbar == null or not _mobile_quickbar.has_method("assign"): + return + if drag_skill_id != 0: + if bool(_mobile_quickbar.assign(slot, "skill", drag_skill_id)): + drag_skill_id = 0 + _refresh_mobile_quickbar_targets() + if _mobile_hint: _mobile_hint.text = "已放入快捷栏第 %d 格" % (slot + 1) + +func _mobile_begin_quickbar_drag(slot: int) -> void: + _mobile_drag_quickbar_slot = slot + if _mobile_hint: + _mobile_hint.text = "拖到另一个快捷栏槽位以交换" + +func _mobile_end_quickbar_drag(slot: int, button: MobileTouchButton) -> void: + if _mobile_drag_quickbar_slot != slot: + return + var target := _mobile_drop_slot_at(button.drag_end_position()) + if target >= 0 and target != slot and _mobile_quickbar \ + and _mobile_quickbar.has_method("mobile_swap"): + _mobile_quickbar.mobile_swap(slot, target) + _refresh_mobile_quickbar_targets() + if _mobile_hint: _mobile_hint.text = "已交换快捷栏位置" + _mobile_drag_quickbar_slot = -1 + +func _mobile_drop_slot_at(position: Vector2) -> int: + for i in _mobile_drop_slots.size(): + var slot := _mobile_drop_slots[i] + if slot and slot.get_global_rect().has_point(position): + return i + return -1 + +func _refresh_mobile_quickbar_targets() -> void: + if not _mobile_mode: + return + for i in _mobile_drop_slots.size(): + var slot := _mobile_drop_slots[i] + if slot == null or _mobile_quickbar == null: + continue + var state: Dictionary = _mobile_quickbar.mobile_slot_state(i) \ + if _mobile_quickbar.has_method("mobile_slot_state") else {} + var label := "—" + if state.get("kind", "") == "skill" and table: + label = table.name_of(int(state.get("id", 0))).substr(0, 4) + elif state.get("kind", "") == "item": + label = "物" + elif state.get("kind", "") == "emote": + label = "表" + slot.set_caption(label) + +func _mobile_assign_skill(skill_id: int) -> void: + if _mobile_quickbar == null or not _mobile_quickbar.has_method("assign_mobile"): + if _mobile_hint: _mobile_hint.text = "快捷栏暂不可用" + return + if bool(_mobile_quickbar.assign_mobile("skill", skill_id)): + drag_skill_id = 0 + _refresh_mobile_quickbar_targets() + if _mobile_hint: _mobile_hint.text = "已放入快捷栏" + else: + if _mobile_hint: _mobile_hint.text = "快捷栏已满,请先移除一个技能" func refresh() -> void: if not is_open(): diff --git a/project/ui/system_menu_ui.gd b/project/ui/system_menu_ui.gd index a335364f..0d92b1ec 100644 --- a/project/ui/system_menu_ui.gd +++ b/project/ui/system_menu_ui.gd @@ -35,6 +35,11 @@ var help_ui: Node # HelpUI(§8.3)—— 未注入时 _ensur var toast: Callable = Callable() # 可选:func(msg: String) 显示提示 var _win: Dictionary = {} +var _mobile_mode := false +var _mobile_root: Control +var _mobile_buttons: Dictionary = {} + +const MOBILE_SIZE := Vector2(700, 330) func setup(ui_manager: CanvasLayer, m2client: Node, assets := "", sys_opt: Node = null, game_opt: Node = null, help_win: Node = null) -> void: @@ -51,18 +56,37 @@ func setup(ui_manager: CanvasLayer, m2client: Node, assets := "", uiscript_dir = assets_root.path_join("uiscript") func is_open() -> bool: + if _mobile_mode: + return _mobile_root != null and is_instance_valid(_mobile_root) return not _win.is_empty() and is_instance_valid(_win.get("root")) +func set_mobile_mode(enabled: bool) -> void: + if enabled != _mobile_mode and is_open(): + close() + _mobile_mode = enabled + if is_instance_valid(help_ui) and help_ui.has_method("set_mobile_mode"): + help_ui.set_mobile_mode(enabled) + func toggle() -> void: if is_open(): close() else: open() func close() -> void: + if _mobile_mode: + var root := _mobile_root + _mobile_root = null + _mobile_buttons.clear() + if root and is_instance_valid(root) and ui: + ui.close(root) + return if is_open(): ui.close(_win["root"]) _win = {} func open() -> void: + if _mobile_mode: + _open_mobile() + return if is_open(): return var path := uiscript_dir.path_join("systemdialog.py") @@ -75,6 +99,97 @@ func open() -> void: _relabel() _wire() +func _open_mobile() -> void: + if is_open(): + return + _mobile_root = Control.new() + _mobile_root.name = "MobileSystemMenuWindow" + _mobile_root.size = MOBILE_SIZE + _mobile_root.set_meta("mobile_title", "系统菜单") + _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP + var panel := Panel.new() + panel.set_anchors_preset(Control.PRESET_FULL_RECT) + panel.add_theme_stylebox_override("panel", _mobile_panel_style()) + _mobile_root.add_child(panel) + var hint := Label.new() + hint.text = "账户、设置和帮助" + hint.position = Vector2(22, 52) + hint.size = Vector2(640, 24) + hint.add_theme_font_size_override("font_size", 13) + hint.add_theme_color_override("font_color", Color(0.68, 0.78, 0.92)) + _mobile_root.add_child(hint) + var grid := GridContainer.new() + grid.name = "MobileSystemActions" + grid.columns = 2 + grid.position = Vector2(22, 82) + grid.size = Vector2(656, 174) + grid.add_theme_constant_override("h_separation", 12) + grid.add_theme_constant_override("v_separation", 8) + _mobile_root.add_child(grid) + for action in ["帮助", "商城", "系统设置", "游戏设置", "选择角色", "登出"]: + var button := Button.new() + button.name = "MobileAction_%s" % action + button.text = String(action) + button.custom_minimum_size = Vector2(322, 48) + button.add_theme_font_size_override("font_size", 14) + var selected := String(action) + button.pressed.connect(func(): _mobile_action(selected)) + _mobile_buttons[selected] = button + grid.add_child(button) + var exit := Button.new() + exit.name = "MobileExitButton" + exit.text = "退出游戏" + exit.position = Vector2(22, 272) + exit.size = Vector2(154, 42) + exit.add_theme_font_size_override("font_size", 13) + exit.pressed.connect(func(): _mobile_action("退出游戏")) + _mobile_buttons["退出游戏"] = exit + _mobile_root.add_child(exit) + var cancel := Button.new() + cancel.name = "MobileCancelButton" + cancel.text = "取消" + cancel.position = Vector2(524, 272) + cancel.size = Vector2(154, 42) + cancel.add_theme_font_size_override("font_size", 13) + cancel.pressed.connect(close) + _mobile_buttons["取消"] = cancel + _mobile_root.add_child(cancel) + ui.open(_mobile_root, true) + +func _mobile_action(action: String) -> void: + match action: + "帮助": + close() + open_help() + "商城": + _send_and_close("/in_game_mall") + "系统设置": + close() + if system_option_ui and system_option_ui.has_method("open"): + system_option_ui.open() + "游戏设置": + close() + if game_option_ui and game_option_ui.has_method("open"): + game_option_ui.open() + "选择角色": _send_and_close("/phase_select") + "登出": _send_and_close("/logout") + "退出游戏": get_tree().quit() + +func _send_and_close(command: String) -> void: + if client and client.has_method("say"): + client.say(0, command) + close() + +func _mobile_panel_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.04, 0.065, 0.1, 0.98) + style.border_color = Color(0.52, 0.68, 0.9, 0.86) + style.set_border_width_all(1) + style.set_corner_radius_all(10) + style.shadow_color = Color(0, 0, 0, 0.45) + style.shadow_size = 12 + return style + func _node(nm: String) -> Control: if _win.is_empty(): return null @@ -99,6 +214,8 @@ func _ensure_help() -> Node: help_ui = HelpUI.new() add_child(help_ui) help_ui.setup(ui, client, assets_root) + if help_ui.has_method("set_mobile_mode"): + help_ui.set_mobile_mode(_mobile_mode) return help_ui func open_help() -> void: @@ -112,9 +229,7 @@ func _wire() -> void: close() open_help()) _btn("mall_button", func(): - if client and client.has_method("say"): - client.say(0, "/in_game_mall") # net.SendChatPacket("/in_game_mall") - close()) + _send_and_close("/in_game_mall")) _btn("system_option_button", func(): close() if system_option_ui and system_option_ui.has_method("open"): @@ -124,13 +239,9 @@ func _wire() -> void: if game_option_ui and game_option_ui.has_method("open"): game_option_ui.open()) _btn("change_button", func(): - if client and client.has_method("say"): - client.say(0, "/phase_select") # net.ExitGame() - close()) + _send_and_close("/phase_select")) _btn("logout_button", func(): - if client and client.has_method("say"): - client.say(0, "/logout") # net.LogOutGame() - close()) + _send_and_close("/logout")) _btn("exit_button", func(): get_tree().quit()) _btn("cancel_button", close) var board := _node("board") diff --git a/project/ui/system_option_ui.gd b/project/ui/system_option_ui.gd index 28104248..019e728d 100644 --- a/project/ui/system_option_ui.gd +++ b/project/ui/system_option_ui.gd @@ -15,6 +15,7 @@ # 设置持久化到 `user://system_option.cfg`(= 原 systemSetting 的配置文件)。 extends Node +const MobileTouchButton = preload("res://ui/mobile/mobile_touch_button.gd") const CFG_PATH := "user://system_option.cfg" const CAMERA_MAX := [11.0, 20.0] # 近 / 远(game_camera.max_dist) const FOG_DENSITY := [0.055, 0.018, 0.004] # 浓 / 中 / 淡(Environment.fog_density) @@ -37,6 +38,8 @@ var _env_get: Callable = Callable() var _win: Dictionary = {} var _cfg := ConfigFile.new() +var _mobile_mode := false +var _mobile_root: Control # --- 状态(= systemSetting 值)--- var music_volume := 0.6 @@ -72,18 +75,36 @@ func setup(ui_manager: CanvasLayer, assets: String, audio_node: Node = null, # --- open / close --------------------------------------------------- func is_open() -> bool: + if _mobile_mode: + return _mobile_root != null and is_instance_valid(_mobile_root) return not _win.is_empty() and is_instance_valid(_win.get("root")) +func set_mobile_mode(enabled: bool) -> void: + if not enabled and _mobile_root != null: + var old_mode := _mobile_mode + _mobile_mode = true + close() + _mobile_mode = old_mode + _mobile_mode = enabled + func toggle() -> void: if is_open(): close() else: open() func close() -> void: + if _mobile_mode: + if _mobile_root and is_instance_valid(_mobile_root) and ui: + ui.close(_mobile_root) + _mobile_root = null + return if is_open(): ui.close(_win["root"]) _win = {} func open() -> void: + if _mobile_mode: + _open_mobile() + return if is_open(): return var path := uiscript_dir.path_join("systemoptiondialog.py") @@ -97,6 +118,91 @@ func open() -> void: _wire() _sync_controls() +func _open_mobile() -> void: + if is_open(): + return + _mobile_root = Control.new() + _mobile_root.name = "MobileSystemOptionWindow" + _mobile_root.size = Vector2(680, 340) + _mobile_root.set_meta("mobile_title", "系统设置") + _mobile_root.mouse_filter = Control.MOUSE_FILTER_STOP + var panel := Panel.new() + panel.set_anchors_preset(Control.PRESET_FULL_RECT) + panel.add_theme_stylebox_override("panel", _mobile_panel_style()) + _mobile_root.add_child(panel) + var content := VBoxContainer.new() + content.position = Vector2(18, 50) + content.size = Vector2(644, 270) + content.add_theme_constant_override("separation", 8) + _mobile_root.add_child(content) + _add_mobile_slider(content, "背景音乐", music_volume, func(v: float): _on_music_volume(v)) + _add_mobile_slider(content, "音效", sound_volume, func(v: float): _on_sound_volume(v)) + _add_mobile_radio(content, "镜头距离", ["近", "远"], camera_mode, + func(i: int): camera_mode = i; _apply_camera(); _save()) + _add_mobile_radio(content, "雾", ["浓", "中", "淡"], fog_level, + func(i: int): fog_level = i; _apply_fog(); _save()) + _add_mobile_radio(content, "图形内存", ["CPU", "GPU"], tiling_mode, + func(i: int): tiling_mode = i; _save()) + var hint := Label.new() + hint.text = "设置会自动保存;图形内存选项将在下次启动时使用" + hint.add_theme_font_size_override("font_size", 11) + hint.add_theme_color_override("font_color", Color(0.62, 0.72, 0.84)) + content.add_child(hint) + ui.open(_mobile_root) + +func _add_mobile_slider(parent: VBoxContainer, caption: String, initial: float, changed: Callable) -> void: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(640, 42) + var label := Label.new() + label.text = caption + label.custom_minimum_size = Vector2(92, 38) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 13) + row.add_child(label) + var slider := HSlider.new() + slider.min_value = 0.0 + slider.max_value = 1.0 + slider.step = 0.01 + slider.value = initial + slider.size_flags_horizontal = Control.SIZE_EXPAND_FILL + slider.custom_minimum_size = Vector2(500, 38) + slider.value_changed.connect(changed) + row.add_child(slider) + parent.add_child(row) + +func _add_mobile_radio(parent: VBoxContainer, caption: String, choices: Array, active: int, picked: Callable) -> void: + var row := HBoxContainer.new() + row.custom_minimum_size = Vector2(640, 44) + var label := Label.new() + label.text = caption + label.custom_minimum_size = Vector2(92, 40) + label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 13) + row.add_child(label) + var buttons: Array[Button] = [] + for i in choices.size(): + var button := Button.new() + button.text = String(choices[i]) + button.toggle_mode = true + button.button_pressed = i == active + button.custom_minimum_size = Vector2(100, 40) + var idx := i + button.pressed.connect(func(): + for other in buttons.size(): + buttons[other].set_pressed_no_signal(other == idx) + picked.call(idx)) + buttons.append(button) + row.add_child(button) + parent.add_child(row) + +func _mobile_panel_style() -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = Color(0.04, 0.065, 0.1, 0.98) + style.border_color = Color(0.52, 0.68, 0.9, 0.86) + style.set_border_width_all(1) + style.set_corner_radius_all(8) + return style + func _node(nm: String) -> Control: if _win.is_empty(): return null diff --git a/project/ui/ui_manager.gd b/project/ui/ui_manager.gd index bc7af369..07db0c9c 100644 --- a/project/ui/ui_manager.gd +++ b/project/ui/ui_manager.gd @@ -11,16 +11,25 @@ extends CanvasLayer const UiScript = preload("res://ui/uiscript.gd") const UiBuild = preload("res://ui/ui_build.gd") +const MobileWindowHost = preload("res://ui/mobile/mobile_window_host.gd") signal window_opened(root: Control) signal window_closed(root: Control) var locale: Callable var screen := Vector2i(1920, 1080) +var mobile_mode := false +var mobile_screen := Vector2i(844, 390) var _root: Control # 全屏容器 var _dim: ColorRect # modal 变暗层 +var _mobile_blocker: ColorRect var _stack: Array[Control] = [] +var _mobile_external: Array[Dictionary] = [] +var _mobile_modals: Array[Node] = [] +var _mobile_saved: Dictionary = {} +var _mobile_close_callbacks: Dictionary = {} +var _last_mobile_area := Rect2(-1, -1, 0, 0) var _drag_win: Control = null var _drag_from := Vector2.ZERO @@ -35,8 +44,69 @@ func _ready() -> void: _dim.set_anchors_preset(Control.PRESET_FULL_RECT) _dim.visible = false _root.add_child(_dim) + _mobile_blocker = ColorRect.new() + _mobile_blocker.name = "MobileWindowBlocker" + _mobile_blocker.color = Color(0, 0, 0, 0) + _mobile_blocker.set_anchors_preset(Control.PRESET_FULL_RECT) + _mobile_blocker.mouse_filter = Control.MOUSE_FILTER_STOP + _mobile_blocker.visible = false + _root.add_child(_mobile_blocker) set_process_input(true) set_process_unhandled_input(true) + set_process(true) + +## Select the presentation policy without changing any feature controller. +## Mobile windows are rebuilt with the landscape design resolution and hosted +## inside the safe area; desktop keeps the historical 1920x1080 parser space. +func set_mobile_mode(enabled: bool) -> void: + mobile_mode = enabled + if enabled: + screen = mobile_screen + layer = 30 + _last_mobile_area = Rect2(-1, -1, 0, 0) + else: + screen = Vector2i(1920, 1080) + layer = 10 + _last_mobile_area = Rect2(-1, -1, 0, 0) + if not enabled: + for record in _mobile_external.duplicate(): + var root: Control = record.get("root") + if root and is_instance_valid(root): + _dismiss_external(root) + _restack() + +func track_mobile_window(root: Control, title := "", close_cb := Callable()) -> void: + if root == null: + return + for record in _mobile_external: + if record.get("root") == root: + record["title"] = title + record["close_cb"] = close_cb + return + _mobile_external.append({"root": root, "title": title, "close_cb": close_cb}) + if mobile_mode and root.visible: + _sync_mobile_windows() + +## Register a native Window/ConfirmationDialog that is created outside a +## feature's hosted Control. It remains owned by the feature, but the mobile +## manager can block world input and make Android back press its cancel action +## first. This avoids moving native dialogs into a Control tree. +func track_mobile_modal(popup: Node) -> void: + if popup == null: + return + if not _mobile_modals.has(popup): + _mobile_modals.append(popup) + popup.set_meta("mobile_modal", true) + MobileWindowHost.wire_buttons(popup) + +func reflow_mobile_window(root: Control) -> void: + if not mobile_mode or root == null or not is_instance_valid(root): + return + if not _stack.has(root): + return + MobileWindowHost.install(root, _mobile_area(), _window_title(root), + Callable(self, "_close_hosted_window").bind(root)) + _restack() # --- open / close -------------------------------------------------------- @@ -61,7 +131,11 @@ func open(win: Control, modal := false) -> void: if win.get_parent() == null: _root.add_child(win) win.set_meta("modal", modal) - _wire_drag(win) + if mobile_mode: + MobileWindowHost.install(win, _mobile_area(), _window_title(win), + Callable(self, "_close_hosted_window").bind(win)) + else: + _wire_drag(win) _stack.erase(win) _stack.append(win) _restack() @@ -70,6 +144,13 @@ func open(win: Control, modal := false) -> void: func close(win: Control) -> void: if win == null: return + if bool(win.get_meta("mobile_external", false)): + var cb: Callable = _mobile_close_callbacks.get(win, Callable()) + if cb.is_valid() and win.visible: + cb.call() + else: + _dismiss_external(win) + return _stack.erase(win) if win.get_parent(): win.get_parent().remove_child(win) @@ -78,9 +159,19 @@ func close(win: Control) -> void: window_closed.emit(win) func close_top() -> bool: + if _close_mobile_popup(): + return true if _stack.is_empty(): return false - close(_stack[-1]) + var win := _stack[-1] + if bool(win.get_meta("mobile_external", false)): + var cb: Callable = _mobile_close_callbacks.get(win, Callable()) + if cb.is_valid(): + cb.call() + else: + _dismiss_external(win) + else: + close(win) return true func top() -> Control: @@ -90,6 +181,9 @@ func top() -> Control: # 下方穿透。非模态窗口保留原版常用的开关键,便于 I/K/V/N 等键关闭当前窗; # 模态确认框只允许 ESC 回到窗口栈。 func blocks_game_input(event: InputEvent) -> bool: + var popup := _mobile_modal_popup() + if popup != null: + return not (event is InputEventKey and event.keycode == KEY_ESCAPE) if _stack.is_empty(): return false var top_window: Control = _stack[-1] @@ -97,6 +191,10 @@ func blocks_game_input(event: InputEvent) -> bool: return false if bool(top_window.get_meta("modal", false)): return not (event is InputEventKey and event.keycode == KEY_ESCAPE) + if mobile_mode and event is InputEventScreenTouch: + return true + if mobile_mode and event is InputEventScreenDrag: + return true if event is InputEventMouse: return top_window.get_global_rect().has_point(event.position) if event is InputEventKey: @@ -112,6 +210,115 @@ func _input(event: InputEvent) -> void: if event is InputEventKey and blocks_game_input(event): get_viewport().set_input_as_handled() +func _process(_delta: float) -> void: + if mobile_mode: + var area := _mobile_area() + if not area.is_equal_approx(_last_mobile_area): + _last_mobile_area = area + _reflow_mobile_windows(area) + _sync_mobile_windows() + # Password/confirmation dialogs are created by feature controllers after + # their window is hosted. Wire their buttons too, including dialogs that + # appear as siblings under UiManager's root. + MobileWindowHost.wire_buttons(_root) + for win in _stack: + if win and is_instance_valid(win): + MobileWindowHost.wire_buttons(win) + for popup in _mobile_modals.duplicate(): + if popup == null or not is_instance_valid(popup): + _mobile_modals.erase(popup) + else: + MobileWindowHost.wire_buttons(popup) + +func _reflow_mobile_windows(area: Rect2) -> void: + # Safe areas can change while the logical viewport stays the same (for + # example when a keyboard, call bar or system gesture inset appears). Keep + # every already-open feature inside the new area instead of waiting for a + # full window resize. + for win in _stack.duplicate(): + if win == null or not is_instance_valid(win) or not win.visible: + continue + MobileWindowHost.install(win, area, _window_title(win), + Callable(self, "_close_hosted_window").bind(win)) + +func _sync_mobile_windows() -> void: + for record in _mobile_external: + var root: Control = record.get("root") + if root == null or not is_instance_valid(root): + continue + if root.visible and not _stack.has(root): + _present_external(root, String(record.get("title", "")), + record.get("close_cb", Callable())) + elif not root.visible and _stack.has(root): + _dismiss_external(root) + +func _mobile_area() -> Rect2: + return MobileWindowHost.safe_rect(get_viewport()) + +func _window_title(win: Control) -> String: + if win == null: + return "" + var title := String(win.get_meta("mobile_title", "")) + if title != "": + return title + return String(win.name) if win.name != "Control" else "功能" + +func _close_hosted_window(win: Control) -> void: + close(win) + +func _present_external(root: Control, title: String, close_cb: Callable) -> void: + if root == null or not is_instance_valid(root) or _stack.has(root): + return + var parent := root.get_parent() + if parent == null: + return + var saved := { + "parent": parent, + "position": root.position, + "size": root.size, + "scale": root.scale, + "pivot": root.pivot_offset, + "anchor_left": root.anchor_left, + "anchor_top": root.anchor_top, + "anchor_right": root.anchor_right, + "anchor_bottom": root.anchor_bottom, + } + _mobile_saved[root] = saved + parent.remove_child(root) + _root.add_child(root) + root.set_meta("mobile_external", true) + root.set_meta("modal", false) + _mobile_close_callbacks[root] = close_cb + MobileWindowHost.install(root, _mobile_area(), title, close_cb) + _stack.append(root) + _restack() + +func _dismiss_external(root: Control) -> void: + if root == null: + return + _stack.erase(root) + _mobile_close_callbacks.erase(root) + MobileWindowHost.uninstall(root) + var saved: Dictionary = _mobile_saved.get(root, {}) + if root.get_parent() == _root: + _root.remove_child(root) + var parent: Node = saved.get("parent") + if parent and is_instance_valid(parent): + parent.add_child(root) + if not saved.is_empty(): + root.position = saved.get("position", root.position) + root.size = saved.get("size", root.size) + root.scale = saved.get("scale", Vector2.ONE) + root.pivot_offset = saved.get("pivot", Vector2.ZERO) + root.anchor_left = float(saved.get("anchor_left", root.anchor_left)) + root.anchor_top = float(saved.get("anchor_top", root.anchor_top)) + root.anchor_right = float(saved.get("anchor_right", root.anchor_right)) + root.anchor_bottom = float(saved.get("anchor_bottom", root.anchor_bottom)) + _mobile_saved.erase(root) + root.set_meta("mobile_external", false) + _restack() + window_closed.emit(root) + func _restack() -> void: var any_modal := false for i in _stack.size(): @@ -120,6 +327,9 @@ func _restack() -> void: if bool(w.get_meta("modal", false)): any_modal = true _dim.visible = any_modal + _mobile_blocker.visible = mobile_mode and not _stack.is_empty() + if _mobile_blocker: + _root.move_child(_mobile_blocker, 0) if any_modal: # 变暗层紧贴在最顶层 modal 之下 _dim.move_to_front() @@ -129,9 +339,49 @@ func _restack() -> void: func _unhandled_input(e: InputEvent) -> void: if e is InputEventKey and e.pressed and e.keycode == KEY_ESCAPE: + if _close_mobile_popup(): + get_viewport().set_input_as_handled() + return if close_top(): get_viewport().set_input_as_handled() +func _mobile_modal_popup() -> Node: + if not mobile_mode or _root == null: + return null + for popup in _mobile_modals: + if popup and is_instance_valid(popup) and _node_visible(popup): + return popup + for node in _root.find_children("*", "", true, false): + if (node is Window or bool(node.get_meta("mobile_modal", false))) and _node_visible(node): + return node + return null + +func _node_visible(node: Node) -> bool: + if node is CanvasItem: + return (node as CanvasItem).visible + if node is Window: + return (node as Window).visible + return false + +func _close_mobile_popup() -> bool: + var popup := _mobile_modal_popup() + if popup == null: + return false + var close_cb: Variant = popup.get_meta("mobile_modal_close", Callable()) + if close_cb is Callable and close_cb.is_valid(): + close_cb.call() + return true + # ConfirmationDialog owners generally use the canceled signal to clear + # pending state. Drive its cancel button when available; plain dialogs can + # safely be hidden on back. + if popup is ConfirmationDialog and popup.has_method("get_cancel_button"): + var cancel = popup.call("get_cancel_button") + if cancel is BaseButton and not cancel.disabled: + cancel.pressed.emit() + return true + popup.hide() + return true + # --- 标题栏拖动 -------------------------------------------------------- func _wire_drag(win: Control) -> void: diff --git a/project/ui/ui_profile.gd b/project/ui/ui_profile.gd new file mode 100644 index 00000000..a176ba11 --- /dev/null +++ b/project/ui/ui_profile.gd @@ -0,0 +1,48 @@ +# UiProfile —— desktop / mobile presentation selection. +# +# The profile only selects presentation and input surfaces. It must never +# change the M2 protocol or gameplay rules. Desktop developers can force the +# mobile HUD with `--mobile-ui` (or the project setting below) without needing +# an Android build. +extends RefCounted + +enum Mode { AUTO, DESKTOP, MOBILE } + +const SETTING := "mt/ui/profile" +const MOBILE_ARG := "--mobile-ui" +const DESKTOP_ARG := "--desktop-ui" + +static func parse(value: String) -> int: + match value.strip_edges().to_lower(): + "mobile": + return Mode.MOBILE + "desktop": + return Mode.DESKTOP + _: + return Mode.AUTO + +static func resolve(value: String = "") -> int: + var requested := value + if requested == "": + requested = String(ProjectSettings.get_setting(SETTING, "auto")) + var parsed := parse(requested) + if parsed != Mode.AUTO: + return parsed + for arg in OS.get_cmdline_args(): + if arg == MOBILE_ARG: + return Mode.MOBILE + if arg == DESKTOP_ARG: + return Mode.DESKTOP + return Mode.MOBILE if OS.has_feature("mobile") else Mode.DESKTOP + +static func is_mobile(value: String = "") -> bool: + return resolve(value) == Mode.MOBILE + +static func name(mode: int) -> String: + match mode: + Mode.MOBILE: + return "mobile" + Mode.DESKTOP: + return "desktop" + _: + return "auto" diff --git a/project/ui/ui_profile.gd.uid b/project/ui/ui_profile.gd.uid new file mode 100644 index 00000000..679f2cc4 --- /dev/null +++ b/project/ui/ui_profile.gd.uid @@ -0,0 +1 @@ +uid://hvyi07nm3dbg