feat: complete mobile UI implementation
This commit is contained in:
@@ -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 的布局语言,不复用参考图中的原始素材。
|
||||
@@ -0,0 +1,664 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>移动端 HUD 草图 · 暗黑 ARPG</title>
|
||||
<link rel="stylesheet" href="../themes/default.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin: 0; min-height: 100%; background: radial-gradient(circle at 50% 0%, #1c3048 0, #0b1018 42rem); color: var(--color-text); font-family: var(--font-sans); }
|
||||
body { padding: 34px 20px 60px; }
|
||||
button, input { font: inherit; }
|
||||
button { color: inherit; cursor: pointer; }
|
||||
.page-head { max-width: 1040px; margin: 0 auto 24px; }
|
||||
.eyebrow { color: var(--color-primary); font-size: var(--text-xs); letter-spacing: .18em; text-transform: uppercase; }
|
||||
h1 { margin: 5px 0; font-size: clamp(1.45rem, 3vw, 2.2rem); }
|
||||
.page-head p { margin: 6px 0 0; max-width: 720px; color: var(--color-text-muted); line-height: 1.55; }
|
||||
.variant { max-width: 1040px; margin: auto; }
|
||||
.variant-intro { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin: 0 auto 14px; max-width: 920px; }
|
||||
.variant-intro h2 { margin: 0; font-size: var(--text-lg); }
|
||||
.variant-intro p { margin: 0; color: var(--color-text-muted); font-size: var(--text-sm); }
|
||||
.device-wrap { display: flex; justify-content: center; }
|
||||
.device { position: relative; width: min(844px, calc(100vw - 40px)); aspect-ratio: 844 / 390; overflow: hidden; border: 1px solid rgba(221, 238, 255, .45); border-radius: 30px; background: #0a1018; box-shadow: 0 28px 80px rgba(0,0,0,.55), 0 0 0 7px rgba(255,255,255,.035); transition: width .25s ease, aspect-ratio .25s ease, border-radius .25s ease; }
|
||||
.preview-tablet .device { width: min(1280px, calc(100vw - 40px)); aspect-ratio: 16 / 9; border-radius: 24px; }
|
||||
.preview-desktop .device { width: min(1440px, calc(100vw - 40px)); aspect-ratio: 16 / 9; border-radius: 18px; }
|
||||
.scene { position: absolute; inset: 0; overflow: hidden; background: linear-gradient(145deg, rgba(16,39,54,.85), rgba(8,17,25,.88) 48%, rgba(32,22,42,.94)); }
|
||||
.scene::before { content: ""; position: absolute; inset: -12%; opacity: .55; background: radial-gradient(ellipse at 25% 37%, rgba(95,181,187,.28), transparent 24%), radial-gradient(ellipse at 78% 23%, rgba(144,97,221,.28), transparent 22%), linear-gradient(145deg, transparent 35%, rgba(255,211,108,.08) 36%, transparent 37% 58%, rgba(116,185,255,.08) 59%, transparent 60%); transform: rotate(-7deg); }
|
||||
.scene::after { content: ""; position: absolute; inset: 0; background: linear-gradient(transparent 57%, rgba(4,8,13,.42) 82%, rgba(4,8,13,.78)); pointer-events: none; }
|
||||
.terrain { position: absolute; inset: 32% 4% 16%; opacity: .4; background: repeating-linear-gradient(164deg, transparent 0 19px, rgba(173,214,213,.16) 20px 21px), repeating-linear-gradient(22deg, transparent 0 36px, rgba(46,70,82,.42) 37px 39px); clip-path: polygon(0 25%, 25% 4%, 53% 24%, 75% 0, 100% 21%, 100% 100%, 0 100%); }
|
||||
.hero { position: absolute; left: 48%; top: 40%; width: 48px; height: 82px; transform: translate(-50%, -50%); border-radius: 45% 45% 28% 28%; background: linear-gradient(90deg, #152d44, #b7d7e8 45%, #315375 58%, #101d2d); box-shadow: 0 0 18px rgba(116,185,255,.45); }
|
||||
.hero::before { content: ""; position: absolute; left: 9px; top: -26px; width: 30px; height: 32px; border-radius: 50%; background: radial-gradient(circle at 40% 42%, #ffe9c5 0 18%, #6f4c3e 19% 54%, #1a2432 55%); }
|
||||
.hero::after { content: "✦"; position: absolute; right: -29px; top: 6px; color: var(--color-accent); font-size: 28px; text-shadow: 0 0 14px #ffb638; }
|
||||
.mob { position: absolute; left: 68%; top: 44%; width: 58px; height: 42px; border-radius: 48% 52% 35% 35%; background: linear-gradient(140deg, #2b4155, #8c6672 45%, #201824); box-shadow: 0 0 18px rgba(255,111,120,.2); }
|
||||
.mob::before { content: ""; position: absolute; left: 13px; top: -17px; width: 34px; height: 22px; border-radius: 55% 45% 35% 35%; background: #5f3442; }
|
||||
.mob::after { content: "· ·"; position: absolute; left: 9px; top: -13px; color: #ffb1a9; letter-spacing: 6px; font-size: 13px; }
|
||||
.top-status { position: absolute; z-index: 2; top: 14px; left: 14px; right: 14px; display: flex; align-items: center; gap: 9px; }
|
||||
.avatar { width: 44px; height: 44px; flex: 0 0 auto; display: grid; place-items: center; border: 1px solid rgba(255,211,108,.78); border-radius: 50%; background: radial-gradient(circle, #658fb6, #182637 62%, #0b121c); box-shadow: 0 0 0 3px rgba(12,20,30,.58); font-size: 20px; }
|
||||
.status-copy { min-width: 0; flex: 0 0 148px; width: 148px; }
|
||||
.status-line { display: flex; align-items: center; justify-content: space-between; gap: 7px; font-size: var(--text-xs); }
|
||||
.status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.level { color: var(--color-accent); font-family: var(--font-mono); }
|
||||
.meter { height: 7px; margin-top: 4px; overflow: hidden; border: 1px solid rgba(255,255,255,.18); border-radius: 8px; background: rgba(3,8,13,.72); }
|
||||
.meter i { display: block; height: 100%; border-radius: inherit; }
|
||||
.meter.hp i { width: 78%; background: linear-gradient(90deg, #f05767, #ff9d82); }
|
||||
.meter.mp i { width: 62%; background: linear-gradient(90deg, #4f91f8, #8dc8ff); }
|
||||
.meter.resource { height: 5px; margin-top: 3px; }
|
||||
.meter.resource i { width: 68%; background: linear-gradient(90deg, #39217d, #8c55ed 70%, #caa0ff); }
|
||||
.mini-map { position: relative; width: 74px; height: 74px; flex: 0 0 auto; overflow: hidden; border: 1px solid rgba(213,235,255,.52); border-radius: 50%; background: radial-gradient(circle at 44% 58%, rgba(101,178,161,.5) 0 12%, transparent 13%), linear-gradient(35deg, #17313a 0 38%, #284b58 39% 44%, #17313a 45% 63%, #4c385d 64%); box-shadow: inset 0 0 0 4px rgba(8,14,22,.55), 0 6px 14px rgba(0,0,0,.3); }
|
||||
.mini-map::before { content: ""; position: absolute; left: 47%; top: 43%; width: 7px; height: 7px; border-radius: 50%; background: var(--color-accent); box-shadow: 0 0 0 4px rgba(255,211,108,.18), 16px -20px 0 -2px var(--color-danger); }
|
||||
.target-card { position: absolute; z-index: 2; top: 94px; left: 50%; width: 190px; transform: translateX(-50%); padding: 8px 11px; opacity: .92; border: 1px solid rgba(255,111,120,.36); border-radius: 12px; background: rgba(13,19,29,.62); backdrop-filter: blur(12px); box-shadow: var(--shadow-sm); }
|
||||
.target-card strong { display: block; font-size: var(--text-sm); }
|
||||
.target-card small { color: #ffabb0; font-size: var(--text-xs); }
|
||||
.target-card .meter { height: 5px; margin-top: 6px; }
|
||||
.target-card .meter i { width: 43%; background: linear-gradient(90deg, #e65060, #ffab87); }
|
||||
.quest-pill { position: absolute; z-index: 2; top: 102px; right: 14px; display: flex; align-items: center; gap: 6px; border: 1px solid rgba(255,211,108,.36); border-radius: 12px; padding: 8px 10px; background: rgba(13,19,29,.54); color: #ffe9ab; font-size: var(--text-xs); backdrop-filter: blur(12px); }
|
||||
.chat-bubble { position: absolute; z-index: 2; left: 50%; top: 57%; transform: translateX(-50%); max-width: 168px; padding: 7px 10px; border: 1px solid rgba(255,255,255,.23); border-radius: 12px 12px 12px 3px; background: rgba(10,16,24,.72); color: #e3efff; font-size: var(--text-xs); backdrop-filter: blur(10px); }
|
||||
.chat-bubble::after { content: ""; position: absolute; left: 11px; bottom: -7px; border: 6px solid transparent; border-top-color: rgba(10,16,24,.72); border-left: 0; }
|
||||
.joystick { position: absolute; z-index: 4; left: 22px; bottom: 116px; width: 110px; height: 110px; border: 1px solid rgba(213,235,255,.24); border-radius: 50%; background: radial-gradient(circle, rgba(116,185,255,.2) 0 28%, rgba(116,185,255,.07) 29% 62%, rgba(10,16,24,.15) 63%); box-shadow: inset 0 0 0 12px rgba(9,17,26,.22); touch-action: none; }
|
||||
.joystick::before { content: ""; position: absolute; inset: 18px; border: 1px dashed rgba(213,235,255,.24); border-radius: 50%; }
|
||||
.stick { position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; transform: translate(-50%,-50%); border: 1px solid rgba(213,235,255,.64); border-radius: 50%; background: radial-gradient(circle at 35% 28%, rgba(255,255,255,.32), rgba(73,134,193,.66) 38%, rgba(19,49,75,.88)); box-shadow: 0 5px 12px rgba(0,0,0,.36); transition: transform .12s ease; }
|
||||
.joystick.is-active .stick { box-shadow: 0 0 0 6px rgba(116,185,255,.14), 0 5px 12px rgba(0,0,0,.36); }
|
||||
.combat-cluster { position: absolute; z-index: 4; right: 16px; bottom: 116px; width: 156px; height: 156px; }
|
||||
.skill { position: absolute; display: grid; place-items: center; width: 52px; height: 52px; border: 1px solid rgba(213,235,255,.54); border-radius: 50%; background: radial-gradient(circle at 36% 30%, rgba(255,255,255,.33), rgba(55,88,129,.8) 45%, rgba(10,19,31,.92)); box-shadow: 0 6px 14px rgba(0,0,0,.36); font-size: 20px; transition: transform .15s ease, filter .15s ease; }
|
||||
.skill:hover, .skill:focus-visible { transform: scale(1.08); filter: brightness(1.25); }
|
||||
.skill.main { right: 0; bottom: 14px; width: 78px; height: 78px; border-color: rgba(255,211,108,.74); background: radial-gradient(circle at 35% 25%, #ffdf9a, #ad5b4d 42%, #2b1b2e 78%); font-size: 29px; }
|
||||
.skill.s1 { left: 12px; top: 5px; }
|
||||
.skill.s2 { left: 52px; top: 52px; }
|
||||
.skill.s3 { right: 4px; top: 3px; }
|
||||
.skill.s4 { left: 0; bottom: 10px; }
|
||||
.skill em { position: absolute; right: -2px; bottom: -2px; display: none; min-width: 18px; padding: 2px 4px; border-radius: 8px; background: #0a111c; color: #f9fcff; font-size: 10px; font-style: normal; }
|
||||
.skill.cooling { filter: grayscale(.7) brightness(.58); }
|
||||
.skill.cooling em { display: block; }
|
||||
.context-action { position: absolute; z-index: 4; right: 23px; bottom: 279px; display: flex; align-items: center; gap: 6px; border: 1px solid rgba(101,225,178,.45); border-radius: 18px; padding: 7px 10px; background: rgba(14,44,46,.72); color: #adffe1; font-size: var(--text-xs); backdrop-filter: blur(10px); }
|
||||
.utility-btn { min-width: 41px; height: 38px; display: grid; place-items: center; flex: 1; border: 1px solid rgba(213,235,255,.12); border-radius: 12px; background: rgba(116,185,255,.08); font-size: 17px; transition: all .15s ease; }
|
||||
.utility-btn:hover, .utility-btn.active { border-color: rgba(116,185,255,.5); background: rgba(116,185,255,.21); transform: translateY(-2px); }
|
||||
.utility-btn span { display: block; margin-top: 1px; color: var(--color-text-muted); font-size: 9px; }
|
||||
.floating-cooldown { position: absolute; z-index: 5; left: 50%; top: 69%; transform: translate(-50%, -50%); padding: 9px 13px; border: 1px solid rgba(255,211,108,.42); border-radius: 16px; background: rgba(22,18,19,.74); color: #ffe4a0; font-size: var(--text-xs); opacity: 0; transition: opacity .2s ease; }
|
||||
.floating-cooldown.show { opacity: 1; }
|
||||
.modal-layer { position: absolute; z-index: 8; inset: 0; display: none; align-items: flex-end; background: rgba(2,6,10,.48); backdrop-filter: blur(6px); }
|
||||
.modal-layer.open { display: flex; }
|
||||
.sheet { width: 100%; max-height: 74%; overflow: auto; padding: 17px; border-top: 1px solid rgba(213,235,255,.32); border-radius: 24px 24px 0 0; background: rgba(12,21,33,.95); box-shadow: 0 -16px 42px rgba(0,0,0,.4); transform: translateY(20px); animation: rise .18s ease forwards; }
|
||||
@keyframes rise { to { transform: translateY(0); } }
|
||||
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.sheet-head h3 { margin: 0; font-size: var(--text-lg); }
|
||||
.sheet-title-row { display: flex; align-items: center; gap: 8px; }
|
||||
.close-btn { width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: 50%; background: var(--color-surface-soft); }
|
||||
.back-btn { width: 32px; height: 32px; border: 1px solid var(--color-border); border-radius: 50%; background: var(--color-surface-soft); }
|
||||
.action-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.action-item { min-height: 58px; border: 1px solid rgba(213,235,255,.13); border-radius: 14px; background: rgba(116,185,255,.07); color: var(--color-text); transition: all .15s ease; }
|
||||
.action-item:hover { border-color: rgba(116,185,255,.5); background: rgba(116,185,255,.16); transform: translateY(-2px); }
|
||||
.action-item b { display: block; font-size: 20px; }
|
||||
.action-item span { color: var(--color-text-muted); font-size: 10px; }
|
||||
.menu-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
|
||||
.menu-item { min-height: 88px; padding: 10px 7px; border: 1px solid rgba(213,235,255,.16); border-radius: 15px; background: linear-gradient(145deg, rgba(116,185,255,.13), rgba(10,16,24,.35)); color: var(--color-text); text-align: center; transition: transform .15s ease, border-color .15s ease, background .15s ease; }
|
||||
.menu-item:hover, .menu-item:focus-visible { transform: translateY(-2px); border-color: rgba(255,211,108,.72); background: rgba(116,185,255,.23); outline: none; }
|
||||
.menu-item b { display: block; margin-bottom: 5px; color: var(--color-accent); font-size: 25px; line-height: 1; }
|
||||
.menu-item span { display: block; font-size: var(--text-sm); }
|
||||
.menu-item small { display: block; margin-top: 4px; color: var(--color-text-muted); font-size: 9px; line-height: 1.3; }
|
||||
.sheet-note { margin: 10px 0 0; color: var(--color-text-muted); font-size: var(--text-xs); line-height: 1.4; }
|
||||
.chat-list { display: grid; gap: 8px; margin-bottom: 10px; }
|
||||
.chat-line { padding: 8px 10px; border-radius: 12px; background: rgba(255,255,255,.055); font-size: var(--text-sm); }
|
||||
.chat-line strong { color: var(--color-primary); }
|
||||
.chat-compose { display: flex; gap: 8px; }
|
||||
.chat-compose input { min-width: 0; flex: 1; padding: 10px; border: 1px solid var(--color-border); border-radius: 12px; background: rgba(0,0,0,.25); color: var(--color-text); outline: none; }
|
||||
.send-btn { padding: 0 13px; border: 1px solid rgba(101,225,178,.4); border-radius: 12px; background: rgba(101,225,178,.14); color: #adffe1; }
|
||||
.annotation-label { position: fixed; z-index: 50; display: none; padding: 5px 7px; pointer-events: none; border: 1px solid rgba(255,211,108,.45); border-radius: 7px; background: #101a27; color: #ffe4a0; font: 11px var(--font-mono); box-shadow: var(--shadow-sm); }
|
||||
body.annotate [data-label]:hover { outline: 1px dashed rgba(255,211,108,.7); outline-offset: 3px; }
|
||||
.toolbar { position: fixed; z-index: 30; right: 16px; bottom: 16px; display: flex; align-items: center; gap: 7px; padding: 8px 10px; border: 1px solid rgba(255,255,255,.16); border-radius: 12px; background: rgba(5,9,14,.78); color: #fff; font-size: 11px; opacity: .44; transition: opacity .2s ease; backdrop-filter: blur(12px); }
|
||||
.toolbar:hover { opacity: 1; }
|
||||
.toolbar button, .toolbar select { border: 1px solid rgba(255,255,255,.19); border-radius: 7px; padding: 5px 7px; background: rgba(255,255,255,.08); color: #fff; }
|
||||
.toolbar button:hover, .toolbar select:hover { background: rgba(116,185,255,.22); }
|
||||
.toast { position: fixed; z-index: 60; left: 50%; bottom: 72px; transform: translate(-50%, 12px); padding: 9px 14px; opacity: 0; border: 1px solid rgba(213,235,255,.28); border-radius: 12px; background: rgba(9,16,25,.93); color: var(--color-text); box-shadow: var(--shadow-md); transition: all .18s ease; pointer-events: none; font-size: var(--text-sm); }
|
||||
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||
.interaction-section { max-width: 1040px; margin: 54px auto 0; }
|
||||
.interaction-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 18px; }
|
||||
.interaction-head h2 { margin: 5px 0 6px; font-size: clamp(1.35rem, 2.6vw, 2rem); }
|
||||
.interaction-head p { max-width: 720px; margin: 0; color: #c6b399; line-height: 1.55; }
|
||||
.interaction-legend { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: 330px; }
|
||||
.legend-chip { display: inline-flex; align-items: center; gap: 5px; padding: 6px 9px; border: 1px solid rgba(224, 181, 105, .26); border-radius: 999px; background: rgba(93, 53, 25, .2); color: #d8bd91; font-size: 10px; white-space: nowrap; }
|
||||
.legend-chip i { width: 7px; height: 7px; border-radius: 50%; background: #e0b15e; box-shadow: 0 0 8px rgba(224, 177, 91, .7); }
|
||||
.legend-chip.menu i { background: #8f6be8; box-shadow: 0 0 8px rgba(143, 107, 232, .7); }
|
||||
.legend-chip.full i { background: #68baa0; box-shadow: 0 0 8px rgba(104, 186, 160, .7); }
|
||||
.legend-chip.modal i { background: #e26c5c; box-shadow: 0 0 8px rgba(226, 108, 92, .7); }
|
||||
.flow-filter { display: flex; flex-wrap: wrap; gap: 7px; margin: 0 0 14px; }
|
||||
.flow-filter button { padding: 7px 12px; border: 1px solid rgba(224, 181, 105, .24); border-radius: 999px; background: rgba(44, 24, 14, .62); color: #c8b18e; font-size: 11px; }
|
||||
.flow-filter button.active, .flow-filter button:hover { border-color: rgba(239, 195, 113, .72); background: rgba(163, 100, 44, .28); color: #f5d998; }
|
||||
.flow-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.flow-card { min-width: 0; padding: 14px; border: 1px solid rgba(224, 181, 105, .25); border-radius: 16px; background: linear-gradient(145deg, rgba(64, 35, 20, .72), rgba(20, 11, 8, .88)); box-shadow: 0 10px 26px rgba(0,0,0,.18); }
|
||||
.flow-card.is-hidden { display: none; }
|
||||
.flow-card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
|
||||
.flow-card-head h3 { margin: 0; color: #f4e0b5; font-size: 14px; }
|
||||
.flow-index { color: #d8a758; font: 11px var(--font-mono); letter-spacing: .08em; }
|
||||
.flow-scope { color: #a99478; font-size: 10px; }
|
||||
.flow-track { display: flex; align-items: stretch; gap: 6px; overflow-x: auto; padding: 2px 1px 5px; scrollbar-width: thin; }
|
||||
.flow-node { position: relative; min-width: 104px; min-height: 67px; padding: 9px 8px; border: 1px solid rgba(224, 181, 105, .24); border-radius: 12px; background: rgba(16, 9, 7, .8); color: #f5ead7; text-align: left; transition: transform .15s ease, border-color .15s ease, background .15s ease; }
|
||||
.flow-node:hover, .flow-node:focus-visible { transform: translateY(-2px); border-color: rgba(239, 195, 113, .85); background: rgba(117, 68, 32, .36); outline: none; }
|
||||
.flow-node b { display: block; margin-bottom: 5px; font-size: 12px; }
|
||||
.flow-node small { display: block; color: #bba586; font-size: 10px; line-height: 1.35; }
|
||||
.flow-node.menu-node { border-color: rgba(143, 107, 232, .4); }
|
||||
.flow-node.full-node { border-color: rgba(104, 186, 160, .4); }
|
||||
.flow-node.modal-node { border-color: rgba(226, 108, 92, .42); }
|
||||
.flow-arrow { align-self: center; flex: 0 0 auto; color: #d8a758; font-size: 16px; opacity: .78; }
|
||||
.flow-detail { margin-top: 14px; min-height: 132px; padding: 16px 18px; border: 1px solid rgba(239, 195, 113, .34); border-radius: 16px; background: rgba(20, 10, 7, .84); box-shadow: 0 15px 32px rgba(0,0,0,.22); }
|
||||
.flow-detail.empty { display: grid; place-items: center; color: #bba586; text-align: center; }
|
||||
.flow-detail h3 { margin: 4px 0 8px; color: #f4e0b5; font-size: 17px; }
|
||||
.detail-kicker { color: #d8a758; font: 10px var(--font-mono); letter-spacing: .14em; text-transform: uppercase; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1.1fr 1fr 1fr; gap: 14px; }
|
||||
.detail-block strong { display: block; margin-bottom: 4px; color: #d8bd91; font-size: 11px; }
|
||||
.detail-block p { margin: 0; color: #c8b18e; font-size: 11px; line-height: 1.5; }
|
||||
.detail-block p + p { margin-top: 4px; }
|
||||
.flow-note { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid rgba(224, 181, 105, .14); color: #9f8b70; font-size: 10px; line-height: 1.5; }
|
||||
.hidden { display: none !important; }
|
||||
@media (max-width: 620px) { body { padding-inline: 10px; } .variant-intro { display: block; padding-inline: 4px; } .variant-intro p { margin-top: 4px; } .toolbar { right: 8px; bottom: 8px; } }
|
||||
|
||||
/* Diablo-inspired ARPG direction: warm bronze frames, dark glass, circular combat controls. */
|
||||
body { background: radial-gradient(circle at 50% 0%, #3a2416 0, #170e0b 35rem, #090706 70rem); }
|
||||
.page-head p { color: #c6b399; }
|
||||
.device { border-color: rgba(224, 181, 105, .72); border-radius: 22px; box-shadow: 0 28px 80px rgba(0,0,0,.7), 0 0 0 6px rgba(204, 147, 64, .08), inset 0 0 0 1px rgba(255, 229, 175, .14); }
|
||||
.scene { background: linear-gradient(145deg, #725031, #b98045 40%, #4b2b1d 78%, #1b100c); }
|
||||
.scene::before { opacity: .7; background: radial-gradient(ellipse at 34% 40%, rgba(255, 224, 152, .28), transparent 24%), radial-gradient(ellipse at 70% 28%, rgba(42, 16, 13, .4), transparent 27%), repeating-linear-gradient(151deg, rgba(255, 220, 155, .06) 0 2px, transparent 2px 38px); }
|
||||
.scene::after { background: linear-gradient(180deg, rgba(12, 7, 5, .54), transparent 20%, transparent 62%, rgba(8, 5, 4, .7)); }
|
||||
.terrain { opacity: .62; background: repeating-linear-gradient(164deg, transparent 0 17px, rgba(70, 35, 18, .18) 18px 20px), repeating-linear-gradient(22deg, transparent 0 35px, rgba(255, 218, 152, .16) 36px 38px); }
|
||||
.hero { background: linear-gradient(90deg, #1a1210, #d2a85d 43%, #6b3d25 59%, #110b09); box-shadow: 0 0 20px rgba(255, 198, 93, .48); }
|
||||
.hero::before { background: radial-gradient(circle at 40% 42%, #ffe0b2 0 18%, #6d3b2b 19% 54%, #160e0c 55%); }
|
||||
.hero::after { color: #ffe0a1; text-shadow: 0 0 14px #ef812e; }
|
||||
.mob { background: linear-gradient(140deg, #493021, #963f32 45%, #1c1110); box-shadow: 0 0 18px rgba(214, 69, 45, .35); }
|
||||
.mob::before { background: #4f1e1b; }
|
||||
.mob::after { color: #ffc0a0; }
|
||||
.top-status { top: 15px; left: 18px; right: auto; }
|
||||
.player-status { display: flex; width: 148px; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.player-name { display: flex; width: 148px; align-items: center; justify-content: center; gap: 6px; color: #f4e0b5; font: 600 11px var(--font-mono); text-shadow: 0 2px 4px #000; white-space: nowrap; }
|
||||
.player-meters { width: 44px; }
|
||||
.party-rail { position: absolute; z-index: 4; top: 112px; left: 18px; display: grid; width: 148px; gap: 5px; }
|
||||
.party-member { display: grid; grid-template-columns: 27px minmax(0, 1fr) auto; align-items: center; gap: 6px; min-height: 34px; padding: 4px 6px 4px 4px; border: 1px solid rgba(223, 181, 111, .32); border-radius: 8px; background: rgba(19, 10, 7, .65); color: #f4e0b5; text-align: left; box-shadow: 0 4px 10px rgba(0,0,0,.28); transition: transform .15s ease, border-color .15s ease, background .15s ease; }
|
||||
.party-member:hover, .party-member:focus-visible { transform: translateX(2px); border-color: rgba(239, 195, 113, .76); background: rgba(87, 46, 22, .78); outline: none; }
|
||||
.party-avatar { display: grid; width: 25px; height: 25px; place-items: center; border: 1px solid rgba(239, 195, 112, .6); border-radius: 50%; background: radial-gradient(circle, #89684e, #291711 68%); color: #f7d58f; font-size: 12px; }
|
||||
.party-copy { min-width: 0; }
|
||||
.party-copy strong { display: block; overflow: hidden; color: #e9d5ae; font-size: 9px; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.party-hp { height: 4px; margin-top: 3px; overflow: hidden; border-radius: 4px; background: rgba(5, 3, 2, .72); }
|
||||
.party-hp i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #8a2525, #e85a46); }
|
||||
.party-distance { color: #bfa77f; font: 9px var(--font-mono); }
|
||||
.top-right-controls { position: absolute; z-index: 4; top: 14px; right: 14px; display: flex; align-items: flex-start; gap: 7px; }
|
||||
.top-right-controls .menu-button { min-width: 42px; width: 42px; height: 42px; flex: 0 0 auto; background: rgba(17, 9, 7, .78); border-color: rgba(224, 177, 91, .5); box-shadow: 0 4px 10px rgba(0,0,0,.42); }
|
||||
.avatar { border-color: rgba(239, 195, 112, .92); background: radial-gradient(circle, #99704c, #2b1a13 62%, #0d0907); box-shadow: 0 0 0 3px rgba(13, 8, 6, .7), 0 0 16px rgba(231, 167, 68, .22); }
|
||||
.level { color: #f4ca7b; }
|
||||
.meter { height: 9px; border-color: rgba(255, 227, 170, .25); background: rgba(11, 6, 5, .82); box-shadow: inset 0 1px 2px rgba(0,0,0,.45); }
|
||||
.meter.hp i { background: linear-gradient(90deg, #761b1e, #e2432c 72%, #ff8961); }
|
||||
.meter.mp i { background: linear-gradient(90deg, #3c247f, #8454f0 72%, #c58aff); }
|
||||
.mini-map { width: 142px; height: 86px; border-radius: 9px; border-color: rgba(223, 187, 119, .72); background: radial-gradient(circle at 43% 58%, rgba(226, 179, 83, .42) 0 7%, transparent 8%), linear-gradient(32deg, #21150e 0 31%, #6a4323 32% 39%, #271711 40% 63%, #80603b 64%); box-shadow: inset 0 0 0 4px rgba(8, 5, 4, .68), 0 6px 14px rgba(0,0,0,.48); }
|
||||
.mini-map::before { left: 47%; top: 43%; background: #f8d17e; box-shadow: 0 0 0 4px rgba(255, 205, 106, .2), 16px -20px 0 -2px #d65838; }
|
||||
.mini-map::after { content: "荒漠边境"; position: absolute; right: 7px; bottom: 5px; color: rgba(245, 213, 157, .7); font: 10px var(--font-mono); letter-spacing: .08em; }
|
||||
.target-card { top: 13px; width: 260px; padding: 4px 12px 6px; border: 0; border-radius: 0; background: rgba(13, 8, 6, .2); box-shadow: none; text-align: center; }
|
||||
.target-card strong { color: #f4e0b5; font: 600 15px var(--font-mono); letter-spacing: .1em; text-shadow: 0 2px 4px #000; }
|
||||
.target-card small { color: #d0b285; }
|
||||
.target-card .meter { height: 11px; margin-top: 5px; border-color: rgba(10, 5, 4, .9); }
|
||||
.target-card .meter i { background: linear-gradient(90deg, #6d171a, #d43d2e 75%, #f06d45); }
|
||||
.quest-pill { top: 109px; right: 168px; border-color: rgba(224, 177, 91, .38); background: rgba(24, 13, 9, .65); color: #f1d49a; box-shadow: 0 5px 13px rgba(0,0,0,.25); }
|
||||
.chat-bubble { top: 57%; background: rgba(17, 9, 7, .72); border-color: rgba(236, 197, 133, .28); color: #f4e6cb; }
|
||||
.context-action { right: 235px; bottom: 104px; border-color: rgba(219, 173, 93, .58); background: rgba(25, 12, 8, .74); color: #f6d593; box-shadow: 0 5px 15px rgba(0,0,0,.32); }
|
||||
.right-rail { position: absolute; z-index: 4; top: 111px; right: 14px; display: grid; gap: 8px; }
|
||||
.right-rail .utility-btn { min-width: 42px; width: 42px; height: 42px; flex: 0 0 auto; background: rgba(17, 9, 7, .64); border-color: rgba(216, 171, 99, .3); box-shadow: 0 4px 10px rgba(0,0,0,.3); }
|
||||
.right-rail .utility-btn span { color: #d8bd91; }
|
||||
.joystick { left: 25px; bottom: 18px; width: 120px; height: 120px; border-color: rgba(223, 181, 111, .32); background: radial-gradient(circle, rgba(185, 119, 58, .26) 0 28%, rgba(74, 39, 22, .42) 29% 62%, rgba(12, 7, 5, .26) 63%); box-shadow: inset 0 0 0 12px rgba(27, 13, 8, .28), 0 7px 18px rgba(0,0,0,.35); }
|
||||
.joystick::before { border-color: rgba(238, 202, 139, .2); }
|
||||
.stick { border-color: rgba(236, 194, 117, .58); background: radial-gradient(circle at 35% 28%, rgba(255, 239, 196, .28), rgba(114, 70, 38, .82) 38%, rgba(30, 14, 9, .94)); }
|
||||
.combat-cluster { right: 20px; bottom: 15px; width: 190px; height: 190px; }
|
||||
.combat-cluster::before { content: ""; position: absolute; inset: 16px -1px -2px 8px; border: 1px solid rgba(185, 132, 68, .3); border-radius: 50%; background: radial-gradient(circle, rgba(21, 10, 8, .56), rgba(12, 6, 5, .12) 68%, transparent 69%); box-shadow: 0 0 0 11px rgba(12, 6, 5, .18); }
|
||||
.combat-cluster::after { content: ""; position: absolute; inset: 5px -4px 12px 5px; border: 2px solid rgba(219, 169, 83, .28); border-left-color: transparent; border-bottom-color: transparent; border-radius: 50%; transform: rotate(34deg); pointer-events: none; }
|
||||
.skill { z-index: 1; width: 58px; height: 58px; border-color: rgba(232, 191, 115, .58); background: radial-gradient(circle at 35% 26%, rgba(247, 225, 177, .24), rgba(62, 28, 22, .94) 46%, rgba(12, 6, 6, .98)); box-shadow: 0 6px 14px rgba(0,0,0,.6), inset 0 0 0 3px rgba(6, 3, 3, .36); }
|
||||
.skill:hover, .skill:focus-visible { border-color: #f4d28d; filter: brightness(1.3); box-shadow: 0 0 0 3px rgba(238, 187, 90, .2), 0 7px 16px rgba(0,0,0,.6); }
|
||||
.skill.main { width: 88px; height: 88px; border-color: rgba(234, 196, 123, .86); background: radial-gradient(circle at 35% 25%, #7e5cf1, #321d75 48%, #0e0820 78%); box-shadow: 0 0 0 4px rgba(15, 7, 28, .75), 0 0 18px rgba(107, 65, 220, .6), 0 7px 17px rgba(0,0,0,.65); font-size: 31px; }
|
||||
.skill.main:hover, .skill.main:focus-visible { box-shadow: 0 0 0 5px rgba(232, 190, 100, .27), 0 0 24px rgba(138, 92, 255, .78); }
|
||||
.skill.s1 { left: 10px; top: 14px; }
|
||||
.skill.s2 { left: 62px; top: 70px; }
|
||||
.skill.s3 { right: 2px; top: 15px; }
|
||||
.skill.s4 { left: 13px; bottom: 2px; }
|
||||
#mobile-hud .skill.s4 { left: auto; right: 16px; top: -52px; bottom: auto; border-color: rgba(223, 66, 44, .86); background: radial-gradient(circle at 35% 25%, #f06a40, #7d1f25 45%, #1a080b 78%); }
|
||||
.skill em { background: #140907; color: #f2d491; }
|
||||
.utility-btn { border-color: rgba(215, 171, 98, .2); border-radius: 7px; background: rgba(124, 76, 40, .16); }
|
||||
.utility-btn:hover, .utility-btn.active { border-color: rgba(240, 200, 123, .72); background: rgba(173, 107, 46, .28); }
|
||||
.utility-btn span { color: #cdb58d; }
|
||||
.modal-layer { background: rgba(7, 3, 2, .6); }
|
||||
.sheet { border-top-color: rgba(226, 181, 99, .5); background: rgba(25, 14, 10, .97); }
|
||||
.action-item { border-color: rgba(220, 176, 100, .24); background: rgba(139, 82, 40, .12); }
|
||||
.action-item:hover { border-color: rgba(239, 195, 113, .7); background: rgba(163, 100, 44, .24); }
|
||||
.action-item span, .sheet-note { color: #c8b18e; }
|
||||
.menu-item { border-color: rgba(220, 176, 100, .3); background: linear-gradient(145deg, rgba(128, 73, 34, .38), rgba(30, 15, 9, .72)); box-shadow: inset 0 1px 0 rgba(255, 224, 163, .08), 0 6px 14px rgba(0,0,0,.2); }
|
||||
.menu-item:hover, .menu-item:focus-visible { border-color: rgba(239, 195, 113, .82); background: linear-gradient(145deg, rgba(164, 95, 40, .52), rgba(44, 20, 10, .82)); }
|
||||
.menu-item b { color: #f0c979; }
|
||||
.menu-item small { color: #bda887; }
|
||||
.send-btn { border-color: rgba(117, 186, 123, .5); background: rgba(75, 122, 73, .2); color: #c3e8b9; }
|
||||
.toolbar { background: rgba(10, 6, 4, .88); border-color: rgba(228, 183, 103, .32); }
|
||||
.toolbar button:hover, .toolbar select:hover { background: rgba(171, 104, 43, .28); }
|
||||
.toast { background: rgba(20, 10, 7, .96); border-color: rgba(224, 178, 98, .42); }
|
||||
@media (max-height: 460px) and (orientation: landscape) {
|
||||
body { padding-top: 66px; }
|
||||
.top-status { top: 10px; }
|
||||
.player-status, .player-name { width: 122px; }
|
||||
.player-meters { width: 44px; }
|
||||
.player-name { font-size: 9px; }
|
||||
.party-rail { top: 91px; left: 10px; width: 122px; gap: 4px; }
|
||||
.party-member { min-height: 30px; padding-block: 3px; }
|
||||
.party-avatar { width: 22px; height: 22px; font-size: 10px; }
|
||||
.top-right-controls { top: 9px; right: 9px; }
|
||||
.mini-map { width: 108px; height: 64px; }
|
||||
.right-rail { top: 82px; }
|
||||
.status-copy { flex-basis: 122px; width: 122px; }
|
||||
.quest-pill { top: 84px; right: 128px; }
|
||||
.target-card { top: 8px; }
|
||||
.joystick { width: 96px; height: 96px; left: 18px; bottom: 13px; }
|
||||
.combat-cluster { right: 14px; bottom: 10px; transform: scale(.82); transform-origin: bottom right; }
|
||||
.context-action { bottom: 88px; }
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.interaction-head { display: block; }
|
||||
.interaction-legend { justify-content: flex-start; margin-top: 12px; }
|
||||
.flow-grid { grid-template-columns: 1fr; }
|
||||
.detail-grid { grid-template-columns: 1fr; gap: 9px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-head">
|
||||
<div class="eyebrow">Mobile ARPG Control HUD / Sketch 001</div>
|
||||
<h1>暗黑 ARPG 风格 · 横屏 HUD 草图</h1>
|
||||
<p>按 844×390 横屏手机设计,保持世界画面优先,战斗操作触手可达;底部中央保持留白,低频功能统一收纳到右上角菜单。点击首页控件或下方交互图节点,查看完整的移动端 UI 流程。</p>
|
||||
</header>
|
||||
|
||||
<main id="sketch-canvas" class="preview-phone">
|
||||
<section id="mobile-hud" class="variant">
|
||||
<div class="variant-intro"><h2>暗黑 ARPG 横屏 HUD</h2><p>左上按“名称→头像→HP/MP/资源短条”排列;右上按“背包→小地图→菜单”排列,地图通过点击小地图进入,底部中央保持留白。</p></div>
|
||||
<div class="device-wrap"><div class="device" data-label="844 × 390 landscape · 暗黑 ARPG">
|
||||
<div class="scene">
|
||||
<div class="terrain"></div><div class="hero" data-label="玩家角色"></div><div class="mob"></div>
|
||||
<div class="top-status" data-label="玩家状态"><div class="player-status"><div class="player-name"><span>Azure · 暮光骑士</span><span class="level">Lv.42</span></div><button class="avatar demo-btn" data-panel="character" data-label="打开角色状态">♜</button><div class="player-meters"><div class="meter hp" aria-label="生命值"><i></i></div><div class="meter mp" aria-label="法力值"><i></i></div><div class="meter resource" aria-label="职业资源:奥能 68%"><i></i></div></div></div></div>
|
||||
<div class="party-rail" data-label="队伍信息:仅组队后显示"><button class="party-member demo-btn" data-panel="party" data-label="队员 Mira · 点击查看或定位"><span class="party-avatar">M</span><span class="party-copy"><strong>Mira · 圣骑士</strong><span class="party-hp"><i style="width: 83%"></i></span></span><span class="party-distance">12m</span></button><button class="party-member demo-btn" data-panel="party" data-label="队员 Ken · 点击查看或定位"><span class="party-avatar">K</span><span class="party-copy"><strong>Ken · 游侠</strong><span class="party-hp"><i style="width: 61%"></i></span></span><span class="party-distance">34m</span></button></div>
|
||||
<div class="top-right-controls" data-label="顶部背包、地图与菜单"><button class="utility-btn top-bag demo-btn" data-panel="inventory" data-label="打开背包">▦<span>背包</span></button><button class="mini-map demo-btn" data-panel="map" aria-label="打开地图" data-label="小地图 / 地图"></button><button class="utility-btn menu-button demo-btn" data-panel="more" data-label="打开菜单">☰<span>菜单</span></button></div>
|
||||
<button class="target-card demo-btn" data-panel="target" data-label="目标板"><strong>血狼 · Lv.40</strong><small>敌对目标</small><div class="meter"><i></i></div></button>
|
||||
<button class="quest-pill demo-btn" data-panel="quest" data-label="任务追踪">◆ 采集狼牙 <b>2/5</b></button>
|
||||
<div class="chat-bubble" data-label="头顶聊天气泡">附近:小心精英怪!</div>
|
||||
<button class="context-action demo-btn" data-toast="靠近目标后:拾取 / 对话 / 钓鱼 / 进入副本">✦ 交互</button>
|
||||
<div class="joystick" data-joystick data-label="虚拟摇杆"><div class="stick"></div></div>
|
||||
<div class="combat-cluster" data-label="战斗技能区">
|
||||
<button class="skill s1 demo-btn" data-skill="冰刃" data-toast="释放技能:冰刃">❄<em>8</em></button>
|
||||
<button class="skill s2 demo-btn" data-skill="冲锋" data-toast="释放技能:冲锋">➤<em>5</em></button>
|
||||
<button class="skill s3 demo-btn" data-skill="治疗" data-toast="释放技能:治疗">✚<em>12</em></button>
|
||||
<button class="skill s4 demo-btn" data-skill="药水" data-toast="使用物品:红色药水">♥<em>3</em></button>
|
||||
<button class="skill main demo-btn" data-skill="普攻" data-toast="普通攻击">⚔</button>
|
||||
</div>
|
||||
<div class="floating-cooldown" data-cooldown>技能冷却中</div>
|
||||
<div class="modal-layer" data-modal><div class="sheet"><div class="sheet-head"><div class="sheet-title-row"><button class="back-btn hidden" data-back aria-label="返回上一级">←</button><h3 data-sheet-title>功能</h3></div><button class="close-btn" data-close>×</button></div><div data-sheet-body></div></div></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<section class="interaction-section" aria-labelledby="interaction-title">
|
||||
<div class="interaction-head">
|
||||
<div>
|
||||
<div class="eyebrow">Interaction map / all screens</div>
|
||||
<h2 id="interaction-title">全量 UI 移动端交互图</h2>
|
||||
<p>每张图表示一条完整操作路径。点击任意节点,查看它的入口位置、触控方式、页面形态和返回路径;任务始终从任务栏进入,组队信息固定显示在左上玩家头像下方。</p>
|
||||
</div>
|
||||
<div class="interaction-legend" aria-label="页面形态图例">
|
||||
<span class="legend-chip"><i></i>常驻 HUD</span>
|
||||
<span class="legend-chip menu"><i></i>右上角菜单</span>
|
||||
<span class="legend-chip full"><i></i>全屏面板</span>
|
||||
<span class="legend-chip modal"><i></i>场景弹窗</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-filter" role="tablist" aria-label="交互图筛选">
|
||||
<button class="active" data-flow-filter="all" role="tab" aria-selected="true">全部流程</button>
|
||||
<button data-flow-filter="entry" role="tab" aria-selected="false">进入游戏</button>
|
||||
<button data-flow-filter="combat" role="tab" aria-selected="false">战斗与任务</button>
|
||||
<button data-flow-filter="character" role="tab" aria-selected="false">角色与物品</button>
|
||||
<button data-flow-filter="social" role="tab" aria-selected="false">社交与经济</button>
|
||||
<button data-flow-filter="system" role="tab" aria-selected="false">系统状态</button>
|
||||
</div>
|
||||
|
||||
<div class="flow-grid" id="flow-grid">
|
||||
<article class="flow-card" data-flow-group="entry">
|
||||
<div class="flow-card-head"><h3>进入游戏与角色入口</h3><span class="flow-index">01 · ENTRY</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node full-node" data-detail="login"><b>登录</b><small>账号密码<br>失败提示</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="server"><b>服务器</b><small>频道选择<br>连接确认</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="character_select"><b>角色列表</b><small>选择 / 创建<br>删除 / 改名</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="loading"><b>加载</b><small>资源加载<br>进入场景</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node" data-detail="hud"><b>首页 HUD</b><small>开始操作</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="combat">
|
||||
<div class="flow-card-head"><h3>首页战斗与掉落</h3><span class="flow-index">02 · COMBAT</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node" data-detail="hud"><b>首页 HUD</b><small>左上状态<br>右上入口</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node" data-detail="movement"><b>移动摇杆</b><small>拖动移动<br>松手复位</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node" data-detail="target"><b>锁定目标</b><small>点击目标<br>显示血条</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node" data-detail="combat_action"><b>普攻 / 技能</b><small>点击释放<br>冷却反馈</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="loot"><b>掉落 / 交互</b><small>拾取、对话<br>进入副本</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node" data-detail="death"><b>死亡 / 复活</b><small>战斗失败<br>回城或复活</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="combat">
|
||||
<div class="flow-card-head"><h3>任务、地图与副本</h3><span class="flow-index">03 · QUEST</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node" data-detail="quest"><b>任务栏</b><small>首页常驻<br>直接点击</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="quest_log"><b>任务日志</b><small>列表 / 奖励<br>任务详情</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="map"><b>小地图 / 大地图</b><small>点击小地图<br>地图信标</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node" data-detail="navigation"><b>自动寻路</b><small>导航至目标<br>到达提示</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="npc_quest"><b>NPC 对话</b><small>接取 / 完成<br>任务奖励</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="dungeon_result"><b>副本结算</b><small>奖励 / 再次挑战<br>退出副本</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="character">
|
||||
<div class="flow-card-head"><h3>角色、装备与技能</h3><span class="flow-index">04 · CHARACTER</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node" data-detail="character_entry"><b>玩家头像</b><small>左上角<br>唯一入口</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="character_status"><b>角色属性</b><small>等级、属性<br>成长信息</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="equipment"><b>装备</b><small>穿戴、替换<br>查看对比</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="skills"><b>技能</b><small>主动、被动<br>坐骑、表情</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="skill_upgrade"><b>技能加点</b><small>查看消耗<br>确认升级</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="inspect"><b>查看玩家</b><small>点击角色<br>查看装备</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="character">
|
||||
<div class="flow-card-head"><h3>背包、物品与仓库</h3><span class="flow-index">05 · ITEMS</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node menu-node" data-detail="inventory"><b>背包</b><small>右上快捷入口<br>菜单也可进入</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="item_tooltip"><b>物品详情</b><small>点击选中<br>长按查看</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="item_action"><b>使用 / 装备 / 出售</b><small>按钮操作<br>危险操作确认</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="safebox"><b>仓库</b><small>背包与仓库<br>左右切换</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="select_item"><b>任务物品选择</b><small>选择数量<br>提交确认</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="social">
|
||||
<div class="flow-card-head"><h3>队伍状态、聊天、好友与公会</h3><span class="flow-index">06 · SOCIAL</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node" data-detail="party"><b>左侧队伍条</b><small>头像下方<br>成员状态</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="party"><b>队员信息</b><small>查看 / 定位<br>离队设置</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node menu-node" data-detail="social_entry"><b>右上菜单</b><small>社交入口<br>不含队伍</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="chat"><b>聊天</b><small>综合、私聊<br>队伍、公会</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="friend"><b>好友</b><small>申请、私聊<br>查看资料</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="love"><b>情侣 / 爱意</b><small>关系信息<br>互动操作</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="guild"><b>公会</b><small>成员、技能<br>公告、公会战</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="social">
|
||||
<div class="flow-card-head"><h3>商店、交易与制作</h3><span class="flow-index">07 · ECONOMY</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node modal-node" data-detail="commerce_entry"><b>NPC / 玩家</b><small>靠近后点击<br>出现交互</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="shop"><b>商店 / 商城</b><small>购买、出售<br>商城物品</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="trade"><b>玩家交易</b><small>拖入物品<br>双方确认</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="private_shop"><b>私人商店</b><small>浏览、购买<br>摆摊管理</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="cube"><b>制作</b><small>放入材料<br>制作确认</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="refine"><b>精炼 / 龙魂</b><small>强化结果<br>失败确认</small></button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="flow-card" data-flow-group="system">
|
||||
<div class="flow-card-head"><h3>系统设置与异常状态</h3><span class="flow-index">08 · SYSTEM</span></div>
|
||||
<div class="flow-track">
|
||||
<button class="flow-node menu-node" data-detail="system_entry"><b>右上菜单</b><small>系统入口<br>随时可打开</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="options"><b>系统 / 游戏设置</b><small>音量、画面<br>操作、屏蔽</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node full-node" data-detail="help"><b>帮助</b><small>操作说明<br>功能说明</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="character_switch"><b>选择角色</b><small>离开当前角色<br>二次确认</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="logout"><b>登出 / 退出</b><small>保存状态<br>确认离开</small></button><span class="flow-arrow">→</span>
|
||||
<button class="flow-node modal-node" data-detail="reconnect"><b>断线重连</b><small>连接恢复<br>重新进入场景</small></button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="flow-detail empty" id="flow-detail" aria-live="polite">
|
||||
<div><div class="detail-kicker">Tap a node</div><p>点击上方任意节点,查看该界面的移动端入口、位置、交互方式和返回路径。</p></div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<div class="toolbar" id="sketch-tools" aria-label="草图工具">
|
||||
<span>横屏预览</span><button data-viewport="phone">手机</button><button data-viewport="tablet">平板</button><button data-viewport="desktop">桌面</button>
|
||||
<select id="theme-switcher" aria-label="主题"><option value="default">暗黑铜金</option></select>
|
||||
<button id="annotation-toggle">标注</button>
|
||||
</div>
|
||||
<div class="toast" id="toast" role="status"></div><div class="annotation-label" id="annotation-label"></div>
|
||||
|
||||
<script>
|
||||
const body = document.body;
|
||||
const canvas = document.getElementById('sketch-canvas');
|
||||
const toast = document.getElementById('toast');
|
||||
let toastTimer;
|
||||
const featureCatalog = [
|
||||
['♟', '社交', 'social', '聊天 · 好友 · 公会'],
|
||||
['✧', '成长', 'growth', '强化 · 制作 · 龙魂'],
|
||||
['▣', '活动', 'adventure', '副本 · 钓鱼'],
|
||||
['◈', '商业', 'market', '商店 · 交易 · 仓库'],
|
||||
['⚙', '系统', 'system', '设置 · 帮助 · 退出']
|
||||
];
|
||||
const featureCopy = {
|
||||
inventory: '<p class="sheet-note">装备 / 腰带 / 物品拖放 · 右侧滑出面板,回到战斗时自动收起。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="拖动红药到快捷栏">♥<span>红药 ×12</span></button><button class="action-item demo-btn" data-toast="查看装备">⚔<span>装备</span></button><button class="action-item demo-btn" data-toast="打开腰带">▤<span>腰带</span></button><button class="action-item demo-btn" data-toast="整理背包">✦<span>整理</span></button></div>',
|
||||
character: '<p class="sheet-note">头像入口承载 HP / MP、状态、装备、技能、坐骑和表情动作。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开状态页">♥<span>状态</span></button><button class="action-item demo-btn" data-toast="查看装备">⚔<span>装备</span></button><button class="action-item demo-btn" data-panel="skills"><b>✦</b><span>技能</span></button><button class="action-item demo-btn" data-panel="mount"><b>♞</b><span>坐骑</span></button><button class="action-item demo-btn" data-panel="emote"><b>☺</b><span>表情</span></button><button class="action-item demo-btn" data-toast="打开称号页">◆<span>称号</span></button></div>',
|
||||
quest: '<p class="sheet-note">任务追踪使用半透明卡片;点击任务行进入日志,地图信标从卡片右上角进入。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="追踪:采集狼牙">◆<span>追踪</span></button><button class="action-item demo-btn" data-toast="打开任务日志">▤<span>日志</span></button><button class="action-item demo-btn" data-toast="显示地图信标">⌖<span>导航</span></button><button class="action-item demo-btn" data-toast="收起任务条">×<span>隐藏</span></button></div>',
|
||||
map: '<p class="sheet-note">小地图点击后升起全屏地图,保留副本目的地罗盘和任务信标。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="地图缩放 +">+<span>放大</span></button><button class="action-item demo-btn" data-toast="地图缩放 -">−<span>缩小</span></button><button class="action-item demo-btn" data-toast="查看世界地图">⌖<span>世界地图</span></button><button class="action-item demo-btn" data-toast="回到玩家位置">●<span>定位</span></button></div>',
|
||||
target: '<p class="sheet-note">目标板支持切换目标、查看血量和情境动作;按住目标卡可进入更多交互。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="切换目标">⇄<span>切换</span></button><button class="action-item demo-btn" data-toast="开始自动攻击">⚔<span>攻击</span></button><button class="action-item demo-btn" data-toast="打开目标信息">? <span>信息</span></button><button class="action-item demo-btn" data-toast="取消目标">×<span>清除</span></button></div>',
|
||||
chat: '<div class="chat-list"><div class="chat-line"><strong>[队伍]</strong> Mira:副本入口在北门。</div><div class="chat-line"><strong>[附近]</strong> 你:收到,马上到。</div><div class="chat-line"><strong>[公会]</strong> Ken:今晚 20:00 公会战。</div></div><div class="chat-compose"><input placeholder="输入消息…"><button class="send-btn" data-send>发送</button></div>',
|
||||
party: '<p class="sheet-note">队伍信息从左上玩家头像下方的队员条进入;未组队时该区域自动隐藏。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="定位队员">⌖<span>定位</span></button><button class="action-item demo-btn" data-toast="查看队员资料">♟<span>资料</span></button><button class="action-item demo-btn" data-toast="打开队伍设置">⚙<span>设置</span></button><button class="action-item demo-btn" data-toast="离开队伍确认">×<span>离队</span></button></div>',
|
||||
more: '<p class="sheet-note">菜单只保留五个一级入口;角色相关功能点击左上头像进入,背包、小地图和任务仍从首页直接进入。</p><div class="menu-grid">' + featureCatalog.map(([icon, name, key, note]) => `<button class="menu-item demo-btn" data-panel="${key}" data-toast="打开${name}"><b>${icon}</b><span>${name}</span><small>${note}</small></button>`).join('') + '</div>',
|
||||
guild: '<p class="sheet-note">公会信息、成员、会徽、公会战和公告从左侧社交轨进入。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开公会成员">♟<span>成员</span></button><button class="action-item demo-btn" data-toast="查看公会技能">✧<span>技能</span></button><button class="action-item demo-btn" data-toast="查看公会会徽">✦<span>会徽</span></button><button class="action-item demo-btn" data-toast="查看公会战">⚔<span>公会战</span></button></div>'
|
||||
};
|
||||
Object.assign(featureCopy, {
|
||||
social: '<p class="sheet-note">社交菜单只保留聊天、好友、公会、情侣和观战;队伍状态固定在左上玩家头像下方。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="chat"><b>☵</b><span>聊天</span></button><button class="action-item demo-btn" data-panel="friend"><b>♧</b><span>好友</span></button><button class="action-item demo-btn" data-panel="guild"><b>♜</b><span>公会</span></button><button class="action-item demo-btn" data-panel="love"><b>♥</b><span>情侣</span></button><button class="action-item demo-btn" data-panel="observer"><b>◉</b><span>观战</span></button></div>',
|
||||
growth: '<p class="sheet-note">成长功能统一处理角色长期养成;技能、坐骑和表情仍归入角色页面,避免重复入口。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="refine"><b>⚒</b><span>精炼</span></button><button class="action-item demo-btn" data-panel="dragon"><b>✧</b><span>龙魂</span></button><button class="action-item demo-btn" data-panel="cube"><b>▣</b><span>制作</span></button></div>',
|
||||
adventure: '<p class="sheet-note">活动通过任务追踪、场景交互或这里进入;地图仍只通过右上小地图打开。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="dungeon"><b>▣</b><span>副本</span></button><button class="action-item demo-btn" data-panel="fishing"><b>♨</b><span>钓鱼</span></button></div>',
|
||||
market: '<p class="sheet-note">涉及货币与物品流转的功能统一归入商业;背包仍在首页右上角单独打开。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="shop"><b>▤</b><span>商店</span></button><button class="action-item demo-btn" data-panel="mall"><b>◈</b><span>商城</span></button><button class="action-item demo-btn" data-panel="trade"><b>⇄</b><span>交易</span></button><button class="action-item demo-btn" data-panel="private_shop"><b>▥</b><span>私人商店</span></button><button class="action-item demo-btn" data-panel="safebox"><b>▤</b><span>仓库</span></button><button class="action-item demo-btn" data-panel="exchange"><b>↔</b><span>兑换</span></button></div>',
|
||||
friend: '<p class="sheet-note">好友与申请使用统一列表;选择好友后可私聊、查看资料或邀请组队。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看好友申请">+<span>申请</span></button><button class="action-item demo-btn" data-toast="打开好友资料">♧<span>资料</span></button><button class="action-item demo-btn" data-toast="发起私聊">☵<span>私聊</span></button><button class="action-item demo-btn" data-toast="邀请组队">♟<span>组队</span></button></div>',
|
||||
love: '<p class="sheet-note">情侣与爱意信息放在社交分组内,避免成为首页固定入口。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看关系信息">♥<span>关系</span></button><button class="action-item demo-btn" data-toast="查看爱意等级">✦<span>等级</span></button><button class="action-item demo-btn" data-toast="发送互动">☺<span>互动</span></button><button class="action-item demo-btn" data-toast="打开情侣设置">⚙<span>设置</span></button></div>',
|
||||
skills: '<p class="sheet-note">技能分为主动、被动、坐骑和表情动作;战斗中的技能按钮由此处配置。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开主动技能">❄<span>主动</span></button><button class="action-item demo-btn" data-toast="打开被动技能">✧<span>被动</span></button><button class="action-item demo-btn" data-toast="打开坐骑技能">♞<span>坐骑</span></button><button class="action-item demo-btn" data-toast="打开表情动作">☺<span>表情</span></button></div>',
|
||||
shop: '<p class="sheet-note">NPC 商店支持购买和出售;商城使用同一套商品详情与确认交互。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="打开购买列表">+<span>购买</span></button><button class="action-item demo-btn" data-toast="打开出售列表">−<span>出售</span></button><button class="action-item demo-btn" data-toast="查看商品详情">? <span>详情</span></button><button class="action-item demo-btn" data-toast="确认交易金额">✓<span>确认</span></button></div>',
|
||||
mall: '<p class="sheet-note">商城从菜单进入,购买前查看价格、限购和获得方式,支付动作使用二次确认。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="浏览商城分类">▤<span>分类</span></button><button class="action-item demo-btn" data-toast="查看商品详情">? <span>详情</span></button><button class="action-item demo-btn" data-toast="加入购物车">+<span>加入</span></button><button class="action-item demo-btn" data-toast="确认购买">✓<span>购买</span></button></div>',
|
||||
safebox: '<p class="sheet-note">仓库采用背包与仓库左右切换;长按物品看详情,拖动完成存入或取出。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="存入仓库">↓<span>存入</span></button><button class="action-item demo-btn" data-toast="取出物品">↑<span>取出</span></button><button class="action-item demo-btn" data-toast="整理仓库">✦<span>整理</span></button><button class="action-item demo-btn" data-toast="切换商城仓库">▤<span>商城仓库</span></button></div>',
|
||||
trade: '<p class="sheet-note">玩家交易通过点击玩家或交互按钮进入;双方放入物品后分别确认,避免误操作。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="拖入交易物品">⇄<span>放入</span></button><button class="action-item demo-btn" data-toast="查看对方物品">? <span>查看</span></button><button class="action-item demo-btn" data-toast="锁定交易内容">▣<span>锁定</span></button><button class="action-item demo-btn" data-toast="等待对方确认">✓<span>确认</span></button></div>',
|
||||
private_shop: '<p class="sheet-note">私人商店通过点击摊位或玩家进入;浏览和摆摊管理共用一个全屏面板。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="浏览私人商店">▤<span>浏览</span></button><button class="action-item demo-btn" data-toast="购买摊位物品">+<span>购买</span></button><button class="action-item demo-btn" data-toast="打开摆摊管理">⚒<span>管理</span></button><button class="action-item demo-btn" data-toast="关闭私人商店">×<span>关闭</span></button></div>',
|
||||
exchange: '<p class="sheet-note">兑换界面展示兑换材料、产出和数量;滑动数量后点击兑换并确认。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择兑换材料">▥<span>材料</span></button><button class="action-item demo-btn" data-toast="调整兑换数量">↔<span>数量</span></button><button class="action-item demo-btn" data-toast="查看兑换产出">✦<span>产出</span></button><button class="action-item demo-btn" data-toast="确认兑换">✓<span>兑换</span></button></div>',
|
||||
cube: '<p class="sheet-note">Cube 制作将材料槽放在中央,底部只保留一个主要制作按钮,制作完成后弹出结果。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择制作配方">▤<span>配方</span></button><button class="action-item demo-btn" data-toast="放入制作材料">+<span>材料</span></button><button class="action-item demo-btn" data-toast="查看制作结果">? <span>预览</span></button><button class="action-item demo-btn" data-toast="确认制作">⚒<span>制作</span></button></div>',
|
||||
refine: '<p class="sheet-note">精炼和龙魂使用同一套强化确认流程:选择装备、查看成功率和消耗,再确认结果。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择强化装备">⚔<span>装备</span></button><button class="action-item demo-btn" data-toast="查看强化材料">▥<span>材料</span></button><button class="action-item demo-btn" data-toast="查看成功率">%<span>成功率</span></button><button class="action-item demo-btn" data-toast="确认强化">✧<span>强化</span></button></div>',
|
||||
dragon: '<p class="sheet-note">龙魂属于制作与强化分支,保留独立页面入口但不放在首页常驻 HUD。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看龙魂槽位">✧<span>槽位</span></button><button class="action-item demo-btn" data-toast="选择龙魂">◇<span>选择</span></button><button class="action-item demo-btn" data-toast="查看龙魂属性">? <span>属性</span></button><button class="action-item demo-btn" data-toast="确认镶嵌">✓<span>镶嵌</span></button></div>',
|
||||
fishing: '<p class="sheet-note">钓鱼是场景情境交互:靠近钓点后出现交互按钮,完成小游戏后返回场景。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="开始钓鱼">♨<span>开始</span></button><button class="action-item demo-btn" data-toast="收杆">↗<span>收杆</span></button><button class="action-item demo-btn" data-toast="查看鱼获">▥<span>鱼获</span></button><button class="action-item demo-btn" data-toast="退出钓鱼">×<span>退出</span></button></div>',
|
||||
dungeon: '<p class="sheet-note">副本入口通过任务导航或场景交互按钮进入;副本内显示目标罗盘,完成后进入结算。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看副本目标">⌖<span>目标</span></button><button class="action-item demo-btn" data-toast="查看队伍状态">♟<span>队伍</span></button><button class="action-item demo-btn" data-toast="退出副本">×<span>退出</span></button><button class="action-item demo-btn" data-toast="开始副本">▣<span>进入</span></button></div>',
|
||||
mount: '<p class="sheet-note">坐骑从技能页面配置,也可在场景中通过情境按钮召唤和收起。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="召唤坐骑">♞<span>召唤</span></button><button class="action-item demo-btn" data-toast="打开坐骑技能">✧<span>技能</span></button><button class="action-item demo-btn" data-toast="更换坐骑">⇄<span>更换</span></button><button class="action-item demo-btn" data-toast="收起坐骑">×<span>收起</span></button></div>',
|
||||
observer: '<p class="sheet-note">观战从社交菜单或队员资料进入,观战时隐藏自身战斗操作,保留退出观战按钮。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="选择观战对象">◉<span>对象</span></button><button class="action-item demo-btn" data-toast="切换观战视角">↔<span>视角</span></button><button class="action-item demo-btn" data-toast="查看战斗信息">? <span>信息</span></button><button class="action-item demo-btn" data-toast="退出观战">×<span>退出</span></button></div>',
|
||||
emote: '<p class="sheet-note">表情动作从技能菜单配置,战斗中不占常驻按钮;选择后在角色头顶显示反馈。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="播放表情:挥手">☺<span>挥手</span></button><button class="action-item demo-btn" data-toast="播放表情:欢呼">✦<span>欢呼</span></button><button class="action-item demo-btn" data-toast="播放动作:坐下">♟<span>动作</span></button><button class="action-item demo-btn" data-toast="设置快捷表情">+<span>配置</span></button></div>',
|
||||
system: '<p class="sheet-note">系统菜单从右上角进入,采用右侧抽屉;设置和帮助进入全屏面板,离开游戏使用确认弹窗。</p><div class="action-grid"><button class="action-item demo-btn" data-panel="options"><b>⚙</b><span>设置</span></button><button class="action-item demo-btn" data-panel="help"><b>?</b><span>帮助</span></button><button class="action-item demo-btn" data-toast="打开角色选择">♜<span>角色</span></button><button class="action-item demo-btn" data-toast="打开退出确认">×<span>退出</span></button></div>',
|
||||
options: '<p class="sheet-note">设置分为音频、画面、操作和社交屏蔽;滑动条与开关都在全屏面板中完成。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="调整音乐音量">♫<span>音乐</span></button><button class="action-item demo-btn" data-toast="调整音效音量">♪<span>音效</span></button><button class="action-item demo-btn" data-toast="调整镜头距离">◎<span>镜头</span></button><button class="action-item demo-btn" data-toast="保存设置">✓<span>保存</span></button></div>',
|
||||
help: '<p class="sheet-note">帮助按移动、战斗、任务、社交和系统分类,使用短卡片说明,支持上下滚动。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="查看移动说明">↕<span>移动</span></button><button class="action-item demo-btn" data-toast="查看战斗说明">⚔<span>战斗</span></button><button class="action-item demo-btn" data-toast="查看系统说明">⚙<span>系统</span></button><button class="action-item demo-btn" data-toast="查看快捷操作">✦<span>快捷操作</span></button></div>',
|
||||
reconnect: '<p class="sheet-note">断线时覆盖场景显示连接状态;恢复成功后自动回到原位置,失败时提供重试或返回角色列表。</p><div class="action-grid"><button class="action-item demo-btn" data-toast="重新连接中">↻<span>重试</span></button><button class="action-item demo-btn" data-toast="查看网络状态">⌁<span>网络</span></button><button class="action-item demo-btn" data-toast="返回角色列表">♜<span>角色列表</span></button><button class="action-item demo-btn" data-toast="关闭提示">×<span>关闭</span></button></div>'
|
||||
});
|
||||
const titles = { inventory: '背包', character: '角色', social: '社交', growth: '成长', adventure: '活动', market: '商业', skills: '技能', quest: '任务', map: '地图', party: '社交 / 队伍', friend: '好友', love: '情侣 / 爱意', guild: '公会', target: '目标', chat: '聊天', shop: '商店', mall: '商城', safebox: '仓库', trade: '玩家交易', private_shop: '私人商店', exchange: '兑换', cube: 'Cube 制作', refine: '精炼 / 强化', dragon: '龙魂', fishing: '钓鱼', dungeon: '副本', mount: '坐骑', observer: '观战', emote: '表情动作', system: '系统', help: '帮助', options: '设置', more: '功能菜单' };
|
||||
|
||||
const flowDetails = {
|
||||
login: { kind: '全屏入口', title: '登录界面', entry: '启动游戏后全屏显示;账号密码输入区保持在横屏安全区中央。', touch: '点击输入框唤起键盘,点击登录提交;错误信息在表单下方即时提示。', back: '登录成功进入服务器选择;失败留在当前页并保留已输入账号。', note: '不与战斗 HUD 共存。' },
|
||||
server: { kind: '全屏入口', title: '服务器 / 频道选择', entry: '登录成功后进入;服务器列表居中,推荐频道置顶。', touch: '点击频道选中,再点击进入;网络状态和人数使用标签显示。', back: '返回登录界面,或进入角色列表。', note: '连接失败直接复用断线重连提示。' },
|
||||
character_select: { kind: '全屏入口', title: '角色列表与角色管理', entry: '服务器选择后进入;角色卡横向排列。', touch: '点击角色选中,点击创建、删除、改名;删除和改名必须二次确认。', back: '返回服务器选择或进入加载界面。', note: '阵营、职业和等级放在角色卡内,不进入战斗 HUD。' },
|
||||
loading: { kind: '状态弹窗', title: '加载界面', entry: '角色进入场景、切换地图或副本时覆盖全屏。', touch: '无需操作;加载失败显示重试按钮。', back: '加载完成进入首页 HUD,异常时转断线重连。', note: '避免用户在资源未完成时点击世界。' },
|
||||
hud: { kind: '常驻 HUD', title: '首页 HUD', entry: '进入场景后的默认页面;世界画面优先。', touch: '左上头像看角色,右上背包/小地图/菜单,任务栏直接进入任务,底部两侧完成移动和战斗。', back: '所有功能面板关闭后回到这里。', note: '名称在头像顶部,HP/MP 条在头像下方且与头像同宽。' },
|
||||
movement: { kind: '常驻 HUD', title: '移动摇杆', entry: '首页左下角,仅作为战斗操作控件。', touch: '按住并拖动控制方向,松手复位;支持边缘拖动防止拇指滑出。', back: '没有独立页面,松手即返回待机状态。', note: '底部中央保持空白,不放功能菜单。' },
|
||||
target: { kind: '常驻 HUD', title: '目标选择与目标板', entry: '点击敌人、NPC 或目标板触发;目标板位于顶部中央。', touch: '点击切换目标,长按查看信息;普通攻击和技能自动作用于当前目标。', back: '点击空白清除目标,或选择新目标。', note: '目标血条不与玩家血条混淆。' },
|
||||
combat_action: { kind: '常驻 HUD', title: '普通攻击与技能', entry: '首页右下角环形战斗盘。', touch: '点击普攻;点击技能释放;长按可预览需要瞄准的技能;冷却用灰度和数字反馈。', back: '释放完成回到战斗状态,技能配置从菜单进入。', note: '药水可作为战斗盘上的物品按钮,不另设底部功能栏。' },
|
||||
loot: { kind: '场景弹窗', title: '掉落物与情境交互', entry: '靠近掉落物、NPC、钓点或副本入口时,在角色附近出现交互按钮。', touch: '点击拾取、对话、钓鱼或进入;掉落物名称显示在世界中并支持点击。', back: '动作完成回到场景;物品自动进入背包并给出短暂提示。', note: '不在首页固定显示拾取菜单。' },
|
||||
death: { kind: '场景弹窗', title: '死亡与复活', entry: 'HP 归零后覆盖场景中央。', touch: '点击原地复活、回城或等待;危险选项二次确认。', back: '复活后回到首页 HUD,回城后进入加载流程。', note: '死亡时暂时禁用摇杆和技能按钮。' },
|
||||
quest: { kind: '常驻 HUD', title: '任务追踪栏', entry: '首页上方常驻显示,不放入右上菜单。', touch: '点击任务行进入日志;点击目标或导航标记进入地图/自动寻路。', back: '关闭任务日志后回到首页,任务进度仍保留。', note: '任务栏是任务系统的唯一首页快捷入口。' },
|
||||
quest_log: { kind: '全屏面板', title: '任务日志', entry: '点击任务追踪栏打开;采用列表 + 详情布局。', touch: '点击任务切换详情,滚动查看奖励;点击追踪更新首页任务栏。', back: '关闭后返回首页 HUD,不改变当前追踪任务。', note: '任务地图标记从详情页进入。' },
|
||||
map: { kind: '全屏面板', title: '小地图与大地图', entry: '点击右上小地图进入;没有独立地图按钮。', touch: '双指缩放、拖动地图、点击信标导航,点击定位回到玩家位置。', back: '点击关闭或系统返回回到首页 HUD。', note: '背包已替代原地图快捷按钮。' },
|
||||
navigation: { kind: '常驻 + 场景', title: '自动寻路', entry: '从任务详情、地图信标或 NPC 目标触发。', touch: '点击导航开始;到达后显示交互按钮;移动摇杆可随时接管。', back: '手动移动、点击停止或到达目的地后结束。', note: '不额外占用常驻 HUD 按钮。' },
|
||||
npc_quest: { kind: '场景弹窗', title: 'NPC 对话与任务', entry: '靠近 NPC 后点击角色或情境交互按钮。', touch: '点击对话选项;接取/完成任务使用明确的主按钮。', back: '关闭对话回到场景;完成后刷新任务栏和奖励提示。', note: '商店、仓库和任务可以在同一 NPC 对话入口分流。' },
|
||||
dungeon_result: { kind: '场景弹窗', title: '副本结算', entry: '副本完成或失败后自动弹出。', touch: '滚动查看奖励,点击再次挑战或退出;奖励领取使用主按钮。', back: '退出回到场景,或再次挑战进入加载界面。', note: '副本内保留目标罗盘和队伍状态。' },
|
||||
character_entry: { kind: '头像入口', title: '角色功能入口', entry: '只点击左上角玩家头像进入。', touch: '点击头像进入角色页,在其中切换属性、装备、技能、坐骑和外观。', back: '关闭角色面板回到首页 HUD。', note: '角色不出现在右上菜单。' },
|
||||
character_status: { kind: '全屏面板', title: '角色属性', entry: '左上头像 → 角色属性。', touch: '左右切换属性分类,滚动查看等级、属性、经验和资源。', back: '返回角色入口,再返回首页。', note: '战斗中只展示精简状态条。' },
|
||||
equipment: { kind: '全屏面板', title: '装备界面', entry: '左上头像 → 角色 → 装备。', touch: '点击装备槽查看,长按物品详情,拖动穿戴或替换。', back: '关闭装备页返回角色或首页。', note: '物品对比使用上下对照卡片。' },
|
||||
skills: { kind: '全屏面板', title: '技能与快捷栏配置', entry: '左上头像 → 角色 → 技能。', touch: '点击技能查看说明,长按查看数值,拖动到首页技能槽。', back: '保存配置后回到首页,技能按钮即时更新。', note: '主动、被动、坐骑、表情分标签显示。' },
|
||||
skill_upgrade: { kind: '场景弹窗', title: '技能升级 / 加点', entry: '技能详情页点击升级。', touch: '点击加点预览消耗,再点击确认;资源不足显示原因。', back: '确认后刷新技能等级,取消回到技能详情。', note: '升级不可误触,必须二次确认。' },
|
||||
inspect: { kind: '场景弹窗', title: '查看其他玩家装备', entry: '点击场景玩家后选择查看,或从队伍/好友资料进入。', touch: '点击玩家资料页签查看装备、等级和公会;不可修改对方数据。', back: '关闭资料卡回到场景或社交列表。', note: '交易、私聊、组队使用同一上下文操作栏。' },
|
||||
inventory: { kind: '全屏面板', title: '背包', entry: '右上背包快捷按钮或菜单 → 背包。', touch: '点击选中,长按详情,拖动整理/装备/放入快捷栏;使用和出售用明确按钮。', back: '关闭返回首页,物品操作结果用 toast 提示。', note: '腰带、装备栏、任务物品作为背包内页。' },
|
||||
item_tooltip: { kind: '场景弹窗', title: '物品详情提示', entry: '背包、仓库、商店或掉落物点击/长按。', touch: '点击物品查看详情;长按保持详情卡,点击操作按钮执行动作。', back: '点空白或返回关闭详情卡。', note: '详情卡不遮挡关键操作区域。' },
|
||||
item_action: { kind: '场景弹窗', title: '使用、装备、出售', entry: '物品详情卡底部操作区。', touch: '点击主操作,数量类动作弹出数量选择;出售和稀有物品使用二次确认。', back: '完成后刷新背包,取消回到详情卡。', note: '拖放和点击操作同时支持。' },
|
||||
safebox: { kind: '全屏面板', title: '仓库与扩展物品栏', entry: '菜单 → 背包 → 仓库,或靠近仓库 NPC 后进入。', touch: '左右切换背包/仓库/商城仓库;拖动物品存入或取出。', back: '关闭面板返回 NPC 对话或首页。', note: '私人商店背包沿用同一物品组件。' },
|
||||
select_item: { kind: '场景弹窗', title: '任务物品选择', entry: '任务奖励、提交材料或任务详情中的选择按钮。', touch: '点击物品,滑动或加减调整数量,提交前显示所需数量。', back: '确认后回到任务流程,取消不消耗物品。', note: '数量选择不使用难操作的小型输入框。' },
|
||||
social_entry: { kind: '右上角菜单', title: '社交功能入口', entry: '右上菜单 → 社交;队伍不出现在社交菜单。', touch: '点击聊天、好友、情侣、公会或观战进入对应页面。', back: '关闭社交页面回到首页 HUD。', note: '组队信息固定显示在左上玩家头像下方。' },
|
||||
chat: { kind: '全屏面板', title: '聊天与战斗日志', entry: '菜单 → 社交 → 聊天;新消息以短暂提示或气泡显示。', touch: '点击频道切换,点击输入框唤起键盘,长按消息进行复制/私聊。', back: '关闭回到首页,未读数显示在菜单角标。', note: '综合、私聊、系统、战斗、队伍、公会统一在频道中。' },
|
||||
party: { kind: '左侧 HUD', title: '队伍信息', entry: '组队后显示在左上玩家头像下方;未组队时自动隐藏。', touch: '点击队员查看资料或定位,长按打开队伍设置;邀请通过好友或玩家交互触发。', back: '关闭队员卡回到首页,队伍血量和距离继续常驻显示。', note: '队伍不出现在社交菜单,也不设置独立首页按钮。' },
|
||||
friend: { kind: '全屏面板', title: '好友与好友申请', entry: '菜单 → 社交 → 好友,或点击玩家资料。', touch: '点击申请、同意、私聊、查看资料;列表上下滚动。', back: '返回社交菜单或首页。', note: '好友申请通过临时弹窗提醒。' },
|
||||
love: { kind: '全屏面板', title: '情侣 / 爱意', entry: '菜单 → 社交 → 情侣 / 爱意。', touch: '点击关系资料和互动动作;关键关系操作使用确认。', back: '关闭返回社交或首页。', note: '低频关系信息不占用首页 HUD。' },
|
||||
guild: { kind: '全屏面板', title: '公会', entry: '菜单 → 社交 → 公会。', touch: '顶部标签切换成员、技能、公告、公会战和会徽;列表支持滚动。', back: '返回社交菜单或首页。', note: '公会邀请通过临时确认弹窗进入。' },
|
||||
commerce_entry: { kind: '场景弹窗', title: 'NPC / 玩家情境入口', entry: '靠近 NPC、玩家、摊位或制作设施后显示交互按钮。', touch: '点击交互按钮,从上下文菜单选择对话、商店、交易、仓库或制作。', back: '关闭上下文菜单返回场景。', note: '只在附近有可交互对象时出现。' },
|
||||
shop: { kind: '全屏面板', title: 'NPC 商店与商城', entry: 'NPC 交互 → 商店,或菜单 → 商店 / 商城。', touch: '点击商品查看详情,数量选择后购买/出售;支付前二次确认。', back: '返回 NPC 对话或首页。', note: '商城使用同一套物品详情和确认组件。' },
|
||||
trade: { kind: '全屏面板', title: '玩家交易', entry: '点击玩家 → 交易,或场景交互菜单进入。', touch: '拖动物品到交易栏,双方锁定后分别确认;内容变化时重新确认。', back: '取消交易返回场景,已锁定物品自动退回。', note: '交易金额和物品变化必须明显反馈。' },
|
||||
private_shop: { kind: '全屏面板', title: '私人商店', entry: '点击玩家摊位进入浏览;自己的摊位从菜单管理。', touch: '点击商品购买,管理页拖动物品上架;上架/下架有确认提示。', back: '关闭返回场景,摊位状态保留。', note: '使用背包的物品组件,减少学习成本。' },
|
||||
cube: { kind: '全屏面板', title: 'Cube 制作', entry: '菜单 → 交易与制作 → Cube,或靠近制作设施。', touch: '选择配方,拖入材料,查看产出预览,点击制作。', back: '结果弹窗关闭后回到制作页或首页。', note: '制作按钮放在面板底部,不占用首页底部。' },
|
||||
refine: { kind: '全屏 + 弹窗', title: '精炼 / 强化 / 龙魂', entry: '菜单 → 交易与制作 → 精炼,龙魂作为同组页签。', touch: '选择装备、查看材料和成功率,确认强化;结果用中央弹窗反馈。', back: '返回制作菜单或首页,失败也明确显示消耗。', note: '龙魂当前代码入口需单独开启后再接入正式菜单。' },
|
||||
system_entry: { kind: '右上角菜单', title: '系统入口', entry: '右上角菜单最外侧按钮。', touch: '点击菜单打开右侧抽屉,点击遮罩或关闭按钮收起。', back: '回到首页 HUD,世界输入恢复。', note: '菜单打开时暂停战斗点击,防止误操作。' },
|
||||
options: { kind: '全屏面板', title: '系统设置与游戏设置', entry: '菜单 → 系统 → 设置。', touch: '滑动音量、镜头距离等,点击开关控制 PK、名称、伤害数字和屏蔽项。', back: '即时保存后返回菜单或首页。', note: '设置项按音频、画面、操作、社交分组。' },
|
||||
help: { kind: '全屏面板', title: '帮助界面', entry: '菜单 → 系统 → 帮助。', touch: '点击分类卡片,上下滚动查看说明;可从快捷入口直接跳转。', back: '返回系统菜单或首页。', note: '帮助文案与实际移动端手势保持一致。' },
|
||||
character_switch: { kind: '全屏 + 弹窗', title: '选择角色', entry: '菜单 → 系统 → 选择角色。', touch: '点击后先保存并弹出确认,再回到角色列表。', back: '取消确认继续当前角色。', note: '切换角色触发加载流程。' },
|
||||
logout: { kind: '场景弹窗', title: '登出 / 退出游戏', entry: '菜单 → 系统 → 登出或退出。', touch: '点击后弹出确认,显示未保存状态;确认后返回登录或关闭游戏。', back: '取消后回到系统菜单。', note: '属于高风险操作,必须二次确认。' },
|
||||
reconnect: { kind: '状态弹窗', title: '断线重连', entry: '网络中断时覆盖当前页面,不清除当前 HUD 状态。', touch: '自动重试;用户可点击重试、查看网络或返回角色列表。', back: '连接成功恢复原场景,失败则进入角色列表。', note: '恢复后保留任务追踪、目标和队伍状态。' }
|
||||
};
|
||||
|
||||
function showToast(message) {
|
||||
toast.textContent = message;
|
||||
toast.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => toast.classList.remove('show'), 1400);
|
||||
}
|
||||
function currentVariantRoot(el) { return el.closest('.variant'); }
|
||||
function renderPanel(root, key, history = []) {
|
||||
const modal = root.querySelector('[data-modal]');
|
||||
const title = root.querySelector('[data-sheet-title]');
|
||||
const bodyNode = root.querySelector('[data-sheet-body]');
|
||||
const back = root.querySelector('[data-back]');
|
||||
title.textContent = titles[key] || key;
|
||||
bodyNode.innerHTML = featureCopy[key] || `<p class="sheet-note">${titles[key] || key} 面板草图。</p>`;
|
||||
modal.dataset.current = key;
|
||||
modal.dataset.history = JSON.stringify(history);
|
||||
back.classList.toggle('hidden', history.length === 0);
|
||||
bindDynamic(bodyNode);
|
||||
}
|
||||
function openPanel(trigger, key) {
|
||||
const root = currentVariantRoot(trigger);
|
||||
const modal = root.querySelector('[data-modal]');
|
||||
const history = modal.classList.contains('open') && modal.dataset.current ? JSON.parse(modal.dataset.history || '[]') : [];
|
||||
if (modal.classList.contains('open') && modal.dataset.current && modal.dataset.current !== key) history.push(modal.dataset.current);
|
||||
renderPanel(root, key, history);
|
||||
modal.classList.add('open');
|
||||
}
|
||||
function closePanel(trigger) {
|
||||
const modal = trigger.closest('[data-modal]');
|
||||
modal.classList.remove('open');
|
||||
modal.dataset.current = '';
|
||||
modal.dataset.history = '[]';
|
||||
}
|
||||
function backPanel(trigger) {
|
||||
const modal = trigger.closest('[data-modal]');
|
||||
const history = JSON.parse(modal.dataset.history || '[]');
|
||||
const key = history.pop();
|
||||
if (!key) return;
|
||||
renderPanel(trigger.closest('.variant'), key, history);
|
||||
}
|
||||
function bindDynamic(scope = document) {
|
||||
scope.querySelectorAll('[data-toast]').forEach(btn => {
|
||||
if (btn.dataset.bound) return;
|
||||
btn.dataset.bound = '1';
|
||||
btn.addEventListener('click', () => showToast(btn.dataset.toast));
|
||||
});
|
||||
const send = scope.querySelector('[data-send]');
|
||||
if (send && !send.dataset.bound) {
|
||||
send.dataset.bound = '1';
|
||||
send.addEventListener('click', () => {
|
||||
const input = scope.querySelector('input');
|
||||
if (!input.value.trim()) { showToast('请输入聊天内容'); return; }
|
||||
showToast('消息已发送'); input.value = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
const flowDetail = document.getElementById('flow-detail');
|
||||
function renderFlowDetail(key) {
|
||||
const detail = flowDetails[key];
|
||||
if (!detail) return;
|
||||
flowDetail.classList.remove('empty');
|
||||
flowDetail.innerHTML = `<div class="detail-kicker">${detail.kind}</div><h3>${detail.title}</h3><div class="detail-grid"><div class="detail-block"><strong>入口 / 所在位置</strong><p>${detail.entry}</p></div><div class="detail-block"><strong>移动端触控</strong><p>${detail.touch}</p></div><div class="detail-block"><strong>返回路径</strong><p>${detail.back}</p></div></div><p class="flow-note">设计备注:${detail.note}</p>`;
|
||||
flowDetail.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
document.querySelectorAll('[data-detail]').forEach(node => node.addEventListener('click', () => renderFlowDetail(node.dataset.detail)));
|
||||
document.querySelectorAll('[data-flow-filter]').forEach(filter => filter.addEventListener('click', () => {
|
||||
const value = filter.dataset.flowFilter;
|
||||
document.querySelectorAll('[data-flow-filter]').forEach(item => {
|
||||
const active = item === filter;
|
||||
item.classList.toggle('active', active);
|
||||
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('.flow-card').forEach(card => card.classList.toggle('is-hidden', value !== 'all' && card.dataset.flowGroup !== value));
|
||||
showToast(value === 'all' ? '显示全部交互流程' : '已筛选交互流程');
|
||||
}));
|
||||
document.addEventListener('click', event => {
|
||||
const btn = event.target.closest('.demo-btn');
|
||||
if (btn) {
|
||||
if (btn.dataset.panel) openPanel(btn, btn.dataset.panel);
|
||||
if (btn.dataset.skill) {
|
||||
showToast(btn.dataset.toast || ('触发:' + btn.dataset.skill));
|
||||
if (btn.dataset.skill !== '普攻') {
|
||||
btn.classList.add('cooling');
|
||||
const badge = btn.querySelector('em');
|
||||
if (badge) badge.textContent = '3';
|
||||
setTimeout(() => { btn.classList.remove('cooling'); if (badge) badge.textContent = ''; }, 900);
|
||||
}
|
||||
}
|
||||
}
|
||||
const close = event.target.closest('[data-close]');
|
||||
if (close) closePanel(close);
|
||||
const back = event.target.closest('[data-back]');
|
||||
if (back) backPanel(back);
|
||||
if (event.target.matches('[data-modal]')) closePanel(event.target.querySelector('[data-close]'));
|
||||
});
|
||||
document.querySelectorAll('[data-viewport]').forEach(btn => btn.addEventListener('click', () => {
|
||||
canvas.className = 'preview-' + btn.dataset.viewport;
|
||||
showToast('预览尺寸:' + btn.textContent);
|
||||
}));
|
||||
document.getElementById('annotation-toggle').addEventListener('click', event => {
|
||||
body.classList.toggle('annotate');
|
||||
event.currentTarget.textContent = body.classList.contains('annotate') ? '关闭标注' : '标注';
|
||||
showToast(body.classList.contains('annotate') ? '标注模式已开启' : '标注模式已关闭');
|
||||
});
|
||||
const annotation = document.getElementById('annotation-label');
|
||||
document.addEventListener('mousemove', event => {
|
||||
if (!body.classList.contains('annotate')) return;
|
||||
const el = event.target.closest('[data-label]');
|
||||
if (!el) { annotation.style.display = 'none'; return; }
|
||||
annotation.textContent = el.dataset.label;
|
||||
annotation.style.left = Math.min(window.innerWidth - 180, event.clientX + 12) + 'px';
|
||||
annotation.style.top = Math.min(window.innerHeight - 40, event.clientY + 12) + 'px';
|
||||
annotation.style.display = 'block';
|
||||
});
|
||||
document.querySelectorAll('[data-joystick]').forEach(joystick => {
|
||||
let active = false;
|
||||
const stick = joystick.querySelector('.stick');
|
||||
const move = event => {
|
||||
if (!active) return;
|
||||
const rect = joystick.getBoundingClientRect();
|
||||
const x = (event.clientX ?? event.touches?.[0]?.clientX) - (rect.left + rect.width / 2);
|
||||
const y = (event.clientY ?? event.touches?.[0]?.clientY) - (rect.top + rect.height / 2);
|
||||
const limit = 26, length = Math.max(1, Math.hypot(x, y)), scale = Math.min(1, limit / length);
|
||||
stick.style.transform = `translate(calc(-50% + ${x * scale}px), calc(-50% + ${y * scale}px))`;
|
||||
};
|
||||
const release = () => { active = false; joystick.classList.remove('is-active'); stick.style.transform = 'translate(-50%, -50%)'; showToast('移动摇杆已复位'); };
|
||||
joystick.addEventListener('pointerdown', event => { active = true; joystick.classList.add('is-active'); joystick.setPointerCapture(event.pointerId); move(event); });
|
||||
joystick.addEventListener('pointermove', move); joystick.addEventListener('pointerup', release); joystick.addEventListener('pointercancel', release);
|
||||
});
|
||||
bindDynamic();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 |
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`、显示遮罩并自动重新进入上次角色,但仍需要在真机和真实服务器上验证网络切换、后台恢复及重连失败边界。低频页面仍需要在真机上做字号、键盘和安全区的视觉微调;当前不能据此视为最终移动端交付完成。
|
||||
@@ -0,0 +1 @@
|
||||
uid://p7skimuw8kud
|
||||
+46
-5
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dn635j1v44kp5
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://ctbdjmg33pe67
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhyev28dml0wh
|
||||
@@ -0,0 +1 @@
|
||||
uid://bilb10438dl5i
|
||||
@@ -0,0 +1 @@
|
||||
uid://dh4ucte7pxc3u
|
||||
@@ -0,0 +1 @@
|
||||
uid://x2vuateiv3w1
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlpn6gv5jrh14
|
||||
@@ -0,0 +1 @@
|
||||
uid://c77cvj7gtqck4
|
||||
@@ -0,0 +1 @@
|
||||
uid://icsacgdld3gb
|
||||
@@ -0,0 +1 @@
|
||||
uid://da5ixd8n62gyh
|
||||
@@ -0,0 +1 @@
|
||||
uid://exadl2wqkpmh
|
||||
@@ -0,0 +1 @@
|
||||
uid://7g66aavury6r
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8hnrnm5whxx
|
||||
@@ -0,0 +1 @@
|
||||
uid://crv0udnsb76ik
|
||||
@@ -0,0 +1 @@
|
||||
uid://dq43hpy353dab
|
||||
@@ -0,0 +1 @@
|
||||
uid://dl4x22ievekvx
|
||||
@@ -0,0 +1 @@
|
||||
uid://faq2wm5101df
|
||||
@@ -0,0 +1 @@
|
||||
uid://ub000mncflac
|
||||
@@ -0,0 +1 @@
|
||||
uid://312cgt6dprj8
|
||||
@@ -0,0 +1 @@
|
||||
uid://bs8t4ae40g81s
|
||||
@@ -0,0 +1 @@
|
||||
uid://vei6jny5w1m2
|
||||
+83
-3
@@ -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,11 +191,16 @@ func setup(m2client: Node, assets_root: String,
|
||||
or int(pickable.get_meta("vid", -1)) == vid:
|
||||
pc.pickables.erase(pickable))
|
||||
|
||||
if _ui_mobile:
|
||||
hud = MobileUiRoot.new()
|
||||
add_child(hud)
|
||||
hud.setup(world, player, client)
|
||||
else:
|
||||
hud = Hud.new()
|
||||
add_child(hud)
|
||||
if _map_loaded():
|
||||
hud.setup(world, player) # 含小地图 / 快捷栏 / 背包
|
||||
else:
|
||||
add_child(hud)
|
||||
hud.call("_build_status") # 无地图兜底:只搭状态栏(血 / 蓝 / 经验 / 等级)
|
||||
|
||||
net_play = NetPlay.new()
|
||||
@@ -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,16 +664,39 @@ 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())
|
||||
_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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://ch0kvrxf13dnh
|
||||
@@ -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),
|
||||
"移动公会页签使用大触控目标")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://iiiuyg47tudg
|
||||
@@ -101,4 +101,25 @@ func _run() -> void:
|
||||
|
||||
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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmarcd40nexwg
|
||||
@@ -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")
|
||||
@@ -0,0 +1 @@
|
||||
uid://j1ufi6gp5au7
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b1vbbltrqfkes
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://j5tq3hj1d8jk
|
||||
@@ -0,0 +1 @@
|
||||
uid://0uv8hln06oex
|
||||
@@ -0,0 +1 @@
|
||||
uid://bqvbpbcp4338q
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3jl5bspgvnf2
|
||||
@@ -0,0 +1 @@
|
||||
uid://cfmkostitpvvu
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0tssfrjgab4y
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjink4dvgp0qu
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwqn6xii0x5xs
|
||||
+15
-5
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://tceq7sornwqf
|
||||
@@ -0,0 +1 @@
|
||||
uid://pqahjhjcoifw
|
||||
@@ -0,0 +1 @@
|
||||
uid://t763fdrluvos
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6slci25nqllo
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://lqisiv6emkkt
|
||||
@@ -0,0 +1 @@
|
||||
uid://s5p8kdn4aywt
|
||||
@@ -0,0 +1 @@
|
||||
uid://db06iedi0733r
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://skkm2dyg8jmx
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci70o6w2yd5cc
|
||||
@@ -0,0 +1 @@
|
||||
uid://dokpyjtoa1s48
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4gu3tfgt3q5d
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5rhqrba42pk5
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dk1va37r3o5b0
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://csy6qy363o028
|
||||
@@ -0,0 +1 @@
|
||||
uid://ckiaafr7cv48r
|
||||
@@ -0,0 +1 @@
|
||||
uid://2wi17v5m0v51
|
||||
@@ -0,0 +1 @@
|
||||
uid://xn5klnex8db2
|
||||
@@ -0,0 +1 @@
|
||||
uid://bmlnjr2tyws0o
|
||||
@@ -0,0 +1 @@
|
||||
uid://d3v7hqq4gtouf
|
||||
@@ -0,0 +1 @@
|
||||
uid://c4hgw5bk51xyv
|
||||
@@ -0,0 +1 @@
|
||||
uid://bumeoghswlc5e
|
||||
+45
-2
@@ -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,10 +227,13 @@ 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
|
||||
_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
|
||||
_map_view.accept_event()
|
||||
|
||||
func _process(_dt: float) -> void:
|
||||
if is_open():
|
||||
|
||||
@@ -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
|
||||
|
||||
+107
-4
@@ -50,6 +50,8 @@ var _fishing_active := false
|
||||
var _whisper_dialog: Panel
|
||||
var _whisper_target: LineEdit
|
||||
var _whisper_message: LineEdit
|
||||
var _mobile_mode := false
|
||||
var _tab_row: HBoxContainer
|
||||
|
||||
func setup(m2client: Node, parent: Node, assets_root := "") -> void:
|
||||
client = m2client
|
||||
@@ -85,6 +87,29 @@ func focus_input() -> void:
|
||||
if _input:
|
||||
_input.grab_focus()
|
||||
|
||||
func set_mobile_mode(enabled: bool) -> void:
|
||||
_mobile_mode = enabled
|
||||
if _root:
|
||||
_apply_mobile_layout()
|
||||
_apply_mobile_whisper_layout()
|
||||
if enabled and _root:
|
||||
# Chat is an on-demand full-screen page on mobile; the landscape HUD has
|
||||
# no permanent bottom chat strip.
|
||||
_root.visible = false
|
||||
|
||||
func close() -> void:
|
||||
if _root:
|
||||
_root.visible = false
|
||||
_close_whisper()
|
||||
|
||||
func get_mobile_windows() -> Array[Control]:
|
||||
var result: Array[Control] = []
|
||||
if _root:
|
||||
result.append(_root)
|
||||
if _whisper_dialog:
|
||||
result.append(_whisper_dialog)
|
||||
return result
|
||||
|
||||
# 预填 "/w <名> " 并聚焦(好友列表点名字时调)。
|
||||
func start_whisper(name: String) -> void:
|
||||
if _whisper_dialog == null or name.strip_edges() == "":
|
||||
@@ -130,9 +155,9 @@ func _build(parent: Node) -> void:
|
||||
panel.add_theme_stylebox_override("panel", sb)
|
||||
_root.add_child(panel)
|
||||
|
||||
var tabrow := HBoxContainer.new()
|
||||
tabrow.position = Vector2(6, 4)
|
||||
_root.add_child(tabrow)
|
||||
_tab_row = HBoxContainer.new()
|
||||
_tab_row.position = Vector2(6, 4)
|
||||
_root.add_child(_tab_row)
|
||||
for i in TABS.size():
|
||||
var b := Button.new()
|
||||
b.text = TABS[i]
|
||||
@@ -141,7 +166,7 @@ func _build(parent: Node) -> void:
|
||||
b.add_theme_font_size_override("font_size", 11)
|
||||
var idx := i
|
||||
b.pressed.connect(func(): _select_tab(idx))
|
||||
tabrow.add_child(b)
|
||||
_tab_row.add_child(b)
|
||||
_tab_btns.append(b)
|
||||
|
||||
for i in TABS.size():
|
||||
@@ -165,6 +190,84 @@ func _build(parent: Node) -> void:
|
||||
_input.text_submitted.connect(_on_submit)
|
||||
_root.add_child(_input)
|
||||
_build_whisper_dialog(parent)
|
||||
_apply_mobile_layout()
|
||||
_apply_mobile_whisper_layout()
|
||||
|
||||
func _apply_mobile_layout() -> void:
|
||||
if _root == null:
|
||||
return
|
||||
if not _mobile_mode:
|
||||
_root.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
||||
_root.position = Vector2(12, -232)
|
||||
_root.size = Vector2(460, 220)
|
||||
if _tab_row:
|
||||
_tab_row.position = Vector2(6, 4)
|
||||
for rt in _log.values():
|
||||
(rt as RichTextLabel).position = Vector2(6, 30)
|
||||
(rt as RichTextLabel).size = Vector2(448, 158)
|
||||
if _input:
|
||||
_input.position = Vector2(6, 192)
|
||||
_input.size = Vector2(448, 24)
|
||||
return
|
||||
# One landscape page with a large text area and a keyboard-friendly input
|
||||
# row. The HUD remains clear until the player explicitly opens chat.
|
||||
_root.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
_root.position = Vector2.ZERO
|
||||
_root.size = Vector2(760, 340)
|
||||
_root.set_meta("mobile_title", "聊天")
|
||||
if _tab_row:
|
||||
_tab_row.position = Vector2(14, 48)
|
||||
_tab_row.size = Vector2(732, 40)
|
||||
_tab_row.add_theme_constant_override("separation", 6)
|
||||
for child in _tab_row.get_children():
|
||||
child.custom_minimum_size = Vector2(112, 38)
|
||||
child.add_theme_font_size_override("font_size", 12)
|
||||
for rt in _log.values():
|
||||
(rt as RichTextLabel).position = Vector2(14, 94)
|
||||
(rt as RichTextLabel).size = Vector2(732, 190)
|
||||
(rt as RichTextLabel).add_theme_font_size_override("normal_font_size", 14)
|
||||
if _input:
|
||||
_input.position = Vector2(14, 294)
|
||||
_input.size = Vector2(732, 38)
|
||||
_input.add_theme_font_size_override("font_size", 13)
|
||||
|
||||
func _apply_mobile_whisper_layout() -> void:
|
||||
if _whisper_dialog == null:
|
||||
return
|
||||
if not _mobile_mode:
|
||||
_whisper_dialog.set_anchors_preset(Control.PRESET_CENTER)
|
||||
_whisper_dialog.position = Vector2(-170, -82)
|
||||
_whisper_dialog.size = Vector2(340, 164)
|
||||
return
|
||||
_whisper_dialog.set_anchors_preset(Control.PRESET_TOP_LEFT)
|
||||
_whisper_dialog.position = Vector2.ZERO
|
||||
_whisper_dialog.size = Vector2(480, 260)
|
||||
_whisper_dialog.set_meta("mobile_title", "私聊")
|
||||
var title := _whisper_dialog.get_child(0) as Label
|
||||
if title:
|
||||
title.position = Vector2(18, 50)
|
||||
title.add_theme_font_size_override("font_size", 15)
|
||||
var target_label := _whisper_dialog.get_child(1) as Label
|
||||
if target_label:
|
||||
target_label.position = Vector2(18, 94)
|
||||
var message_label := _whisper_dialog.get_child(3) as Label
|
||||
if message_label:
|
||||
message_label.position = Vector2(18, 148)
|
||||
if _whisper_target:
|
||||
_whisper_target.position = Vector2(86, 90)
|
||||
_whisper_target.size = Vector2(370, 42)
|
||||
if _whisper_message:
|
||||
_whisper_message.position = Vector2(86, 144)
|
||||
_whisper_message.size = Vector2(370, 42)
|
||||
_whisper_message.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_DEFAULT
|
||||
var cancel := _whisper_dialog.get_node_or_null("Button") as Button
|
||||
var send := _whisper_dialog.get_node_or_null("Button2") as Button
|
||||
if cancel:
|
||||
cancel.position = Vector2(174, 202)
|
||||
cancel.size = Vector2(130, 44)
|
||||
if send:
|
||||
send.position = Vector2(316, 202)
|
||||
send.size = Vector2(130, 44)
|
||||
|
||||
func _build_whisper_dialog(parent: Node) -> void:
|
||||
# root/game.py 的 OpenWhisperDialog 是独立窗口,不应把私聊目标写回公共
|
||||
|
||||
+46
-2
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://ku3jcc4x80r
|
||||
@@ -0,0 +1 @@
|
||||
uid://bwp78bnxnfvvx
|
||||
@@ -27,6 +27,9 @@ var _offered_cells := {}
|
||||
var _self_grid: GridContainer
|
||||
var _exchange_cells: Dictionary = {}
|
||||
var item_mouse: Node
|
||||
var _mobile_mode := false
|
||||
var _mobile_inventory_scroll: ScrollContainer
|
||||
var _mobile_inventory_list: VBoxContainer
|
||||
|
||||
const EXCHANGE_SLOT_COUNT := 12
|
||||
const INVENTORY_WINDOW := 1
|
||||
@@ -49,6 +52,15 @@ func setup(m2client: Node, parent: Node, proto_node: Node = null) -> void:
|
||||
func is_open() -> bool:
|
||||
return _root != null and _root.visible
|
||||
|
||||
func get_mobile_window() -> Control:
|
||||
return _root
|
||||
|
||||
func set_mobile_mode(enabled: bool) -> void:
|
||||
_mobile_mode = enabled
|
||||
if _root:
|
||||
_apply_mobile_layout()
|
||||
_refresh_mobile_inventory()
|
||||
|
||||
func close() -> void:
|
||||
if item_mouse and item_mouse.has_method("unregister_owner"):
|
||||
item_mouse.unregister_owner(self)
|
||||
@@ -87,6 +99,7 @@ func offer(inv_window: int, inv_cell: int) -> void:
|
||||
_offered_cells[key] = true
|
||||
_next_display = (_next_display + 1) % 12
|
||||
_status.text = ""
|
||||
_refresh_mobile_inventory()
|
||||
else:
|
||||
_status.text = "交易请求发送失败"
|
||||
|
||||
@@ -135,6 +148,56 @@ func refresh() -> void:
|
||||
_accept_btn.modulate = Color(0.5, 1, 0.5) if me else Color(1, 1, 1)
|
||||
_root.get_node("PeerAccept").text = "对方: 已接受" if peer else "对方: 未接受"
|
||||
_status.text = ""
|
||||
_refresh_mobile_inventory()
|
||||
|
||||
func _apply_mobile_layout() -> void:
|
||||
if _root == null:
|
||||
return
|
||||
if _mobile_mode:
|
||||
# The inventory candidate list is part of the same hosted surface, so it
|
||||
# scales and receives the same safe-area treatment as the trade window.
|
||||
_root.size = Vector2(688, 400)
|
||||
_root.position = Vector2(-344, -200)
|
||||
if _mobile_inventory_scroll:
|
||||
_mobile_inventory_scroll.position = Vector2(452, 54)
|
||||
_mobile_inventory_scroll.size = Vector2(224, 280)
|
||||
_mobile_inventory_scroll.visible = true
|
||||
else:
|
||||
_root.size = Vector2(440, 360)
|
||||
_root.position = Vector2(-220, -180)
|
||||
if _mobile_inventory_scroll:
|
||||
_mobile_inventory_scroll.position = Vector2.ZERO
|
||||
_mobile_inventory_scroll.size = Vector2.ZERO
|
||||
_mobile_inventory_scroll.visible = false
|
||||
|
||||
func _refresh_mobile_inventory() -> void:
|
||||
if not _mobile_mode or _mobile_inventory_list == null or not is_open():
|
||||
return
|
||||
for child in _mobile_inventory_list.get_children():
|
||||
child.queue_free()
|
||||
var inventory: Array = client.get_inventory() if client and client.has_method("get_inventory") else []
|
||||
var shown := 0
|
||||
for item in inventory:
|
||||
var cell := int(item.get("cell", -1))
|
||||
var vnum := int(item.get("vnum", 0))
|
||||
if cell < 0 or vnum <= 0:
|
||||
continue
|
||||
if _offered_cells.has("%d:%d" % [INVENTORY_WINDOW, cell]):
|
||||
continue
|
||||
var button := Button.new()
|
||||
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
button.text = "%s ×%d · 点按加入" % [_name_of(vnum), int(item.get("count", 1))]
|
||||
button.custom_minimum_size = Vector2(214, 34)
|
||||
button.tooltip_text = _tooltip_for(item)
|
||||
var captured_cell := cell
|
||||
button.pressed.connect(func() -> void: offer(INVENTORY_WINDOW, captured_cell))
|
||||
_mobile_inventory_list.add_child(button)
|
||||
shown += 1
|
||||
if shown == 0:
|
||||
var empty := Label.new()
|
||||
empty.text = "(没有可交易物品)"
|
||||
empty.add_theme_font_size_override("font_size", 12)
|
||||
_mobile_inventory_list.add_child(empty)
|
||||
|
||||
func _item_at(inv_window: int, inv_cell: int) -> Dictionary:
|
||||
if client == null:
|
||||
@@ -216,6 +279,7 @@ func _drop_to_slot(payload: Dictionary, display_pos: int) -> bool:
|
||||
_status.text = "交易请求发送失败"
|
||||
return false
|
||||
_offered_cells[key] = true
|
||||
_refresh_mobile_inventory()
|
||||
return true
|
||||
|
||||
func _fill(box: VBoxContainer, rows: Array) -> void:
|
||||
@@ -256,6 +320,11 @@ func _on_accept() -> void:
|
||||
else:
|
||||
_status.text = "接受请求发送失败"
|
||||
|
||||
func _on_cancel() -> void:
|
||||
if client and client.has_method("exchange_cancel"):
|
||||
client.exchange_cancel()
|
||||
close()
|
||||
|
||||
func _capture_anchor() -> void:
|
||||
if _anchor_valid or client == null or not client.has_method("get_main_vid") \
|
||||
or not client.has_method("get_entity"):
|
||||
@@ -369,6 +438,9 @@ func _build(parent: Node) -> void:
|
||||
_gold_input = LineEdit.new()
|
||||
_gold_input.placeholder_text = "金币"
|
||||
_gold_input.custom_minimum_size = Vector2(90, 0)
|
||||
# Godot will open the native numeric keyboard on Android/iOS while the
|
||||
# validation below remains authoritative for desktop and hardware keyboards.
|
||||
_gold_input.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER
|
||||
bottom.add_child(_gold_input)
|
||||
var put := Button.new()
|
||||
put.text = "放金币"
|
||||
@@ -380,5 +452,14 @@ func _build(parent: Node) -> void:
|
||||
bottom.add_child(_accept_btn)
|
||||
var cancel := Button.new()
|
||||
cancel.text = "取消"
|
||||
cancel.pressed.connect(func() -> void: client.exchange_cancel())
|
||||
cancel.pressed.connect(_on_cancel)
|
||||
bottom.add_child(cancel)
|
||||
|
||||
_mobile_inventory_scroll = ScrollContainer.new()
|
||||
_mobile_inventory_scroll.name = "MobileInventory"
|
||||
_mobile_inventory_scroll.visible = false
|
||||
_mobile_inventory_list = VBoxContainer.new()
|
||||
_mobile_inventory_list.add_theme_constant_override("separation", 4)
|
||||
_mobile_inventory_scroll.add_child(_mobile_inventory_list)
|
||||
_root.add_child(_mobile_inventory_scroll)
|
||||
_apply_mobile_layout()
|
||||
|
||||
+79
-16
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,6 +209,16 @@ func _place_tail(n: Node3D, tag: Label3D, d_px: float) -> void:
|
||||
else:
|
||||
tag.position.y = TAG_BASE_Y - d_px * px
|
||||
|
||||
# 移动端情境按钮用它判断附近是否有可拾取物,避免常驻占用 HUD。
|
||||
func has_nearby_item() -> bool:
|
||||
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
|
||||
if p == null:
|
||||
return false
|
||||
for node in _by_vid.values():
|
||||
if is_instance_valid(node) and p.global_position.distance_to(node.global_position) < PICKUP_RANGE:
|
||||
return true
|
||||
return false
|
||||
|
||||
# 捡最近的一个(范围内)。返回捡的 vid,0 = 没有。
|
||||
func try_pickup() -> int:
|
||||
var p: Node3D = _player_getter.call() if _player_getter.is_valid() else null
|
||||
|
||||
+133
-40
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+268
-5
@@ -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,8 +768,23 @@ 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 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)
|
||||
@@ -534,18 +793,22 @@ func _ask_count(title_text: String, max_count: int, on_confirm: Callable) -> voi
|
||||
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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqv4o4srhhl45
|
||||
+17
-1
@@ -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"
|
||||
|
||||
+33
-2
@@ -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,7 +185,15 @@ func _ask_password() -> void:
|
||||
_password_dialog.canceled.connect(func() -> void:
|
||||
_password_dialog.queue_free()
|
||||
_password_dialog = null)
|
||||
_root.get_parent().add_child(_password_dialog)
|
||||
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:
|
||||
@@ -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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user