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/资源短条”排列;右上按“背包→小地图→菜单”排列,地图通过点击小地图进入,底部中央保持留白。
+
+
+
+
+
+
+
+
+
附近:小心精英怪!
+
+
+
+
+
+
+
+
+
+
技能冷却中
+
+
+
+
+
+
+
+
+
+
+
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