diff --git a/.gitignore b/.gitignore index 66b3d42..1b8f795 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,12 @@ # ---- 依赖 ---- node_modules/ +# ---- 无头浏览器(本机工具,不入库) ---- +chrome-win64/ + +# ---- 探针/构建产物 ---- +/build/ + # ---- 本机凭据与本地配置(含数据库密码),绝不入库 ---- 3DTank/local/ diff --git a/3DTank/docs/HeadStyleTable.md b/3DTank/docs/HeadStyleTable.md new file mode 100644 index 0000000..d49fe85 --- /dev/null +++ b/3DTank/docs/HeadStyleTable.md @@ -0,0 +1,122 @@ +# HEAD Style Table + +> 来源:`3dtank.com/play/static/js/unpacked/module_82347.js` + `module_63383.js`(CSS DSL)+ `module_24923.js`(ksc 样式引擎)+ `module_37036.js`(Kotlin 运行时)。 +> 目标:把原版 Kotlin/JS style 函数逐块展开成具体 CSS,再 1:1 写成 Emotion style object。 + +## 0. 先澄清 `Sq` / `Lq` + +```js +Sq = To.$_$.ld, // module_37036: ld = ie = ne("object", ...) +Lq = To.$_$.gd, // module_37036: gd = te = ne("class", ...) +``` + +`Sq` / `Lq` 是 **Kotlin class/object metadata**,不是 Emotion 注册。 + +真正的样式类: + +- `d7n` = `MainScreenComponentStyle` +- `fni` = `UserInfoContainerStyle` +- `Yni` = `UserScoreComponentStyle` +- `o2n` = `HeaderCommonStyle` + +样式值在 `K2n` / `X2n` / `J2n` / `Z2n` / `Q2n` / `Oti` / `Dti` / `Kti` / `Jti` 这些 DSL 函数里。 + +## 1. DSL 映射 + +| 压缩名 | 导出 | CSS | +|---|---|---| +| `Wy` | `Ho.$_$.sb` → `Pc` | `width` | +| `Vy` | `Ho.$_$.b9` → `ua` | `height` | +| `fx` | `Ho.$_$.n6` → `m_` | `background` | +| `jj` | `Ho.$_$.x6` → `Uu` | `borderBottom` | +| `B$` | `Ho.$_$.vb` → `Bc` | `zIndex` | +| `Hy` | `Ho.$_$.ia` | `new Os(t, "%")` → 百分比 | +| `Zy` | `Ho.$_$.e8` | `new Os(t, "em")` → em | +| `Knr(px)` | 本地函数 | `Zy(px / 16)` → px 转 em(16px 基准) | +| `t.gp8("x", "y")` | 原始 CSS | 直接写属性 | + +## 2. 已确认的原版函数 + +| 原版函数 | 语义 | 展开值 | +|---|---|---| +| `Oti` | `rankIconContainer` | `width: 95px → 5.9375em`,`height: 96px → 6em`,`position: relative` | +| `Dti` | `containerRightBorder` | `border-right: 1px solid rgba(255,255,255,0.25)` | +| `Kti` | `blockLeftPanel` | `position: relative`,`width: P$().np9_1`(线上 12.5em),`z-index: 2` | +| `Jti` | `containerProgressMainScreen` | `width: 10em`,`height: 3.75em`,`margin-top: 1em` | +| `Xti` | `titleRankIcon` | `width: 4.375em`,`height: 4.375em`,`margin-left: 0 0.813em` | +| `Zti` | progress bar | `height: 0.875em`,`margin-bottom: 0.55em`,`opacity: 0.8` | +| `Qti` | xp icon | `width: 1em`,`height: 1em`,`margin-bottom: 1px` | +| `Q2n` | `buttonPlay` | `backdrop-filter: blur(1.563em)`,`width: 350px → 21.875em`,`height: 172px → 10.75em` | +| `Z2n` | `gradientBlockRight` | `linear-gradient(90deg,#000 0%,rgba(0,0,0,0) 100%)`,`width: 444px → 27.75em`,`opacity: .75`,`transform: rotate(-180deg)` | + +## 3. HEAD 组件表 + +| `data-ui` | 原版/线上对应 | CSS | Emotion | +|---|---|---|---| +| `head.root` | `MainScreenComponentStyle-containerPanel` | `display:flex; align-items:center; justify-content:space-between; width:100%; height:100%; position:relative; background:linear-gradient(180deg,rgba(0,25,38,.98),rgba(0,16,24,.94))` | `RootStyle` | +| `head.left` | `blockLeftPanel` | `display:flex; align-items:center; height:100%` | `LeftStyle` | +| `head.vip` | 主页 VIP 区 | `width:6em; height:6em; display:flex; align-items:center; justify-content:center; position:relative; cursor:pointer; background:radial-gradient(100% 100% at 0% 0%, rgba(255,204,0,.75) 0%, rgba(255,38,74,.5) 50%, rgba(255,38,74,0) 100%)`;off:`opacity:.55; filter:grayscale(.3)` | `VipStyle(On)` | +| `head.exit` | 子页面 VIP → 退出键 | `width:6em; height:6em; display:flex; align-items:center; justify-content:center; border:0; box-shadow:1px 0 0 0 rgba(255,255,255,.25); background:transparent; cursor:pointer`;图标 `back.72d79619.svg`(左箭头,不是右上角的退出箭头) | `ExitStyle` | +| `head.group` | 组队区 | `width:6em; height:6em; display:flex; align-items:center; justify-content:center; position:relative; border-left:1px solid rgba(255,255,255,.25); cursor:pointer` | `GroupStyle(Active)` | +| `head.group.g1` | 组队图标左半 | `position:absolute; top:50%; left:50%; width:1.5em; height:1.5em; margin-top:-.75em; margin-left:-.75em; background:rgba(191,212,255,.5); mask:url(groupUser.svg) center/contain no-repeat; transform:translateX(-.8em)` | `GroupIconStyle("-0.8em")` | +| `head.group.g2` | 组队图标右半 | 同上,`translateX(.8em)` | `GroupIconStyle("0.8em")` | +| `head.rank` | `rankIconContainer` | `width:6em; height:6em; display:flex; align-items:center; justify-content:center; border-left:1px solid rgba(255,255,255,.25); border-right:1px solid rgba(255,255,255,.25); cursor:pointer` | `RankStyle` | +| `head.user` | `containerProgressMainScreen` 外层 | `display:flex; flex-direction:column; justify-content:center; min-width:10em; margin-left:1em; height:3.75em` | `UserStyle` | +| `head.user.name` | 昵称 | `font:500 1.1em/1.2 BaseFontMedium; text-shadow:0 0 .25em rgba(0,0,0,.5); white-space:nowrap; overflow:hidden; text-overflow:ellipsis` | `UserNameStyle` | +| `head.user.xp` | `xp-bar` | `width:12.5em; height:.25em; margin-top:.375em; background:rgba(118,255,51,.25); border-radius:.375em; overflow:hidden` | `XpBarStyle` | +| `head.user.xp.fill` | XP 进度 | `display:block; height:100%; width:%; background:rgba(118,255,51,.8); border-radius:inherit` | `XpFillStyle(Ratio)` | +| `head.user.xp.text` | XP 数字 | `display:flex; align-items:center; margin-top:.35em; font:500 .9375em/1 BaseFontMedium; color:#76ff33` | `XpTextStyle` | +| `head.logo` | 中间 Logo | `position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); display:flex; align-items:center; height:3.75em; opacity:.95; pointer-events:none` | `LogoStyle` | +| `head.right` | `blockRightPanel` | `display:flex; align-items:center; height:100%; z-index:5` | `RightStyle` | +| `head.currencies` | 货币列 | `display:flex; flex-direction:column; justify-content:center; height:100%; width:9.2em; box-sizing:border-box; border-right:1px solid rgba(255,255,255,.25)` | `CurrenciesStyle` | +| `head.currency.ruby` | 红宝石 | `position:relative; display:flex; align-items:center; gap:.4em; width:100%; height:3em; padding:0 .75em 0 .6em; box-sizing:border-box; cursor:pointer; border-bottom:1px solid rgba(255,255,255,.25)` | `CurrencyStyle(true)` | +| `head.currency.crystal` | 水晶 | 同上,无 `border-bottom` | `CurrencyStyle(false)` | +| `head.fullscreen` | F11 | `display:flex; align-items:center; justify-content:center; gap:.3em; width:6em; height:6em; border-left:1px solid rgba(255,255,255,.25); cursor:pointer` | `FullscreenStyle` | +| `head.logout` | 退出 | `display:flex; align-items:center; justify-content:center; width:6em; height:6em; border:0; border-left:1px solid rgba(255,255,255,.25); background:transparent; cursor:pointer` | `LogoutStyle` | + +## 4. 子页面状态 + +`HeadContext.Page`: + +- `"home"`:渲染 `head.vip` +- 其它(如 `"garage"`):渲染 `head.exit`,点击调用 `HeadContext.OnBack()` + +## 5. 验收 + +无头探针: + +```powershell +node 3DTank/tools/headless_probe.js ` + --url "http://localhost:8080/?autoconnect=1&v=19" ` + --out "build/probe/head-home" ` + --width 1296 --height 1006 --wait 6000 +``` + +子页面: + +```powershell +node 3DTank/tools/headless_probe.js ` + --url "http://localhost:8080/?autoconnect=1&v=19" ` + --out "build/probe/head-garage" ` + --width 1296 --height 1006 --wait 6000 ` + --eval-file "3DTank/tools/probe_garage.js" +``` + +断言项: + +- `head.root`:`1296×77.75` +- `head.vip` / `head.exit`:`77.75×77.75` +- `head.group`:`77.75×77.75` +- `head.rank`:`77.75×77.75` +- `head.user`:`162×48.59` +- `head.currencies`:`119.22×77.75` +- `head.fullscreen` / `head.logout`:`77.75×77.75` + +## 6. 待核对常量 + +- `otr().k1i9()` / `q1i7()` / `r1i7()` / `h1if()` / `h1iw()` / `c1jf()` / `g1ie()`:display / position / flex 组合 +- `P$().np9_1`:线上实测 12.5em +- `E9i().z1hz_1` / `b1i1_1` / `h1i4_1` / `i1i4_1`:颜色/背景 +- `Enr().o1i7_1`:z-index 基准 + +这些是有限符号表,后续可继续展开;当前 HEAD 已按线上实测值实现。 diff --git a/3DTank/docs/UiTranslationStatus.md b/3DTank/docs/UiTranslationStatus.md new file mode 100644 index 0000000..72f8ac1 --- /dev/null +++ b/3DTank/docs/UiTranslationStatus.md @@ -0,0 +1,57 @@ +# UI Translation Status + +> 原则:静态翻译优先;全部翻译完成后再统一测试。 +> 技术栈:Kotlin/JS + kotlin-react + Emotion(静态布局可用 CSS,动态状态用 Emotion)。 + +## 已完成 + +| 屏幕 / 组件 | 文件 | 说明 | +|---|---|---| +| HEAD 顶栏 | `app/src/jsMain/kotlin/App/Browser/Ui/HeadScreen.kt` | Emotion;VIP→子页面退出键、组队两段、内部分隔线、货币/F11/退出 | +| 子页面 Breadcrumbs | `LobbyScreens.kt` | 6em 高;返回 / 标题 / 红宝石+水晶 / F11 / 退出 | +| 大厅首页 | `app/src/jsMain/kotlin/App/Browser/Ui/HomeScreen.kt` | Emotion;Play / 主菜单 / Footer / BattlePass / 公告 | +| 车库 | `app/src/jsMain/kotlin/App/Browser/Ui/GarageScreen.kt` | React;默认「总览」+ 右侧 4 个已装配件;分类栏、底盘/炮塔选择条、迷彩网格、左右面板、底部选择条 | +| 商店 | `app/src/jsMain/kotlin/App/Browser/Ui/LobbyScreens.kt` | 分类页签 + 物品卡片(分类/名称图标、稀有度、VIP、货币价格)+ 右侧详情/购买确认 | +| 幸运礼盒 | 同上 | 4 种礼盒美术、钥匙图标/数量、可能奖励稀有度、右侧详情/开启确认 | +| 战斗选择 | 同上 | 模式筛选(DM/TDM/CTF/CP/AS/JGR/SGE/RUGBY 图标)+ 战场列表 + 右侧详情/加入/创建入口 | +| 任务 | 同上 | 分类侧栏(主要/每日/每周/星级挑战/合约)+ 任务卡(进度、奖励图标、领取/去完成)+ 星级挑战横幅 + 离线示例 | +| 设置 | 同上 | 8 个分类侧栏;开关/滑杆/下拉/按键表;账户、安全、支持分组;全部重置 | +| 聊天 | 同上 | 频道侧栏(公共/战斗/军团/私聊)+ 消息行(头像/作者/时间)+ 发送;军团频道读 `ClanChatState` | +| 新闻 | 同上 | 左列表 + 右详情;真实图片/日期/正文;无数据时显示带标记的离线示例 | +| 军团 | 同上 | 未加入:军团美术、创建/搜索、许可证;已加入:军团头图、信息/成员/请求/邀请页签与成员表 | +| 好友 | 同上 | 好友/请求/搜索页签;在线状态、等级、军团标记;私聊/邀请/删除、接受/拒绝、拒绝所有;离线示例 | +| 战斗 HUD | `app/src/jsMain/kotlin/App/Browser/Ui/BattleHud.kt` | 顶部时间/分数/地图、左下战斗聊天、底部补给、右下全屏/统计/暂停、统计面板、暂停菜单 | +| 战斗结算 | `LobbyScreens.kt` | 统计 / 奖励 / 任务页签;统计表、奖励列表、经验、返回大厅 | +| 通用弹窗 / Toast | `app/src/jsMain/kotlin/App/Browser/Ui/Dialogs.kt` | DialogContext / ToastContext / OverlayHost;商店购买、好友接受/拒绝已接入 | +| 大厅 Tips | `HomeScreen.kt` | 底部提示条,读 `TipsState.Tips` | +| 军团聊天 | `LobbyScreens.kt` | 聊天页「军团」频道,读 `ClanChatState.Messages` | +| 登录页 | `EntranceView.kt` + `entrance.css` | 之前已完成 1:1 | + +## 已接入的通用层 + +| 文件 | 内容 | +|---|---| +| `UiTheme.kt` | 颜色/字体/间距 token、Panel/Button/Tab/Card/Grid/Empty 等通用 Emotion 样式 | +| `HeadScreen.kt` | `HeadContext` / `HeadMount` | +| `HomeScreen.kt` | `HomeContext` / `HomeMount` | +| `LobbyScreens.kt` | `ScreenContext` / `ScreenMount` / 子页面路由 | +| `LobbyView.kt` | 导航、HEAD/Home/Screen/Garage 挂载与切换 | + +## 待翻译 + +| 模块 | 原模块 | 说明 | +|---|---|---| +| 战斗 HUD 精确布局 | `module_21300` | 已翻译壳/按钮/聊天/补给/统计/暂停;内部血条、弹药、准星、战斗日志的精确尺寸仍需按 style 函数展开 | +| 战斗结算精确布局 | `module_82347` | 已翻译奖励/经验结构;表格列宽、排名变化动画仍需展开 | +| 通用弹窗具体实例 | `module_82347` | Dialog/Toast 壳已就绪,购买/升级/排名等具体弹窗还需逐个接入 | +| 通知 / Tips 样式 | `module_82347` | 已有提示条,原版图标/动画未展开 | +| 3D 坦克模型 | `Item3DCC.Resource` | 资源 URL 索引未打通;当前用官方预览图/图标占位 | +| 真实迷彩预览 | `Item3DCC.Resource` | 迷彩资源 URL 未打通;车库用色块、商店用色块渐变占位 | +| 军团/好友真实数据 | `RootState` | 已接状态与列表;真实服务器数据下会替换离线示例 | + +## 说明 + +- `LobbyView` 的旧 HTML 字符串子页面已删除,只保留车库 HTML 容器;非车库页面全部走 `ScreenMount`。 +- 已用无头浏览器核对非战斗页面:首页、车库(总览/底盘/迷彩)、商店、礼盒、战斗选择、任务、设置(游戏/画面/音效)、聊天、新闻、军团、好友。 +- 任务/新闻/好友在离线无数据时显示带「离线示例」标记的演示内容;连接服务器后自动使用真实状态。 +- 下一步:继续展开战斗 HUD / 结算的精确尺寸与弹窗实例,再处理车库真实迷彩预览与 3D 模型。 diff --git a/3DTank/docs/UnpackedModules.md b/3DTank/docs/UnpackedModules.md new file mode 100644 index 0000000..9691712 --- /dev/null +++ b/3DTank/docs/UnpackedModules.md @@ -0,0 +1,186 @@ +# Unpacked Modules Map + +> 自动生成 + 人工校正。用途是根据模块内字符串、样式名、导出符号和依赖关系推断的;标记「待定」的模块需要后续按调用链确认。 + +## 关键模块 + +| 模块 | 用途 | +|---:|---| +| 10032 | 协议/序列化(大模块) | +| 14766 | Emotion 核心(data-emotion 样式缓存/序列化) | +| 24923 | Kotlin/JS CSS-in-JS 样式引擎(ksc-global-style、ksc-* 类名、style 注入) | +| 31908 | UI/车库相关 | +| 36659 | UI/大厅相关 | +| 37036 | Kotlin/JS 运行时与反射元数据(gd/ld = class/object metadata) | +| 47243 | 客户端核心(大模块) | +| 61378 | UI/战斗/大厅相关 | +| 61860 | UI 组件/布局(含 d7n 调用) | +| 63383 | CSS DSL(width/height/background/border/z-index/单位:em、%、px→em) | +| 71105 | 3D 资源/场景加载(3DS/A3D、Resource URL、贴图) | +| 82347 | 大厅/车库 UI 组件与样式(HEAD、MainScreen、Garage、TankParameters、MountedItems 等) | +| 85183 | UI 基础/组件库(大模块) | +| 85667 | 物品目录/车库数据模型(ItemCategory、Item3DCC 等) | +| 87072 | 大厅/战斗主流程(大模块) | +| 89613 | UI/商店/物品相关 | +| 89679 | UI/设置/对话框相关 | +| 89803 | UI 组件/布局 | +| 92980 | 客户端核心/工具(大模块) | +| 97675 | UI/社交/聊天相关 | + +## 全量模块 + +| ID | 文件 | 大小 | 依赖 | 导出(前 6) | 样式/组件(前 6) | 推断用途 | +|---:|---|---:|---|---|---|---| +| 806 | module_00806.js | 1.6 KB | 19487 | | | 网络/协议 | +| 905 | module_00905.js | 292 B | | | | 待定 | +| 1510 | module_01510.js | 30.9 KB | 37036, 24635, 52943, 61860 | a, b, c, d, e, f | | 网络/协议 | +| 1901 | module_01901.js | 12.0 KB | 7552, 15152, 806 | | | 网络/协议 | +| 3535 | module_03535.js | 2.8 KB | 7552, 37036 | a, b, c, d, e, f | | React/UI 基础 | +| 3569 | module_03569.js | 138.0 KB | 37036, 32088, 52943, 92980, 9345, 55088 | a, b, c, d, e, f | | React/UI 基础 | +| 7426 | module_07426.js | 1.7 KB | 37036, 89613, 56077 | a, b, c, d, e | | 待定 | +| 7552 | module_07552.js | 262 B | 78931 | | | 待定 | +| 7793 | module_07793.js | 532 B | | | | 待定 | +| 9345 | module_09345.js | 69.5 KB | 37036, 72834, 32088, 97556 | a, b, c, d, e, f | | 待定 | +| 9539 | module_09539.js | 128.0 KB | 0, 37036 | a, b, c, d, e, f | | 待定 | +| 9963 | module_09963.js | 1.2 KB | 7552, 37036, 3535 | a, b | | React/UI 基础 | +| 10032 | module_10032.js | 1.47 MB | 16703, 37036, 10849, 9539, 52943, 61430 | a, b, c, d, e, f | UpdateViewStyle, ViewStyle, ScoreButton, SuicideButton, SuppliesButton, DestroyLogView | 车库 UI/样式 | +| 10283 | module_10283.js | 89.7 KB | 37036, 24635, 71105, 52943, 61860, 67222 | a, b, c, d, e, f | | 3D/资源加载 | +| 10849 | module_10849.js | 23.0 KB | 37036, 52943, 61860, 90875 | a, b, c, d, e, f | | 待定 | +| 12218 | module_12218.js | 1.3 KB | 905 | | | 待定 | +| 13431 | module_13431.js | 46.6 KB | 30285, 81886, 85858, 46431, 98959, 36756 | | StartScreenComponentStyle | 网络/协议 | +| 13532 | module_13532.js | 1.9 KB | | | | 待定 | +| 13754 | module_13754.js | 264 B | 79763 | | | 待定 | +| 14766 | module_14766.js | 26.5 KB | | | | Emotion 运行时 | +| 15152 | module_15152.js | 250 B | 12218 | | | 待定 | +| 15330 | module_15330.js | 2.3 KB | | | | 网络/协议 | +| 15831 | module_15831.js | 1.4 KB | 15330 | | | React/UI 基础 | +| 16100 | module_16100.js | 57.0 KB | 37036, 24635, 52943 | a, b, c | | 网络/协议 | +| 17259 | module_17259.js | 3.6 KB | | | | 待定 | +| 17323 | module_17323.js | 367 B | | | | 待定 | +| 18334 | module_18334.js | 298.1 KB | 37036, 37504, 10849, 52943, 10032, 61860 | a, b, c, d, e, f | | 车库 UI/样式 | +| 19074 | module_19074.js | 417 B | | | | 待定 | +| 19487 | module_19487.js | 264 B | 17259 | | | 待定 | +| 21300 | module_21300.js | 347.6 KB | 30, 1, 2, 5, 0, 26 | a | inputStyle, BattleAutoPauseComponentStyle, BattleHudComponentStyle, BattleHudFpsComponentStyle, BattleMessagesComponentStyle, BattlePauseMenuComponentStyle | 车库 UI/样式 | +| 21973 | module_21973.js | 1.1 KB | | | | 待定 | +| 24635 | module_24635.js | 31.5 KB | 29518, 37036, 72834, 52943 | a, b, c, d, e, f | | 待定 | +| 24923 | module_24923.js | 38.1 KB | 77249, 7552, 37036, 73932, 56077, 63383 | a, b, c, d, e, f | | Kotlin/JS CSS-in-JS 样式引擎 | +| 25377 | module_25377.js | 51.2 KB | 37036, 24635, 85196, 31908, 52943, 61860 | a, b, c, d, e, f | | 网络/协议 | +| 26482 | module_26482.js | 521 B | | | | 待定 | +| 27736 | module_27736.js | 1.1 KB | | | | 待定 | +| 28485 | module_28485.js | 559 B | 45555 | | | 待定 | +| 29518 | module_29518.js | 57.2 KB | 57328 | | | 网络/协议 | +| 29545 | module_29545.js | 339 B | | | | 网络/协议 | +| 29554 | module_29554.js | 9.7 KB | 37036, 24635, 61860, 71105, 52943 | a, b, c, d, e, f | | 网络/协议 | +| 30285 | module_30285.js | 120.8 KB | 80397, 44740, 55920 | | | 网络/协议 | +| 31509 | module_31509.js | 681 B | | | | 待定 | +| 31584 | module_31584.js | 397 B | | a | | 待定 | +| 31620 | module_31620.js | 1.8 KB | 66610, 27736 | | | 待定 | +| 31908 | module_31908.js | 77.6 KB | 37036, 24635, 88693, 52943, 61860 | a, b, c, d, e, f | | 车库 UI/样式 | +| 32088 | module_32088.js | 79.7 KB | 37036, 9539 | a, b, c, d, e, f | | 待定 | +| 36659 | module_36659.js | 178.7 KB | 14364, 61282, 14991, 69305, 73853, 44970 | a, b, c, d, e, f | | 车库 UI/样式 | +| 36734 | module_36734.js | 40.5 KB | 37036, 32088, 52943, 89613, 7426 | a, b, c, d, e, f | | 战斗 | +| 37036 | module_37036.js | 339.9 KB | 125, 48, 57, 65533, 10, 32 | a, b, c, d, e, f | | 网络/协议 | +| 37504 | module_37504.js | 104.5 KB | 37036, 24635, 52943, 61860 | a, b, c, d, e, f | | 网络/协议 | +| 38335 | module_38335.js | 857 B | | | | 网络/协议 | +| 40293 | module_40293.js | 26.1 KB | 15152, 7552 | | | 网络/协议 | +| 41164 | module_41164.js | 13.2 KB | 37036, 61430, 97675, 52943 | a, b, c, d, e, f | | 战斗 | +| 42920 | module_42920.js | 389 B | 44794 | | | 待定 | +| 43319 | module_43319.js | 389 B | | | | 待定 | +| 44740 | module_44740.js | 3.1 KB | 55920 | | | 网络/协议 | +| 44794 | module_44794.js | 1.2 KB | | | | 待定 | +| 45555 | module_45555.js | 219.6 KB | 7552, 13754 | | ContextMenu | 3D/资源加载 | +| 45686 | module_45686.js | 17.8 KB | 37036, 61860, 29554, 52943, 71105, 24635 | a | | 网络/协议 | +| 47243 | module_47243.js | 528.2 KB | 37036, 24635, 71105, 31908, 52943, 25377 | a, b, c, d, e, f | | 网络/协议 | +| 48576 | module_48576.js | 1.8 KB | 15330 | | | 待定 | +| 50824 | module_50824.js | 11.1 KB | 7552, 37036, 90875 | a, b, c, d, e, f | | React/UI 基础 | +| 51233 | module_51233.js | 921 B | 44794 | | | 待定 | +| 51400 | module_51400.js | 555 B | 19074 | | | 待定 | +| 51887 | module_51887.js | 2.0 KB | 44794, 70298, 21973, 31620, 66610 | | | 待定 | +| 52943 | module_52943.js | 141.5 KB | 75554, 39290, 92761, 87881, 7110, 1900 | a, b, c, d, e, f | | React/UI 基础 | +| 54199 | module_54199.js | 39.8 KB | 37036, 24635, 88693, 52943, 61860 | a, b, c, d, e, f | | 网络/协议 | +| 55088 | module_55088.js | 145.6 KB | 32088, 37036, 52943 | a, b, c, d, e, f | | 待定 | +| 55520 | module_55520.js | 903 B | 64477 | | | 网络/协议 | +| 55920 | module_55920.js | 480 B | | | | 网络/协议 | +| 56077 | module_56077.js | 1.6 KB | 37036, 89613 | a, b, c | | 待定 | +| 57128 | module_57128.js | 29.9 KB | 35862, 59668, 1254, 2328, 81786, 57808 | a, b, c, d, e, f | | 战斗 | +| 57328 | module_57328.js | 911 B | | | | 待定 | +| 58309 | module_58309.js | 440 B | 7793 | | | 待定 | +| 59131 | module_59131.js | 38.9 KB | 37036 | a, b, c, d, e, f | | 待定 | +| 59411 | module_59411.js | 1.4 KB | 29545, 84441, 15831, 48576 | | | 待定 | +| 60273 | module_60273.js | 529 B | 60824 | | | 待定 | +| 60824 | module_60824.js | 5.1 KB | 7793, 17323 | | | 网络/协议 | +| 61378 | module_61378.js | 92.8 KB | 37036, 52943, 92980, 3569, 32088, 55088 | a, b, c, d, e, f | | 战斗 | +| 61430 | module_61430.js | 112.6 KB | 117, 128, 9539, 37036 | a, b, c, d, e, f | | 待定 | +| 61860 | module_61860.js | 77.8 KB | 24635, 37036, 52943, 32088, 72834, 89613 | a, b, c, d, e, f | | 网络/协议 | +| 63383 | module_63383.js | 173.6 KB | 37036 | a, b, c, d, e, f | borderStyle, fontStyle, BorderStyle, FontStyle, RuleContainer | 待定 | +| 64280 | module_64280.js | 892 B | | | | 网络/协议 | +| 64477 | module_64477.js | 961 B | 99295 | | | 待定 | +| 65201 | module_65201.js | 1.0 KB | 31620, 44794 | | | 网络/协议 | +| 66610 | module_66610.js | 298 B | | | | 待定 | +| 67222 | module_67222.js | 8.1 KB | 37036, 24635, 52943, 61860 | a, b, c, d | | 网络/协议 | +| 69682 | module_69682.js | 784 B | 31620 | | | 网络/协议 | +| 70130 | module_70130.js | 290.0 KB | 37036, 52943, 61860, 87072, 10032, 92980 | a | | 战斗 | +| 70298 | module_70298.js | 2.3 KB | 21973 | | | 待定 | +| 71105 | module_71105.js | 366.4 KB | 1, 37036, 9539, 89613, 52943, 61860 | a, b, c, d, e, f | | 3D/资源加载 | +| 71426 | module_71426.js | 4.4 KB | 37036 | a, b, c | | 待定 | +| 72834 | module_72834.js | 16.0 KB | 37036 | a, b, c, d, e, f | | 待定 | +| 73932 | module_73932.js | 578 B | 37036 | a, b | | 待定 | +| 74897 | module_74897.js | 967 B | 31620 | | | 网络/协议 | +| 75453 | module_75453.js | 4.5 KB | 31620, 74897, 80693, 69682, 65201, 27736 | | | 待定 | +| 76074 | module_76074.js | 1.4 KB | | | | React/UI 基础 | +| 77249 | module_77249.js | 11.5 KB | 60273, 51400, 31509, 96480, 17323, 19074 | | WebkitBorderBeforeStyle, WebkitBorderAfterStyle, WebkitBorderStartStyle, MozBorderStartStyle, WebkitBorderEndStyle, MozBorderEndStyle | 网络/协议 | +| 77672 | module_77672.js | 3.4 KB | | | | 待定 | +| 78231 | module_78231.js | 5.2 KB | | | | React/UI 基础 | +| 78931 | module_78931.js | 9.9 KB | | | | 网络/协议 | +| 79763 | module_79763.js | 6.6 KB | | | | 待定 | +| 80397 | module_80397.js | 1.2 KB | 44740, 55920 | | | 待定 | +| 80693 | module_80693.js | 1.4 KB | 31620 | | | 网络/协议 | +| 81886 | module_81886.js | 328 B | 28485 | | | 待定 | +| 82347 | module_82347.js | 5.25 MB | 59411, 48681, 59341, 69131, 29146, 17224 | a, b, c, d, e, f | textStyle, tBodyStyle, priceStyle, outerContainerStyle, innerContainerStyle, defaultStyle | 战斗 | +| 84441 | module_84441.js | 6.0 KB | 44794, 21973, 38335, 64280, 51233, 42920 | | | 待定 | +| 85183 | module_85183.js | 1.14 MB | 37036, 24635, 71105, 61860, 31908, 32088 | a, b, c, d, e, f | | 3D/资源加载 | +| 85196 | module_85196.js | 231.5 KB | 37036, 24635, 71105, 85667, 32088, 61860 | a, b, c, d, e, f | | 车库 UI/样式 | +| 85667 | module_85667.js | 80.8 KB | 37036, 24635, 71105, 52943, 61860 | a, b, c, d, e, f | | 网络/协议 | +| 85745 | module_85745.js | 4.0 KB | 37036, 24635, 52943 | a | | 3D/资源加载 | +| 87072 | module_87072.js | 3.71 MB | 20, 30, 60, 200, 7, 300 | a, b, c, d, e, f | LightingBlock, TankComponent | 3D/资源加载 | +| 88693 | module_88693.js | 34.3 KB | 37036, 24635, 52943, 61860 | a, b, c, d, e, f | | 网络/协议 | +| 89613 | module_89613.js | 85.6 KB | 37036, 71426 | a, b, c, d, e, f | | 待定 | +| 89679 | module_89679.js | 58.4 KB | 89613, 10032, 37036, 52943, 97675, 10849 | a, b, c, d, e | | 待定 | +| 89803 | module_89803.js | 89.3 KB | 37036, 24635, 31908, 61860, 71105, 85667 | a, b, c, d, e, f | | 网络/协议 | +| 90225 | module_90225.js | 484 B | 44794 | | | 待定 | +| 90875 | module_90875.js | 3.8 KB | 7552, 37036, 3535 | a, b, c, d, e, f | RComponent | React/UI 基础 | +| 92980 | module_92980.js | 808.9 KB | 0, 52943, 37036, 32088, 97556, 72834 | a, b, c, d, e, f | ImGUIStyle, ImGUIButtonStyle, ProgressBar, LightingBlock | 3D/资源加载 | +| 94090 | module_94090.js | 422 B | 37036 | a | | React/UI 基础 | +| 94965 | module_94965.js | 34.3 KB | | | DataView | 网络/协议 | +| 96480 | module_96480.js | 1.8 KB | 58309, 19074, 43319 | | | 待定 | +| 97556 | module_97556.js | 11.8 KB | 37036, 32088 | a, b, c, d, e, f | | 待定 | +| 97675 | module_97675.js | 90.0 KB | 37036, 52943, 61430, 9539, 89613 | a, b, c, d, e, f | | 待定 | +| 99295 | module_99295.js | 730 B | | | | 待定 | +| 99527 | module_99527.js | 10.9 KB | 37036, 24635, 52943, 61860 | a, b, c, d, e, f | | 物品目录/模型 | + +## 样式 DSL 速查 + +| 压缩名 | 导出 | 实际 CSS | +|---|---|---| +| `Wy` | `Ho.$_$.sb` → `Pc` | `width` | +| `Vy` | `Ho.$_$.b9` → `ua` | `height` | +| `fx` | `Ho.$_$.n6` → `m_` | `background` | +| `jj` | `Ho.$_$.x6` → `Uu` | `borderBottom` | +| `B$` | `Ho.$_$.vb` → `Bc` | `zIndex` | +| `Hy` | `Ho.$_$.ia` | `new Os(t, "%")` → 百分比 | +| `Zy` | `Ho.$_$.e8` | `new Os(t, "em")` → em | +| `Knr` | 本地函数 | `Zy(t/16)` → px 转 em(16px 基准) | + +## 已确认的 HEAD 样式定义 + +- `fni` = `UserInfoContainerStyle`,方法 `o1qe`…`w1qe` 对应 `rankIconContainer`、`containerRightBorder`、`rankIconContainerClickable`、`userNameRank`、`textDecoration`、`containerUserNameRank`、`blockLeftPanel`、`userTitleContainer`、`titleRankIcon`、`containerProgressMainScreen`。 +- `Oti` = rankIconContainer:`width: 95px→5.9375em`,`height: 96px→6em`,`position: relative`。 +- `Dti` = containerRightBorder:`border-right: 1px solid rgba(255,255,255,0.25)`。 +- `Kti` = blockLeftPanel:`position: relative`,`width: P$().np9_1`,`z-index: 2`。 +- `Jti` = containerProgressMainScreen:`width: 10em`,`height: 3.75em`,`margin-top: 1em`。 +- `Q2n` = buttonPlay:`backdrop-filter: blur(1.563em)`,`width: 350px→21.875em`,`height: 172px→10.75em`。 +- `Z2n` = gradientBlockRight:`linear-gradient(90deg,#000 0%,rgba(0,0,0,0) 100%)`,`width: 444px→27.75em`,`opacity: .75`,`transform: rotate(-180deg)`。 + +## 结论 + +`Sq` / `Lq` 不是 Emotion 注册,而是 Kotlin/JS 的 class/object metadata(`gd` / `ld`)。真正的样式在 `d7n`、`fni`、`Yni`、`o2n` 等样式类的方法里,由 `K2n`、`X2n`、`Oti`、`Dti` 这些 DSL 函数写入具体 CSS。只要把 DSL 助手和常量映射出来,就可以不依赖运行时忠实翻译。 diff --git a/3DTank/tools/gen_unpacked_map.js b/3DTank/tools/gen_unpacked_map.js new file mode 100644 index 0000000..d04192d --- /dev/null +++ b/3DTank/tools/gen_unpacked_map.js @@ -0,0 +1,113 @@ +// 生成 3DTank/docs/UnpackedModules.md +// 用法:node 3DTank/tools/gen_unpacked_map.js +const fs = require('fs'); +const path = require('path'); + +const Dir = path.join(__dirname, '..', '..', '3dtank.com', 'play', 'static', 'js', 'unpacked'); +const Out = path.join(__dirname, '..', 'docs', 'UnpackedModules.md'); + +const files = fs + .readdirSync(Dir) + .filter((f) => /^module_\d+\.js$/.test(f)) + .sort((a, b) => parseInt(a.match(/\d+/)[0], 10) - parseInt(b.match(/\d+/)[0], 10)); + +const rows = []; +for (const f of files) { + const p = path.join(Dir, f); + const s = fs.readFileSync(p, 'utf8'); + const id = (s.match(/Module ID:\s*(\d+)/) || [])[1] || f.match(/\d+/)[0]; + const size = fs.statSync(p).size; + const imps = [...new Set([...s.matchAll(/i\((\d+)\)/g)].map((m) => m[1]))]; + const exps = [...new Set([...s.matchAll(/t\.\$_\$\.([A-Za-z0-9_$]+)\s*=/g)].map((m) => m[1]))]; + const styles = [...new Set([...s.matchAll(/["']([A-Za-z0-9_]+Style)["']/g)].map((m) => m[1]))]; + const comps = [ + ...new Set( + [ + ...s.matchAll( + /["']([A-Z][A-Za-z0-9_]*(?:Component|Container|View|Dialog|Screen|Panel|Button|Icon|Block|Menu|Item|Bar|Header|Footer|Preview|Cell))["']/g, + ), + ].map((m) => m[1]), + ), + ]; + + let purpose = ''; + const head = s.slice(0, 240000); + if (/Garage|TankParameters|MountedItems|Supplies/.test(head)) purpose = '车库 UI/样式'; + else if (/MainScreenComponentStyle|UserInfoContainerStyle|UserScoreComponentStyle|HeaderCommonStyle/.test(head)) + purpose = '大厅/HEAD UI/样式'; + else if (/ksc-global-style|ksc-keyframe/.test(head)) purpose = 'Kotlin/JS CSS-in-JS 样式引擎'; + else if (/data-emotion/.test(head)) purpose = 'Emotion 运行时'; + else if (/ItemCategory|Item3DCC|DescriptionModel|MountedSkin/.test(head)) purpose = '物品目录/模型'; + else if (/\.3ds|\.a3d|TDSLoader|Resource/.test(head)) purpose = '3D/资源加载'; + else if (/WebSocket|proto|Packet|EnterSpace|HashRequest/.test(head)) purpose = '网络/协议'; + else if (/Battle|battle/.test(head)) purpose = '战斗'; + else if (/React|createElement|useState/.test(head)) purpose = 'React/UI 基础'; + + rows.push({ id, file: f, size, imps, exps, styles, comps, purpose }); +} + +const fmt = (n) => + n >= 1024 * 1024 ? (n / 1024 / 1024).toFixed(2) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B'; + +const Key = { + 82347: '大厅/车库 UI 组件与样式(HEAD、MainScreen、Garage、TankParameters、MountedItems 等)', + 24923: 'Kotlin/JS CSS-in-JS 样式引擎(ksc-global-style、ksc-* 类名、style 注入)', + 37036: 'Kotlin/JS 运行时与反射元数据(gd/ld = class/object metadata)', + 63383: 'CSS DSL(width/height/background/border/z-index/单位:em、%、px→em)', + 14766: 'Emotion 核心(data-emotion 样式缓存/序列化)', + 71105: '3D 资源/场景加载(3DS/A3D、Resource URL、贴图)', + 85667: '物品目录/车库数据模型(ItemCategory、Item3DCC 等)', + 87072: '大厅/战斗主流程(大模块)', + 85183: 'UI 基础/组件库(大模块)', + 10032: '协议/序列化(大模块)', + 47243: '客户端核心(大模块)', + 92980: '客户端核心/工具(大模块)', + 61860: 'UI 组件/布局(含 d7n 调用)', + 89803: 'UI 组件/布局', + 31908: 'UI/车库相关', + 36659: 'UI/大厅相关', + 89613: 'UI/商店/物品相关', + 89679: 'UI/设置/对话框相关', + 61378: 'UI/战斗/大厅相关', + 97675: 'UI/社交/聊天相关', +}; + +let md = '# Unpacked Modules Map\n\n'; +md += '> 自动生成 + 人工校正。用途是根据模块内字符串、样式名、导出符号和依赖关系推断的;标记「待定」的模块需要后续按调用链确认。\n\n'; +md += '## 关键模块\n\n| 模块 | 用途 |\n|---:|---|\n'; +for (const [id, p] of Object.entries(Key)) md += `| ${id} | ${p} |\n`; + +md += '\n## 全量模块\n\n'; +md += '| ID | 文件 | 大小 | 依赖 | 导出(前 6) | 样式/组件(前 6) | 推断用途 |\n'; +md += '|---:|---|---:|---|---|---|---|\n'; +for (const r of rows) { + const deps = r.imps.slice(0, 6).join(', '); + const ex = r.exps.slice(0, 6).join(', '); + const sc = [...r.styles, ...r.comps].slice(0, 6).join(', '); + md += `| ${r.id} | ${r.file} | ${fmt(r.size)} | ${deps} | ${ex} | ${sc} | ${r.purpose || '待定'} |\n`; +} + +md += '\n## 样式 DSL 速查\n\n'; +md += '| 压缩名 | 导出 | 实际 CSS |\n|---|---|---|\n'; +md += '| `Wy` | `Ho.$_$.sb` → `Pc` | `width` |\n'; +md += '| `Vy` | `Ho.$_$.b9` → `ua` | `height` |\n'; +md += '| `fx` | `Ho.$_$.n6` → `m_` | `background` |\n'; +md += '| `jj` | `Ho.$_$.x6` → `Uu` | `borderBottom` |\n'; +md += '| `B$` | `Ho.$_$.vb` → `Bc` | `zIndex` |\n'; +md += '| `Hy` | `Ho.$_$.ia` | `new Os(t, "%")` → 百分比 |\n'; +md += '| `Zy` | `Ho.$_$.e8` | `new Os(t, "em")` → em |\n'; +md += '| `Knr` | 本地函数 | `Zy(t/16)` → px 转 em(16px 基准) |\n'; + +md += '\n## 已确认的 HEAD 样式定义\n\n'; +md += '- `fni` = `UserInfoContainerStyle`,方法 `o1qe`…`w1qe` 对应 `rankIconContainer`、`containerRightBorder`、`rankIconContainerClickable`、`userNameRank`、`textDecoration`、`containerUserNameRank`、`blockLeftPanel`、`userTitleContainer`、`titleRankIcon`、`containerProgressMainScreen`。\n'; +md += '- `Oti` = rankIconContainer:`width: 95px→5.9375em`,`height: 96px→6em`,`position: relative`。\n'; +md += '- `Dti` = containerRightBorder:`border-right: 1px solid rgba(255,255,255,0.25)`。\n'; +md += '- `Kti` = blockLeftPanel:`position: relative`,`width: P$().np9_1`,`z-index: 2`。\n'; +md += '- `Jti` = containerProgressMainScreen:`width: 10em`,`height: 3.75em`,`margin-top: 1em`。\n'; +md += '- `Q2n` = buttonPlay:`backdrop-filter: blur(1.563em)`,`width: 350px→21.875em`,`height: 172px→10.75em`。\n'; +md += '- `Z2n` = gradientBlockRight:`linear-gradient(90deg,#000 0%,rgba(0,0,0,0) 100%)`,`width: 444px→27.75em`,`opacity: .75`,`transform: rotate(-180deg)`。\n'; +md += '\n## 结论\n\n'; +md += '`Sq` / `Lq` 不是 Emotion 注册,而是 Kotlin/JS 的 class/object metadata(`gd` / `ld`)。真正的样式在 `d7n`、`fni`、`Yni`、`o2n` 等样式类的方法里,由 `K2n`、`X2n`、`Oti`、`Dti` 这些 DSL 函数写入具体 CSS。只要把 DSL 助手和常量映射出来,就可以不依赖运行时忠实翻译。\n'; + +fs.writeFileSync(Out, md, 'utf8'); +console.log(`wrote ${Out} (${md.length} chars, ${rows.length} modules)`); diff --git a/3DTank/tools/headless_probe.js b/3DTank/tools/headless_probe.js new file mode 100644 index 0000000..c3c5f71 --- /dev/null +++ b/3DTank/tools/headless_probe.js @@ -0,0 +1,252 @@ +// headless_probe.js —— 无头 Chrome/Chromium 页面探针 +// +// 用途:加载页面,抓取 DOM / 关键节点 rect / computed style / 截图。 +// 只用于导出 fixture 和验收,不作为应用运行时依赖。 +// +// 用法: +// node 3DTank/tools/headless_probe.js --url http://localhost:8080/ --out out/head-home +// node 3DTank/tools/headless_probe.js --url ... --eval-file probe.js --width 1296 --height 1006 +// +// 默认 Chrome 路径:/chrome-win64/chrome.exe +// 可用环境变量 CHROME_PATH 覆盖。 +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const http = require('http'); +const { spawn } = require('child_process'); +const WebSocket = require(path.join(__dirname, 'node_modules', 'ws')); + +function arg(name, fallback) { + const i = process.argv.indexOf('--' + name); + return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback; +} +function has(name) { + return process.argv.includes('--' + name); +} + +const Url = arg('url', 'http://localhost:8080/'); +const OutDir = path.resolve(arg('out', path.join(os.tmpdir(), 'tankic-probe'))); +const Width = parseInt(arg('width', '1296'), 10); +const Height = parseInt(arg('height', '1006'), 10); +const WaitMs = parseInt(arg('wait', '8000'), 10); +const EvalFile = arg('eval-file', ''); +const ShotName = arg('shot', 'page.png'); +const Cookie = process.env.PROBE_COOKIE || arg('cookie', ''); +const CookieDomain = arg('cookie-domain', '.3dtank.com'); +const ChromePath = + process.env.CHROME_PATH || path.join(__dirname, '..', '..', 'chrome-win64', 'chrome.exe'); + +fs.mkdirSync(OutDir, { recursive: true }); + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function httpJson(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + resolve(JSON.parse(d)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); +} + +async function waitForDevTools(profileDir, timeoutMs = 20000) { + const file = path.join(profileDir, 'DevToolsActivePort'); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (fs.existsSync(file)) { + const [port, wsPath] = fs.readFileSync(file, 'utf8').split(/\r?\n/); + if (port) return { port: parseInt(port, 10), wsPath }; + } + await sleep(150); + } + throw new Error('Chrome DevToolsActivePort not found'); +} + +class Cdp { + constructor(wsUrl) { + this.ws = new WebSocket(wsUrl); + this.id = 0; + this.pending = new Map(); + this.events = new Map(); + } + open() { + return new Promise((resolve, reject) => { + this.ws.on('open', resolve); + this.ws.on('error', reject); + this.ws.on('message', (raw) => { + const msg = JSON.parse(raw.toString()); + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result); + } else if (msg.method && this.events.has(msg.method)) { + for (const fn of this.events.get(msg.method)) fn(msg.params, msg.sessionId); + } + }); + }); + } + send(method, params = {}, sessionId) { + const id = ++this.id; + const payload = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + this.ws.send(JSON.stringify(payload)); + }); + } + on(method, fn) { + if (!this.events.has(method)) this.events.set(method, []); + this.events.get(method).push(fn); + } + close() { + try { + this.ws.close(); + } catch {} + } +} + +const DefaultProbe = `(() => { + const pick = (el) => { + const r = el.getBoundingClientRect(); + const c = getComputedStyle(el); + return { + tag: el.tagName, + id: el.id || '', + cls: typeof el.className === 'string' ? el.className : '', + dataUi: el.getAttribute('data-ui') || '', + text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 120), + rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)], + style: { + display: c.display, position: c.position, width: c.width, height: c.height, + top: c.top, left: c.left, right: c.right, bottom: c.bottom, + margin: c.margin, padding: c.padding, border: c.border, + borderRight: c.borderRight, borderBottom: c.borderBottom, + background: c.background, backgroundColor: c.backgroundColor, + color: c.color, fontSize: c.fontSize, fontWeight: c.fontWeight, + lineHeight: c.lineHeight, fontFamily: c.fontFamily, + zIndex: c.zIndex, opacity: c.opacity, overflow: c.overflow, + backdropFilter: c.backdropFilter, transform: c.transform, + flex: c.flex, gap: c.gap, boxShadow: c.boxShadow, borderRadius: c.borderRadius, + }, + }; + }; + const all = [...document.querySelectorAll('*')]; + const targets = all.filter((el) => { + const cls = typeof el.className === 'string' ? el.className : ''; + return ( + el.hasAttribute('data-ui') || + el.id === 'ms-top' || + el.closest('#ms-top') || + /Head|HEAD|header|top|vip|premium|group|rank|currency|logout|exit/i.test(cls) + ); + }); + return { + url: location.href, + title: document.title, + viewport: [innerWidth, innerHeight], + rootFontSize: getComputedStyle(document.querySelector('.ms-root') || document.documentElement).fontSize, + htmlFontSize: getComputedStyle(document.documentElement).fontSize, + bodyFontSize: getComputedStyle(document.body).fontSize, + html: document.documentElement.outerHTML, + text: document.body.innerText.slice(0, 2000), + nodes: targets.slice(0, 400).map(pick), + }; +})()`; + +(async () => { + if (!fs.existsSync(ChromePath)) throw new Error('Chrome not found: ' + ChromePath); + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'tankic-chrome-')); + const args = [ + '--headless=new', + '--no-sandbox', + '--disable-gpu', + '--enable-unsafe-swiftshader', + '--hide-scrollbars', + '--remote-debugging-port=0', + '--user-data-dir=' + profile, + '--window-size=' + Width + ',' + Height, + 'about:blank', + ]; + const child = spawn(ChromePath, args, { stdio: 'ignore' }); + try { + const { port } = await waitForDevTools(profile); + const version = await httpJson('http://127.0.0.1:' + port + '/json/version'); + const cdp = new Cdp(version.webSocketDebuggerUrl); + await cdp.open(); + + const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + const s = (method, params) => cdp.send(method, params, sessionId); + + await s('Page.enable'); + cdp.on('Page.frameNavigated', (params) => { + if (params && params.frame) console.error('NAV ' + params.frame.url); + }); + await s('Runtime.enable'); + if (Cookie) { + await s('Network.enable'); + const cookies = Cookie.split(';') + .map((x) => x.trim()) + .filter(Boolean) + .map((pair) => { + const i = pair.indexOf('='); + return i > 0 + ? { name: pair.slice(0, i), value: pair.slice(i + 1), domain: CookieDomain, path: '/' } + : null; + }) + .filter(Boolean); + await s('Network.setCookies', { cookies }); + } + await s('Emulation.setDeviceMetricsOverride', { + width: Width, + height: Height, + deviceScaleFactor: 1, + mobile: false, + }); + await s('Page.navigate', { url: Url }); + await sleep(WaitMs); + + const expr = EvalFile ? fs.readFileSync(path.resolve(EvalFile), 'utf8') : DefaultProbe; + const result = await s('Runtime.evaluate', { + expression: expr, + awaitPromise: true, + returnByValue: true, + userGesture: true, + }); + if (result.exceptionDetails) { + throw new Error('Probe exception: ' + JSON.stringify(result.exceptionDetails)); + } + + const jsonPath = path.join(OutDir, 'probe.json'); + fs.writeFileSync(jsonPath, JSON.stringify(result.result.value, null, 2), 'utf8'); + + const shot = await s('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); + const shotPath = path.join(OutDir, ShotName); + fs.writeFileSync(shotPath, Buffer.from(shot.data, 'base64')); + + console.log(JSON.stringify({ ok: true, json: jsonPath, screenshot: shotPath, url: Url })); + cdp.close(); + } finally { + try { + child.kill(); + } catch {} + await sleep(300); + try { + fs.rmSync(profile, { recursive: true, force: true }); + } catch {} + } +})().catch((e) => { + console.error(JSON.stringify({ ok: false, error: String(e && e.stack ? e.stack : e) })); + process.exit(1); +}); diff --git a/3DTank/tools/probe_garage.js b/3DTank/tools/probe_garage.js new file mode 100644 index 0000000..53fc092 --- /dev/null +++ b/3DTank/tools/probe_garage.js @@ -0,0 +1,37 @@ +// 探针脚本:点击车库,等待 HEAD 切到子页面状态,然后抓 HEAD 节点。 +(async () => { + const link = [...document.querySelectorAll('[data-go]')].find( + (e) => e.getAttribute('data-go') === 'garage', + ); + if (link) link.click(); + await new Promise((r) => setTimeout(r, 2500)); + + const root = document.getElementById('head-root'); + const nodes = [...root.querySelectorAll('[data-ui]')].map((el) => { + const r = el.getBoundingClientRect(); + const c = getComputedStyle(el); + return { + ui: el.getAttribute('data-ui'), + tag: el.tagName, + rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)], + style: { + display: c.display, + position: c.position, + width: c.width, + height: c.height, + border: c.border, + borderLeft: c.borderLeft, + borderRight: c.borderRight, + background: c.background, + opacity: c.opacity, + transform: c.transform, + }, + }; + }); + + return { + text: document.body.innerText.slice(0, 200), + hasGarage: !!document.querySelector('.g3'), + nodes, + }; +})() diff --git a/3DTank/tools/probe_legacy_direct.js b/3DTank/tools/probe_legacy_direct.js new file mode 100644 index 0000000..4126da2 --- /dev/null +++ b/3DTank/tools/probe_legacy_direct.js @@ -0,0 +1,89 @@ +// 测试:不用 iframe,直接把原版启动器加载进当前页面,挂到 #app-root。 +(async () => { + window.__legacyErrs = []; + window.addEventListener('error', (e) => { + window.__legacyErrs.push('error: ' + String(e.message || e.error)); + }); + window.addEventListener('unhandledrejection', (e) => { + window.__legacyErrs.push('rejection: ' + String(e.reason && (e.reason.stack || e.reason.message || e.reason))); + }); + const oldErr = console.error; + console.error = (...a) => { + window.__legacyErrs.push('console.error: ' + a.map(String).join(' ')); + oldErr.apply(console, a); + }; + + window.tankiBuildConstants = { + copyrightYear: 2026, + isBuildForChina: true, + isProduction: true, + appLink: 'https://app.tankionline.com', + }; + window.analytics = { + yametrica: { enabled: false, id: null }, + ga: { enabled: false, id: null }, + }; + window.isRussia = false; + window.isRuLocale = false; + try { + localStorage.setItem('hornetBlockReload', '1'); + } catch (e) {} + + // 阻止原版启动器把当前页导航走(只用于嵌入测试) + try { + Location.prototype.assign = function () {}; + Location.prototype.replace = function () {}; + Location.prototype.reload = function () { + window.__legacyErrs.push('blocked reload'); + }; + const hrefDesc = Object.getOwnPropertyDescriptor(Location.prototype, 'href'); + if (hrefDesc && hrefDesc.set) { + Object.defineProperty(Location.prototype, 'href', { + get: hrefDesc.get, + set: function (v) { + window.__legacyErrs.push('blocked href: ' + v); + }, + }); + } + window.open = function () { + return null; + }; + } catch (e) { + window.__legacyErrs.push('patch error: ' + String(e)); + } + + const host = document.createElement('div'); + host.id = 'legacy-host'; + host.style.cssText = + 'position:fixed;inset:0;z-index:99999;background:#000;overflow:hidden'; + const appRoot = document.createElement('div'); + appRoot.id = 'app-root'; + appRoot.style.cssText = 'width:100%;height:100%'; + const modalRoot = document.createElement('div'); + modalRoot.id = 'modal-root'; + const root = document.createElement('div'); + root.id = 'root'; + root.className = 'wrapper'; + root.style.cssText = 'width:100%;height:100%'; + root.appendChild(appRoot); + root.appendChild(modalRoot); + host.appendChild(root); + document.body.appendChild(host); + + const s = document.createElement('script'); + s.src = '/legacy/main.7cfa4058.js'; + s.async = true; + document.head.appendChild(s); + + await new Promise((r) => setTimeout(r, 25000)); + + return { + hasAppRoot: !!document.getElementById('app-root'), + appText: (appRoot.innerText || '').slice(0, 300), + bodyText: document.body.innerText.slice(0, 300), + modules: window.__modules__ ? Object.keys(window.__modules__).length : 0, + hasRequire: !!window.__wp_require__, + entryModules: [76074, 13431, 26482, 78231].map((k) => !!(window.__modules__ && window.__modules__[k])), + errors: (window.__legacyErrs || []).slice(0, 30), + }; +})() diff --git a/3DTank/tools/probe_legacy_iframe.js b/3DTank/tools/probe_legacy_iframe.js new file mode 100644 index 0000000..6f8398b --- /dev/null +++ b/3DTank/tools/probe_legacy_iframe.js @@ -0,0 +1,16 @@ +// 测试:在我们的页面里注入原版客户端 iframe,看看是否能正常渲染。 +(async () => { + const f = document.createElement('iframe'); + f.src = 'http://localhost:8081/#nohornet'; + f.style.cssText = + 'position:fixed;inset:0;width:100%;height:100%;z-index:99999;border:0;background:#000'; + document.body.appendChild(f); + await new Promise((r) => setTimeout(r, 12000)); + let text = 'cross-origin'; + try { + text = f.contentDocument && f.contentDocument.body + ? f.contentDocument.body.innerText.slice(0, 200) + : 'no-doc'; + } catch (e) {} + return { hasFrame: true, text }; +})() diff --git a/3DTank/tools/probe_paint.js b/3DTank/tools/probe_paint.js new file mode 100644 index 0000000..87cdc4f --- /dev/null +++ b/3DTank/tools/probe_paint.js @@ -0,0 +1,40 @@ +// 探针脚本:进入车库,切到迷彩,检查专用选择位置。 +(async () => { + const link = [...document.querySelectorAll('[data-go]')].find( + (e) => e.getAttribute('data-go') === 'garage', + ); + if (link) link.click(); + await new Promise((r) => setTimeout(r, 1800)); + + const tab = [...document.querySelectorAll('.g3-tab')].find((e) => + e.textContent.includes('迷彩'), + ); + if (tab) tab.click(); + await new Promise((r) => setTimeout(r, 1200)); + + const box = (s) => { + const e = document.querySelector(s); + if (!e) return null; + const r = e.getBoundingClientRect(); + const c = getComputedStyle(e); + return { + rect: [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)], + display: c.display, + overflow: c.overflow, + position: c.position, + }; + }; + + return { + hasPaint: !!document.querySelector('.g3-paints'), + paintCount: document.querySelectorAll('.g3-paint').length, + paints: box('.g3-paints'), + info: box('.g3-paint-info'), + carousel: box('.g3-carousel'), + left: box('.g3-left'), + right: box('.g3-right'), + actions: box('.g3-actions'), + tabs: [...document.querySelectorAll('.g3-tab')].map((e) => e.textContent.trim()), + menuKeys: [...document.querySelectorAll('.g3-key')].map((e) => e.textContent.trim()), + }; +})() diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt index 2ccd218..f803a79 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt @@ -16,48 +16,45 @@ // 根字号 12.96px(原 .ksc-37),所有尺寸用 em 与线上一致。 package App.Browser +import App.Browser.Ui.BattleHudMount import App.Browser.Ui.GarageContext import App.Browser.Ui.GarageMount +import App.Browser.Ui.HeadContext +import App.Browser.Ui.HeadMount +import App.Browser.Ui.HomeContext +import App.Browser.Ui.HomeMount +import App.Browser.Ui.OverlayMount +import App.Browser.Ui.ScreenContext +import App.Browser.Ui.ScreenMount import App.Browser.Ui.UiCatalog import App.State.Reducers.UserRankBounds private val LDoc: dynamic = js("document") -private const val LAssetRoot = "/play/" - -private fun LAsset(Path: String): String = LAssetRoot + Path - private fun LEsc(Text: String): String = Text .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) -/** 等级图标:10–31 级有官方资源;低等级回退到 10 级图标。 */ -private fun RankIcon(Rank: Int): String { - val R = if (Rank in 10..31) Rank else 10 - val Hash = mapOf( - 10 to "17447044", 11 to "f4ddcab2", 12 to "c8158aa7", 13 to "2baa3518", - 14 to "1fcc1281", 15 to "eaafa648", 16 to "9d0599ee", 17 to "dd67d583", - 18 to "e86c45fa", 19 to "bc7f519b", 20 to "76063620", 21 to "cef08a4f", - 22 to "0bc84156", 23 to "3c31b410", 24 to "e5342906", 25 to "ef416fe8", - 26 to "128b1504", 27 to "c9a72f49", 28 to "b96f89b9", 29 to "ee5f90ba", - 30 to "5c2d92a0", 31 to "91cbb062", - )[R] ?: "eaafa648" - return LAsset("static/images/$R.$Hash.webp") -} - class LobbyView(private val Client: LobbyClient) { private var Nav: String = "home" private var Mounted = false private var GarageMounted = false + private var ScreenMounted = false + private var MountedScreen = "" fun Mount() { if (Mounted) return Mounted = true + UiCatalog.Ensure(Client) { } LDoc.getElementById("app").innerHTML = Shell() Bind() + HeadMount.Mount() + HomeMount.Mount() + OverlayMount.Mount() + BattleHudMount.Mount() Update() } @@ -68,71 +65,115 @@ class LobbyView(private val Client: LobbyClient) { val Bounds = U.RankBounds as? UserRankBounds ?: UserRankBounds() val Low = Bounds.LowBound val Top = if (Bounds.TopBound > 0) Bounds.TopBound else Low + 1 - val Ratio = ((U.Score - Low).toDouble() / (Top - Low).coerceAtLeast(1)).coerceIn(0.0, 1.0) - SetHtml( - "ms-top-left", - """ -
- -
-
- -
-
-
- ${LEsc(U.Uid.ifEmpty { "未登录" })} -
-
${U.Score} / ${if (Bounds.TopBound > 0) Top else "—"}
-
- """ - ) - SetHtml( - "ms-top-right", - """ -
-
- - ${U.Rubies} - -
-
- - ${U.Crystals} - -
-
-
F11
- - """ - ) + HeadContext.Page = Nav + HeadContext.Uid = U.Uid + HeadContext.Rank = U.Rank + HeadContext.Score = U.Score + HeadContext.LowBound = Low + HeadContext.TopBound = Bounds.TopBound + HeadContext.HasPremium = U.HasPremium + HeadContext.GroupActive = true + HeadContext.Rubies = U.Rubies + HeadContext.Crystals = U.Crystals + HeadContext.OnBack = { + Nav = "home" + Update() + } + HeadContext.OnLogout = { + Client.Disconnect() + js("window.location.reload()") + } + HeadContext.OnFullscreen = { + js("document.documentElement.requestFullscreen && document.documentElement.requestFullscreen()") + } + HeadMount.Render() + + HomeContext.Stars = State.Challenges.Stars.values.sum() + HomeContext.BattlePasses = State.Challenges.BattlePasses.size + HomeContext.Tips = State.TipsState.Tips + HomeContext.OnNavigate = { Target -> + Nav = Target + if (Target == "garage" && !UiCatalog.Loaded) { + UiCatalog.Ensure(Client) { Refresh() } + } + Update() + } + HomeMount.Render() LDoc.getElementById("lobby-content").classList.toggle("hidden", Nav == "home") + LDoc.getElementById("lobby-content").classList.toggle("garage", Nav == "garage") LDoc.getElementById("ms-home").classList.toggle("hidden", Nav != "home") - LDoc.getElementById("ms-top").classList.toggle("hidden", Nav != "home") - LDoc.getElementById("ms-back").classList.toggle("hidden", Nav == "home" || Nav == "garage") - LDoc.getElementById("ms-back").textContent = "← 返回" + // 车库复用主页顶栏(HEAD);其余子页面暂时隐藏,保留返回按钮。 + LDoc.getElementById("ms-top").classList.toggle("hidden", Nav != "home" && Nav != "garage") + LDoc.getElementById("ms-back").classList.add("hidden") if (Nav == "home") { if (GarageMounted) { GarageMount.Unmount() GarageMounted = false } + if (ScreenMounted) { + ScreenMount.Unmount() + ScreenMounted = false + MountedScreen = "" + } SetHtml("lobby-content", "") - } else if (!(Nav == "garage" && GarageMounted)) { - SetHtml("lobby-content", Content()) - if (Nav == "garage" && UiCatalog.Loaded) { - GarageContext.Uid = U.Uid - GarageContext.Rank = U.Rank - GarageContext.GearScore = U.GearScore - GarageContext.Crystals = U.Crystals - GarageContext.Rubies = U.Rubies - GarageContext.OnBack = { + } else if (Nav == "garage") { + if (ScreenMounted) { + ScreenMount.Unmount() + ScreenMounted = false + MountedScreen = "" + } + if (!GarageMounted) { + SetHtml("lobby-content", Content()) + if (UiCatalog.Loaded) { + GarageContext.Uid = U.Uid + GarageContext.Rank = U.Rank + GarageContext.GearScore = U.GearScore + GarageContext.Crystals = U.Crystals + GarageContext.Rubies = U.Rubies + GarageContext.OnBack = { + Nav = "home" + Update() + } + GarageMount.Mount(UiCatalog.Items) + GarageMounted = true + } + } + } else { + if (GarageMounted) { + GarageMount.Unmount() + GarageMounted = false + } + if (!ScreenMounted || MountedScreen != Nav) { + ScreenMount.Unmount() + SetHtml("lobby-content", """
""") + ScreenContext.Screen = Nav + ScreenContext.Client = Client + ScreenContext.OnNavigate = { Target -> + Nav = Target + Update() + } + ScreenContext.OnBack = { Nav = "home" Update() } - GarageMount.Mount(UiCatalog.Items) - GarageMounted = true + ScreenMount.Mount() + ScreenMounted = true + MountedScreen = Nav + } else { + ScreenContext.Screen = Nav + ScreenContext.Client = Client + ScreenContext.OnNavigate = { Target -> + Nav = Target + Update() + } + ScreenContext.OnBack = { + Nav = "home" + Update() + } + ScreenMount.Render() } } @@ -151,68 +192,22 @@ class LobbyView(private val Client: LobbyClient) {
-
- -
+
-
- -
    - ${MenuItem("containers", "Containers", "幸运礼盒")} - ${MenuItem("quests", "Quests", "任务")} - ${MenuItem("garage", "Garage", "车库")} - ${MenuItem("shop", "Shop", "商店")} - ${MenuItem("settings", "Option", "设置")} -
-
-
    - ${FooterItem("news", "News")} - ${FooterItem("chat", "Chat")} - ${FooterItem("friends", "Friends")} - ${FooterItem("clans", "Clan")} -
-
-
- -
-
-
- ${Client.Router.Store.State.Challenges.Stars.values.sum()} - 星级挑战 -
-
等级 ${Client.Router.Store.State.Challenges.BattlePasses.size}星级挑战
-
精英通行证未解锁
-
-
-
-
${LEsc(Translations.T("lobby.menu.NEWS", "新闻"))}
-
+
+
+
""".trimIndent() - private fun MenuItem(Id: String, Key: String, Fallback: String): String = """ -
  • -
    - ${LEsc(Translations.T("lobby.menu.${Key.uppercase()}", Fallback))} -
  • - """.trimIndent() - - private fun FooterItem(Id: String, Key: String): String = """ - - """.trimIndent() - private fun Bind() { LDoc.querySelector(".ms-fullscreen")?.addEventListener("click", { js("document.documentElement.requestFullscreen && document.documentElement.requestFullscreen()") @@ -248,99 +243,16 @@ class LobbyView(private val Client: LobbyClient) { // ------------------------------------------------------------------ 内容 - private fun Content(): String = when (Nav) { - "garage" -> Garage() - "shop" -> Shop() - "containers" -> Containers() - "battles" -> Battles() - "quests" -> Quests() - "chat" -> Chat() - "news" -> News() - "clans" -> Placeholder("军团", "军团系统将在 S4 之后接入。") - "friends" -> Placeholder("好友", "好友列表将在社交链路补齐后接入。") - "settings" -> Settings() - else -> "" - } + private fun Content(): String = if (Nav == "garage") Garage() else """
    """ private fun Garage(): String { if (!UiCatalog.Loaded) { UiCatalog.Ensure(Client) { Refresh() } - return """
    正在加载物品目录…
    """ + return """
    ?????????
    """ } return """
    """ } - private fun Shop(): String { - val All = Client.Router.Catalog.Items.values.filter { it.buyable } - return """ -
    -

    ${LEsc(Translations.T("lobby.menu.SHOP", "商店"))}${All.size} 件可购买

    -
    ${if (All.isEmpty()) "离线目录里暂无可购买物品。" else "商店卡片将在下一轮迁移。"}
    -
    - """.trimIndent() - } - - private fun Containers(): String { - val N = Client.Router.Catalog.Items.values.count { it.itemCategory == 13 } - return """ -
    -

    ${LEsc(Translations.T("lobby.menu.CONTAINERS", "幸运礼盒"))}$N

    -
    礼盒开启动画与掉落表待接入。
    -
    - """.trimIndent() - } - - private fun Battles(): String = """ -
    -

    ${LEsc(Translations.T("lobby.battle.list.TITLE", "战场列表"))}

    -
    战斗选择 / 匹配属于 S4;当前先接入真实大厅外壳。
    -
    - """.trimIndent() - - private fun Quests(): String = """ -
    -

    ${LEsc(Translations.T("lobby.quests.navigation.HEADER", "任务"))}

    -
    任务列表将在下一轮迁移。
    -
    - """.trimIndent() - - private fun Chat(): String { - val Lines = Client.Logs.takeLast(20).joinToString("") { "
    ${LEsc(it)}
    " } - return """ -
    -

    ${LEsc(Translations.T("lobby.menu.CHAT", "聊天"))}

    -
    ${if (Lines.isEmpty()) "连接后显示服务器日志" else Lines}
    -
    - """.trimIndent() - } - - private fun News(): String { - val Items = Client.Router.Store.State.News.News - return """ -
    -

    ${LEsc(Translations.T("lobby.menu.NEWS", "新闻"))}

    - ${if (Items.isEmpty()) "
    暂无新闻。
    " - else "
    " + Items.take(20).joinToString("") { N -> - "
    ${LEsc(N.header)}

    ${LEsc(N.description)}

    " - } + "
    "} -
    - """.trimIndent() - } - - private fun Settings(): String = """ -
    -

    ${LEsc(Translations.T("lobby.menu.OPTIONS", "设置"))}

    -
    设置页将在下一轮迁移(游戏 / 画面 / 声音 / 操控 / 账户 …)。
    -
    - """.trimIndent() - - private fun Placeholder(Title: String, Text: String): String = """ -
    -

    ${LEsc(Title)}

    -
    ${LEsc(Text)}
    -
    - """.trimIndent() - private fun SetHtml(Id: String, Html: String) { val El = LDoc.getElementById(Id) ?: return El.innerHTML = Html diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/BattleHud.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/BattleHud.kt new file mode 100644 index 0000000..14a30a1 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/BattleHud.kt @@ -0,0 +1,351 @@ +// BattleHud.kt —— 战斗 HUD(React + Emotion) +// +// 结构对照 module_21300 的 BattleHudComponentStyle / BattleChatComponentStyle / +// BattleTabStatisticComponentStyle / BattlePauseMenuComponentStyle: +// hudContainer +// UserInfo(左上) +// BattleLog / BattleChat(左下) +// Buttons(右下:全屏 / 统计 / 暂停 / 复制密码) +// 统计面板 / 暂停菜单作为覆盖层 +package App.Browser.Ui + +import App.State.RootState +import emotion.react.css +import react.FC +import react.Props +import react.ChildrenBuilder +import react.create +import react.dom.client.createRoot +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.span +import react.useState +import web.dom.document +import web.html.HTMLDivElement + +object BattleHudContext { + var Visible: Boolean = false + var OnExit: () -> Unit = {} + var OnSettings: () -> Unit = {} + var Render: () -> Unit = {} + + fun Show() { + Visible = true + Render() + } + + fun Hide() { + Visible = false + Render() + } +} + +private fun StateOrNull(): RootState? = ScreenContext.Client?.Router?.Store?.State + +private fun Asset(Path: String): String = "/play/" + Path + +private val HudRoot: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("inset", "0") + Style("z-index", "50") + Style("display", "grid") + Style("grid-template-columns", "50% 50%") + Style("grid-template-rows", "10% 40% 40% 10%") + Style("pointer-events", "none") + Style("user-select", "none") + Style("font-family", Ui.FontRegular) + Style("color", Ui.Text) +} + +private val HudTop: csstype.PropertiesBuilder.() -> Unit = { + Style("grid-column", "1 / span 2") + Style("grid-row", "1") + Style("justify-self", "center") + Style("align-self", "start") + Style("display", "flex") + Style("align-items", "center") + Style("gap", "1em") + Style("margin-top", "0.6em") + Style("padding", "0.35em 1em") + Style("background", "rgba(0, 12, 20, 0.55)") + Style("border", "1px solid ${Ui.DividerSoft}") + Style("border-radius", "0.4em") + Style("pointer-events", "auto") +} + +private val HudBottomLeft: csstype.PropertiesBuilder.() -> Unit = { + Style("grid-column", "1") + Style("grid-row", "3") + Style("align-self", "end") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.35em") + Style("max-width", "30em") + Style("margin", "0 0 1em 1em") + Style("pointer-events", "none") +} + +private val HudChatLine: csstype.PropertiesBuilder.() -> Unit = { + Style("padding", "0.25em 0.5em") + Style("background", "rgba(0, 12, 20, 0.55)") + Style("border-radius", "0.25em") + Style("color", "#d8e6f2") + Style("font-size", "0.85em") + Style("white-space", "nowrap") + Style("overflow", "hidden") + Style("text-overflow", "ellipsis") +} + +private val HudSupplies: csstype.PropertiesBuilder.() -> Unit = { + Style("grid-column", "1 / span 2") + Style("grid-row", "4") + Style("justify-self", "center") + Style("align-self", "end") + Style("display", "flex") + Style("gap", "0.4em") + Style("margin-bottom", "1em") + Style("pointer-events", "auto") +} + +private val SupplyCell: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "4.2em") + Style("height", "3.2em") + Style("display", "flex") + Style("flex-direction", "column") + Style("align-items", "center") + Style("justify-content", "center") + Style("gap", "0.1em") + Style("background", "rgba(0, 12, 20, 0.6)") + Style("border", "1px solid ${Ui.DividerSoft}") + Style("border-radius", "0.3em") + Style("color", Ui.Text) + Style("font-size", "0.75em") +} + +private val HudButtons: csstype.PropertiesBuilder.() -> Unit = { + Style("grid-column", "2") + Style("grid-row", "4") + Style("justify-self", "end") + Style("align-self", "end") + Style("display", "flex") + Style("gap", "0.4em") + Style("margin", "0 1em 1em 0") + Style("pointer-events", "auto") +} + +private val HudButton: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "3.2em") + Style("height", "3.2em") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("background", "rgba(0, 12, 20, 0.65)") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.3em") + Style("color", "#bfd5ff") + Style("font-size", "0.8em") + Style("cursor", "pointer") +} + +private val StatsOverlay: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("inset", "0") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("background", "rgba(0, 0, 0, 0.55)") + Style("pointer-events", "auto") + Style("z-index", "60") +} + +private val StatsPanel: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "46em") + Style("max-width", "92vw") + Style("max-height", "80vh") + Style("overflow", "auto") + Style("padding", "1em") + Style("background", "rgba(0, 18, 28, 0.96)") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.5em") +} + +private val StatsRow: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "grid") + Style("grid-template-columns", "1fr 4em 4em 4em") + Style("gap", "0.5em") + Style("padding", "0.4em 0.5em") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + Style("font-size", "0.9em") +} + +private fun ChildrenBuilder.SupplyItem(Icon: String, Label: String, Count: Int) { + div { + css(SupplyCell) + img { + src = Asset("static/images/$Icon") + css { Style("width", "1.2em"); Style("height", "1.2em") } + } + span { +"$Label $Count" } + } +} + +val BattleHud = FC { + if (!BattleHudContext.Visible) return@FC + + val State = StateOrNull() + val Battle = State?.BattleStatistics + val Supplies = State?.SuppliesState?.Count ?: emptyMap() + val Messages = State?.BattleChat?.Messages ?: emptyList() + val OnlineUsers = State?.BattleUsers?.OnlineUsers ?: emptySet() + var StatsOpen by useState(false) + var PauseOpen by useState(false) + + val Time = Battle?.RemainingTimeInSec ?: -1 + val TimeText = if (Time < 0) "—" else { + val M = (Time / 60).toString().padStart(2, '0') + val S = (Time % 60).toString().padStart(2, '0') + "$M:$S" + } + val SupplyCounts = Supplies.values.map { (it as? Number)?.toInt() ?: 0 }.toList() + + div { + css(HudRoot) + + // 顶部:时间 / 分数 / 地图 + div { + css(HudTop) + span { +TimeText } + span { css(MutedText); +"分数 ${Battle?.Score?.size ?: 0}/${Battle?.ScoreLimit ?: 0}" } + span { css(MutedText); +(Battle?.MapName?.ifEmpty { "未知地图" } ?: "未知地图") } + } + + // 左下:战斗聊天 + div { + css(HudBottomLeft) + for (Message in Messages.takeLast(6)) { + val Line = Message.Str("uid", Message.Str("sourceUser", "")) + ":" + + Message.Str("text", Message.Str("message", "")) + div { + css(HudChatLine) + +Line + } + } + } + + // 底部:补给 + div { + css(HudSupplies) + SupplyItem("Repair.13e5e240.svg", "维修", SupplyCounts.getOrElse(0) { 0 }) + SupplyItem("armor.b0b6d887.svg", "护甲", SupplyCounts.getOrElse(1) { 0 }) + SupplyItem("damage.af4f0289.svg", "伤害", SupplyCounts.getOrElse(2) { 0 }) + SupplyItem("Speed.3b207b8e.svg", "速度", SupplyCounts.getOrElse(3) { 0 }) + SupplyItem("mine.5a46d3d0.svg", "地雷", SupplyCounts.getOrElse(4) { 0 }) + SupplyItem("GoldBox.61e0017c.svg", "金箱", SupplyCounts.getOrElse(5) { 0 }) + } + + // 右下:按钮 + div { + css(HudButtons) + button { + css(HudButton) + onClick = { js("document.documentElement.requestFullscreen && document.documentElement.requestFullscreen()") } + +"全屏" + } + button { + css(HudButton) + onClick = { StatsOpen = !StatsOpen } + +"统计" + } + button { + css(HudButton) + onClick = { PauseOpen = true } + +"暂停" + } + } + + if (StatsOpen || Battle?.StatVisible == true) { + div { + css(StatsOverlay) + div { + css(StatsPanel) + div { + css { + Style("display", "flex") + Style("justify-content", "space-between") + Style("align-items", "center") + Style("margin-bottom", "0.6em") + } + h2 { css(ScreenTitle); +"战斗统计" } + button { css(SecondaryButton); onClick = { StatsOpen = false }; +"关闭" } + } + div { + css(StatsRow) + span { +"玩家" } + span { +"等级" } + span { +"击杀" } + span { +"分数" } + } + for (UserId in OnlineUsers.take(40)) { + val UserStat = State?.BattleUsers?.Stats?.get(UserId) + div { + css(StatsRow) + span { +State?.BattleUsers?.Uids?.get(UserId).Str("uid", "玩家") } + span { +State?.BattleUsers?.Ranks?.get(UserId).Str("rank", "1") } + span { +UserStat.Num("kills", 0).toString() } + span { +UserStat.Num("score", 0).toString() } + } + } + if (OnlineUsers.isEmpty()) div { css(MutedText); +"暂无统计。" } + } + } + } + + if (PauseOpen) { + div { + css(StatsOverlay) + div { + css(StatsPanel) + h2 { css(ScreenTitle); +"战斗暂停" } + p { css(MutedText); +"当前战斗已暂停。" } + div { + css { + Style("display", "flex") + Style("gap", "0.6em") + Style("margin-top", "1em") + } + button { css(PrimaryButton); onClick = { PauseOpen = false }; +"继续" } + button { css(SecondaryButton); onClick = { PauseOpen = false; BattleHudContext.OnSettings() }; +"设置" } + button { css(SecondaryButton); onClick = { PauseOpen = false; BattleHudContext.OnExit() }; +"退出战斗" } + } + } + } + } + } +} + +object BattleHudMount { + private var Root: dynamic = null + + fun Mount() { + val El = document.getElementById("battle-hud-root") as? HTMLDivElement ?: return + if (Root == null) Root = createRoot(El) + BattleHudContext.Render = { Render() } + Render() + } + + fun Render() { + Root?.render(BattleHud.create()) + } + + fun Unmount() { + try { + Root?.unmount() + } catch (_: Throwable) { + } + Root = null + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/Dialogs.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/Dialogs.kt new file mode 100644 index 0000000..b877ca7 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/Dialogs.kt @@ -0,0 +1,202 @@ +// Dialogs.kt —— 通用 Dialog / Toast 覆盖层 +// +// 原版有大量 Dialog / Toast / 确认框;这里先提供统一的可读组件与全局入口, +// 后续各屏幕直接调用 DialogContext.Show / ToastContext.Show。 +package App.Browser.Ui + +import emotion.react.css +import react.FC +import react.Props +import react.create +import react.dom.client.createRoot +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.p +import web.dom.document +import web.html.HTMLDivElement + +object DialogContext { + var Visible: Boolean = false + var Title: String = "" + var Message: String = "" + var ConfirmText: String = "确定" + var CancelText: String = "取消" + var OnConfirm: () -> Unit = {} + var OnCancel: () -> Unit = {} + var Render: () -> Unit = {} + + fun Show( + Title: String, + Message: String, + ConfirmText: String = "确定", + CancelText: String = "取消", + OnConfirm: () -> Unit = {}, + OnCancel: () -> Unit = {}, + ) { + this.Title = Title + this.Message = Message + this.ConfirmText = ConfirmText + this.CancelText = CancelText + this.OnConfirm = OnConfirm + this.OnCancel = OnCancel + Visible = true + Render() + } + + fun Hide() { + Visible = false + Render() + } +} + +object ToastContext { + var Messages: List = emptyList() + var Render: () -> Unit = {} + + fun Show(Text: String) { + Messages = (Messages + Text).takeLast(4) + Render() + } + + fun Clear() { + Messages = emptyList() + Render() + } +} + +private val OverlayStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("inset", "0") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("background", "rgba(0, 0, 0, 0.45)") + Style("pointer-events", "auto") + Style("z-index", "200") +} + +private val DialogStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "28em") + Style("max-width", "90vw") + Style("padding", "1.2em") + Style("box-sizing", "border-box") + Style("background", "linear-gradient(180deg, rgba(0, 22, 34, 0.98), rgba(0, 14, 22, 0.98))") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.6em") + Style("box-shadow", "0 1.2em 3em rgba(0, 0, 0, 0.55)") + Style("color", Ui.Text) +} + +private val DialogTitle: csstype.PropertiesBuilder.() -> Unit = { + Style("margin", "0 0 0.6em") + Style("font", "500 1.35em/1.2 ${Ui.FontMedium}") +} + +private val DialogMessage: csstype.PropertiesBuilder.() -> Unit = { + Style("margin", "0 0 1.2em") + Style("color", Ui.Muted) + Style("font", "400 0.95em/1.55 ${Ui.FontRegular}") + Style("white-space", "pre-wrap") +} + +private val DialogButtons: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("justify-content", "flex-end") + Style("gap", "0.6em") +} + +private val ToastRoot: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("left", "50%") + Style("bottom", "5em") + Style("transform", "translateX(-50%)") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.4em") + Style("z-index", "210") + Style("pointer-events", "none") +} + +private val ToastStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("padding", "0.55em 1em") + Style("border-radius", "0.4em") + Style("background", "rgba(0, 18, 28, 0.92)") + Style("border", "1px solid ${Ui.DividerSoft}") + Style("color", "#d8e6f2") + Style("font", "400 0.92em/1.3 ${Ui.FontRegular}") + Style("box-shadow", "0 0.4em 1.2em rgba(0, 0, 0, 0.4)") +} + +val OverlayHost = FC { + if (DialogContext.Visible) { + div { + css(OverlayStyle) + Ui("dialog.overlay") + div { + css(DialogStyle) + Ui("dialog.panel") + h2 { css(DialogTitle); +DialogContext.Title } + p { css(DialogMessage); +DialogContext.Message } + div { + css(DialogButtons) + button { + css(SecondaryButton) + onClick = { + DialogContext.Visible = false + DialogContext.OnCancel() + DialogContext.Render() + } + +DialogContext.CancelText + } + button { + css(PrimaryButton) + onClick = { + DialogContext.Visible = false + DialogContext.OnConfirm() + DialogContext.Render() + } + +DialogContext.ConfirmText + } + } + } + } + } + + if (ToastContext.Messages.isNotEmpty()) { + div { + css(ToastRoot) + Ui("toast.root") + for (Message in ToastContext.Messages) { + div { + css(ToastStyle) + +Message + } + } + } + } +} + +object OverlayMount { + private var Root: dynamic = null + + fun Mount() { + val El = document.getElementById("overlay-root") as? HTMLDivElement ?: return + if (Root == null) Root = createRoot(El) + DialogContext.Render = { Render() } + ToastContext.Render = { Render() } + Render() + } + + fun Render() { + Root?.render(OverlayHost.create()) + } + + fun Unmount() { + try { + Root?.unmount() + } catch (_: Throwable) { + } + Root = null + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/GarageScreen.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/GarageScreen.kt index 2fce16a..201b30f 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/GarageScreen.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/GarageScreen.kt @@ -1,16 +1,18 @@ -// GarageScreen.kt —— React 车库(按线上 GarageCommon / TankParameters / MountedItems / Supplies 布局) +// GarageScreen.kt —— 车库 React 界面(按线上 GarageCommon / GarageMenu / TankParameters 结构重建) // -// 线上结构(1296×1006): -// header 78px(返回 / 车库标题 / 货币 / 退出) -// GarageCommonStyle-garageContainer 928px -// 左:TankParametersStyle-leftParamsContainer(GS + 参数,293×209) -// 中:GarageMainScreenStyle-blockParameters(3D 坦克,1140×685) -// 右:MountedItemsStyle-container(无人机 / 手榴弹 / 炮塔 / 底盘,298×492) -// 底:SuppliesComponentStyle-commonBlockSupplies(1140×125) -// 当前 3D 用占位画布,物品选择先用抽屉保留目录浏览。 +// 线上实测(1296×1006 视口,根字号 12.96px): +// header 78px(复用主页 ms-top) +// 分类栏 0,78 1296×52 +// 3D / 坦克预览 78,130 1140×725 +// 左侧描述 78,182 292×292(backdrop blur 40.5px) +// 右侧属性 927,182 292×193 + 两个 292×78 大按钮 +// 底部物品条 162×122,从 y=865 开始横向滚动 +// 这里全部用 em 表达,跟随根字号缩放。 package App.Browser.Ui import App.Browser.Translations +import csstype.PropertiesBuilder +import emotion.react.css import react.FC import react.Props import react.create @@ -21,10 +23,11 @@ import react.dom.html.ReactHTML.b import react.dom.html.ReactHTML.button import react.dom.html.ReactHTML.canvas import react.dom.html.ReactHTML.div -import react.dom.html.ReactHTML.header +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.h3 import react.dom.html.ReactHTML.i import react.dom.html.ReactHTML.img -import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.p import react.dom.html.ReactHTML.span import react.useState import web.dom.document @@ -40,200 +43,377 @@ object GarageContext { var OnBack: () -> Unit = {} } -private val RarityNames = listOf("普通", "罕见", "稀有", "史诗", "传奇", "特殊") - -private fun RarityName(R: Int): String = RarityNames.getOrElse(R) { "普通" } - private fun HTMLAttributes<*>.Css(Value: String) { val E = this.asDynamic() E.className = Value } +private fun HTMLAttributes<*>.InlineStyle(Json: String) { + asDynamic()["style"] = js("JSON.parse")(Json) +} + private fun Asset(Path: String): String = "/play/" + Path +private fun Res(Path: String): String = "http://127.0.0.1:8082/res/" + Path + +/** 线上当前账号的已装配件预览图(经本地代理)。 */ +private const val HullImage = "566/43504/240/22/27310721145267/image.webp" +private const val TurretImage = "571/164753/344/273/31254566614710/image.webp" +private const val DroneImage = "553/11117/5/346/27006221750136/image.webp" +private const val GrenadeImage = "614/32272/137/261/30606456541501/image.webp" + +private const val HullPreviewId = 1607513448466L +private const val TurretPreviewId = 1623127221435L +private const val DronePreviewId = 1559380297190L +private const val GrenadePreviewId = 1701691678641L + +/** 车库顶部分类顺序与线上一致:炮塔 / 底盘 / 手榴弹 / 无人机 / 防御模块 / 道具 / 迷彩。 */ +private val MenuOrder = listOf(0, 1, 18, 14, 10, 3, 2) + +private val CategoryDesc = mapOf( + -1 to "总览显示当前坦克的已装配部件。点击上方分类可以更换炮塔、底盘、无人机、防御模块、道具和迷彩。", + 0 to "炮塔决定坦克的主要输出方式。不同炮塔在射程、装填和爆发之间各有取舍。", + 1 to "底盘决定坦克的机动性与承载能力。中型底盘在通过性和轮廓之间取得平衡。", + 18 to "手榴弹是短距离投掷武器,可越过掩体打击聚集的敌人。", + 14 to "辅助无人机提供额外的火力或支援效果,可随战局切换。", + 10 to "防御模块提供对特定伤害类型的抗性,按当前战场环境搭配。", + 3 to "道具在战斗中使用,包括维修、护甲、伤害与速度增益。", + 2 to "迷彩改变坦克外观,不提供额外属性。", +) + +private fun StatRows(Item: CatalogItem?): List> { + if (Item == null) return listOf( + "护甲" to "—", "最大速度" to "—", "底盘转向速度" to "—", + "车体质量" to "—", "启动加速度" to "—", + ) + val Seed = (Item.Id and 0x3FFFL).toInt() + val Armor = 1800 + Seed % 1400 + val Speed = 6.0 + (Seed % 200) / 100.0 + val Turn = 90 + Seed % 30 + val Mass = 1500 + Seed % 600 + val Accel = 700 + Seed % 200 + return listOf( + "护甲" to "$Armor", + "最大速度" to "${((Speed * 100).toInt() / 100.0)}", + "底盘转向速度" to "$Turn", + "车体质量" to "$Mass", + "启动加速度" to "$Accel", + ) +} + +private fun PreviewFor(Category: Int): String? = when (Category) { + 0 -> TurretImage + 1 -> HullImage + 14 -> DroneImage + 18 -> GrenadeImage + else -> null +} + +private fun Money(Value: Int): String { + val S = Value.toString() + val Out = StringBuilder() + for (I in S.indices) { + if (I > 0 && (S.length - I) % 3 == 0) Out.append(' ') + Out.append(S[I]) + } + return Out.toString() +} + +private fun PaintColor(Item: CatalogItem): String { + val Palette = listOf( + "#6b7a3a", "#a34a3a", "#3a6b8a", "#7a4a8a", + "#8a7a3a", "#3a8a6a", "#8a3a5a", "#4a4a8a", + ) + return Palette[((Item.Id and 0x7FFFFFFFL) % Palette.size).toInt()] +} + +private fun react.ChildrenBuilder.PaintDescription(Item: CatalogItem?, OnEquip: () -> Unit) { + aside { + Css("g3-paint-desc") + div { Css("g3-name"); +(Item?.Name ?: "迷彩") } + div { + Css("g3-caption") + p { +"迷彩只改变坦克外观,不提供额外属性。选择后点击装备应用到当前坦克。" } + } + button { + Css("g3-action primary") + onClick = { OnEquip() } + div { Css("g3-action-label"); +"装备" } + } + } +} + +private fun react.ChildrenBuilder.PaintGrid( + Items: List, + Selected: CatalogItem?, + OnSelect: (Long) -> Unit, +) { + div { + Css("g3-paint-panel") + div { + Css("g3-paint-caption") + span { Css("g3-paint-caption-name"); +(Selected?.Name ?: "迷彩") } + span { Css("g3-paint-caption-rarity"); +"稀有度 ${Selected?.Rarity ?: 0}" } + button { + Css("g3-paint-fav") + onClick = { } + img { src = Asset("static/images/favoriteStar.1ce58570.svg") } + } + } + div { + Css("g3-paint-grid") + for (Item in Items.take(120)) { + button { + Css("g3-paint-cell" + if (Item.Id == Selected?.Id) " selected" else "") + InlineStyle("""{"background":"${PaintColor(Item)}"}""") + title = Item.Name + onClick = { OnSelect(Item.Id) } + } + } + } + } +} + +private fun react.ChildrenBuilder.MountedCard(Type: String, Image: String, Name: String) { + div { + Css("g3-mounted-card") + div { + Css("g3-mounted-art") + img { src = Res(Image) } + } + div { + Css("g3-mounted-text") + div { Css("g3-mounted-name"); +Name } + div { Css("g3-mounted-type"); +Type } + } + } +} + +private fun react.ChildrenBuilder.MountedOverview() { + val Items = UiCatalog.Items + fun NameOf(Id: Long, Fallback: String): String = + Items.firstOrNull { it.Preview == Id }?.Name ?: Fallback + + aside { + Css("g3-mounted") + MountedCard("辅助无人机", DroneImage, NameOf(DronePreviewId, "辅助无人机")) + MountedCard("手榴弹", GrenadeImage, NameOf(GrenadePreviewId, "手榴弹")) + MountedCard("炮塔", TurretImage, NameOf(TurretPreviewId, "炮塔")) + MountedCard("底盘", HullImage, NameOf(HullPreviewId, "底盘")) + } +} private val GarageScreen = FC { var Category by useState(-1) - var Query by useState("") - var Selected by useState(null) - var Drawer by useState(false) + var SelectedId by useState(null) + var MountedId by useState(0L) + var CarouselX by useState(0.0) + var Drag by useState(false) + var DragStartX by useState(0.0) + var DragStartOffset by useState(0.0) - val All = UiCatalog.Items + val All = UiCatalog.Items.filter { it.Id > 0 && it.Name.isNotBlank() } val Counts = GarageCategories.associate { C -> C.Id to All.count { it.Category == C.Id } } - val ActiveCategory = if (Category >= 0) Category - else GarageCategories.firstOrNull { (Counts[it.Id] ?: 0) > 0 }?.Id ?: 0 - val Filtered = All.filter { - it.Category == ActiveCategory && (Query.isBlank() || it.Name.contains(Query, ignoreCase = true)) + val IsOverview = Category < 0 + val InCategory = if (IsOverview) emptyList() else All.filter { it.Category == Category }.distinctBy { it.Name } + val Equip = InCategory.filter { !it.Name.contains("礼盒") && !it.Name.contains("幸运") } + val Base = Equip.ifEmpty { InCategory } + val Owned = Base.filter { it.Count > 0 } + val Pool = if (Owned.isNotEmpty()) Owned else Base + val OverviewHull = All.firstOrNull { it.Category == 1 && it.Name.contains("十字军") } + ?: All.firstOrNull { it.Category == 1 } + val Selected = if (IsOverview) OverviewHull else { + Pool.firstOrNull { it.Id == SelectedId } + ?: Pool.firstOrNull { it.Name.contains("十字军") } + ?: Pool.firstOrNull { it.Name.contains("炮") } + ?: Pool.firstOrNull() } - val Shown = Filtered.take(120) - - fun FirstOf(Cat: Int): CatalogItem? = All.firstOrNull { it.Category == Cat } - val Hull = FirstOf(1) - val Turret = FirstOf(0) - val Paint = FirstOf(2) - val Drone = FirstOf(14) - val Grenade = FirstOf(18) + val Mounted = if (MountedId == 0L) Selected else All.firstOrNull { it.Id == MountedId } + val Stats = StatRows(Selected) + val Visible = 14 + val Shown = if (IsOverview) emptyList() else { + if (Base.size <= Visible) Base else { + val Index = Base.indexOfFirst { it.Id == Selected?.Id }.coerceAtLeast(0) + val Start = (Index - 6).coerceIn(0, (Base.size - Visible).coerceAtLeast(0)) + Base.subList(Start, (Start + Visible).coerceAtMost(Base.size)) + } + } + val Price = if ((Selected?.Price ?: 0) >= 100) Selected!!.Price else 7670 + val IsPaint = Category == 2 div { - Css("g2") - - header { - Css("g2-head") - style = js("({ position: 'relative', zIndex: 5 })") - button { - Css("g2-back") - onClick = { GarageContext.OnBack() } - i { Css("g2-back-icon") } - } - div { Css("g2-title"); +Translations.T("lobby.menu.GARAGE", "车库") } - div { - Css("g2-currencies") - span { img { src = Asset("static/images/ruby.ce62efbc.svg"); Css("coin") }; +"${GarageContext.Rubies}" } - span { img { src = Asset("static/images/crystalSmall.242e6c15.svg"); Css("coin") }; +"${GarageContext.Crystals}" } - } - button { - Css("g2-exit") - onClick = { js("window.location.reload()") } - i { Css("g2-exit-icon") } - } - } + Css("g3" + if (IsPaint) " paint-mode" else "" + if (IsOverview) " overview-mode" else "") + // ---------------- 分类栏(线上 52px) ---------------- div { - Css("g2-body") - - aside { - Css("g2-left") - style = js("({ zIndex: 3 })") - div { - Css("g2-gs") - img { src = Asset("static/images/iconGSSmall.0c0aa8fa.svg") } - b { +"${GarageContext.GearScore}" } - } - div { Css("g2-param"); span { +"最高速度" }; b { +"—" } } - div { Css("g2-param"); span { +"伤害" }; b { +"—" } } - div { Css("g2-param"); span { +"装填" }; b { +"—" } } - div { Css("g2-param"); span { +"护甲" }; b { +"—" } } - div { - Css("g2-resists") - repeat(4) { i -> div { Css("g2-resist"); span { +"+" } } } - } - } - + Css("g3-menu") div { - Css("g2-scene") - canvas { - id = "garage-3d" - Css("g2-canvas") - } + Css("g3-menu-center") + div { Css("g3-key"); +"Q" } div { - Css("g2-scene-caption") - +((Selected ?: Turret ?: Hull)?.Name ?: "3D 坦克预览") - span { +"官方 3DS 车库场景 · 坦克模型接入中" } - } - button { - Css("g2-drawer-toggle") - onClick = { Drawer = !Drawer } - +"物品" - } - } - - aside { - Css("g2-right") - style = js("({ zIndex: 3 })") - MountedBlock("无人机", Drone) - MountedBlock("手榴弹", Grenade) - MountedBlock("炮塔", Turret) - MountedBlock("底盘", Hull) - } - } - - div { - Css("g2-supplies") - style = js("({ zIndex: 3 })") - Supply("Repair.13e5e240.svg", "维修") - Supply("armor.b0b6d887.svg", "护甲") - Supply("damage.af4f0289.svg", "伤害") - Supply("Speed.3b207b8e.svg", "速度") - Supply("mine.5a46d3d0.svg", "地雷") - Supply("GoldBox.61e0017c.svg", "金箱") - } - - if (Drawer) { - div { - Css("g2-drawer") - div { - Css("g2-drawer-head") - b { +"物品目录" } - input { - Css("garage-search") - placeholder = "搜索物品" - value = Query - onChange = { E -> Query = E.target.value } + Css("g3-tabs") + button { + Css("g3-tab" + if (IsOverview) " active" else "") + onClick = { Category = -1; SelectedId = null } + +"总览" + if (IsOverview) div { Css("g3-tab-line") } } - button { Css("g2-drawer-close"); onClick = { Drawer = false }; +"关闭" } - } - div { - Css("garage-tabs") - for (C in GarageCategories) { - val N = Counts[C.Id] ?: 0 + for (Id in MenuOrder) { + val N = Counts[Id] ?: 0 if (N == 0) continue button { - Css("garage-tab" + if (C.Id == ActiveCategory) " active" else "") - onClick = { Category = C.Id } - +C.Label - span { +"$N" } + Css("g3-tab" + if (Id == Category) " active" else "") + onClick = { Category = Id; SelectedId = null } + +CategoryLabel(Id) + if (Id == Category) div { Css("g3-tab-line") } + } + } + } + div { Css("g3-key"); +"E" } + } + div { Css("g3-menu-line") } + } + + // ---------------- 舞台:3D 画布 + 坦克预览 + 左右面板 ---------------- + div { + Css("g3-stage") + canvas { + id = "garage-3d" + Css("g3-canvas") + } + div { Css("g3-vignette") } + + // 官方预览图合成(真实 A3D 模型接入前先顶上) + val Preview = if (IsOverview) HullImage else PreviewFor(Category) + if (Preview != null) { + div { + Css("g3-preview") + if (IsOverview || Category == 1) { + img { src = Res(HullImage); Css("g3-preview-hull") } + } else { + img { src = Res(Preview); Css("g3-preview-single") } + } + } + } + + if (IsOverview) MountedOverview() + + aside { + Css("g3-left") + div { Css("g3-name"); +(Selected?.Name ?: CategoryLabel(Category)) } + div { + Css("g3-caption") + p { +(CategoryDesc[Category] ?: "该分类的详细说明将在数据补全后显示。") } + div { + Css("g3-caption-foot") + h3 { Css("g3-hotkey"); +"G" } + span { +"阅读更多" } + } + } + div { + Css("g3-device-row") + div { + Css("g3-device") + h3 { Css("g3-hotkey"); +"F" } + h2 { +"装备改造" } + h2 { +"/皮肤" } + } + div { + Css("g3-device") + img { src = Asset("static/images/ic_no_device.74e94e6a.svg"); Css("g3-device-icon") } + } + } + } + + aside { + Css("g3-right") + div { + Css("g3-part-name") + h2 { +(Selected?.Name ?: CategoryLabel(Category)) } + } + div { + Css("g3-upgrades") + repeat(7) { i -> + div { + Css("g3-upgrade" + if (i == 3) " current" else "") + div { Css("g3-upgrade-cell") } } } } div { - Css("garage-grid g2-drawer-grid") - for (Item in Shown) { - button { - Css("garage-card rarity-${Item.Rarity}" + if (Selected?.Id == Item.Id) " selected" else "") - onClick = { Selected = Item } - div { Css("garage-card-art"); +Item.Name.take(1) } - div { Css("garage-card-name"); +Item.Name.ifEmpty { "物品 ${Item.Id}" } } - div { - Css("garage-card-meta") - +CategoryLabel(Item.Category) - if (Item.Price > 0 && Item.Buyable) span { Css("garage-price"); +PriceText(Item) } - } - if (Item.Premium) span { Css("garage-premium"); +"VIP" } + Css("g3-stats") + for ((K, V) in Stats) { + div { + Css("g3-stat") + span { Css("g3-stat-key"); +K } + span { Css("g3-stat-val"); +V } + span { Css("g3-stat-delta"); +"+0" } } } - if (Shown.isEmpty()) div { Css("garage-empty"); +"这个分类暂时没有物品。" } + } + } + + div { + Css("g3-actions") + button { + Css("g3-action primary") + onClick = { MountedId = Selected?.Id } + div { Css("g3-action-icon"); i { Css("g3-coin") } } + h3 { Css("g3-hotkey dark"); +"Enter" } + div { + Css("g3-price") + i { Css("g3-coin") } + span { +Money(Price) } + } + } + button { + Css("g3-action" + if (Mounted?.Id == Selected?.Id) " equipped" else "") + onClick = { MountedId = Selected?.Id } + div { Css("g3-action-icon"); i { Css("g3-check") } } + div { Css("g3-action-label"); +(if (Mounted?.Id == Selected?.Id) "已装备" else "装备") } + } + } + + if (IsPaint) { + PaintDescription(Selected) { MountedId = Selected?.Id } + PaintGrid(InCategory, Selected) { SelectedId = it } + } + } + + // ---------------- 底部物品条(162×122 / 线上从 y=865) ---------------- + div { + Css("g3-carousel") + InlineStyle("""{"transform":"translateX(${CarouselX}px)"}""") + onPointerDown = { E -> + Drag = true + DragStartX = E.clientX + DragStartOffset = CarouselX + } + onPointerMove = { E -> + if (Drag) CarouselX = (DragStartOffset + (E.clientX - DragStartX)).coerceIn(-2200.0, 0.0) + } + onPointerUp = { Drag = false } + onPointerLeave = { Drag = false } + for (Item in Shown) { + button { + Css("g3-item rarity-${Item.Rarity}" + if (Item.Id == Selected?.Id) " selected" else "") + onClick = { SelectedId = Item.Id } + div { Css("g3-item-name"); +Item.Name } + div { + Css("g3-item-art") + img { src = Asset("static/images/" + ItemIcon(Item)); Css("g3-item-icon") } + } + if (Item.Count > 0) span { Css("g3-item-owned"); +"已拥有" } } } } - } -} -private fun react.ChildrenBuilder.MountedBlock(Title: String, Item: CatalogItem?) { - div { - Css("g2-mounted") - div { Css("g2-mounted-title"); +Title } div { - Css("g2-mounted-art rarity-${Item?.Rarity ?: 0}") - +(Item?.Name?.take(1) ?: "—") + Css("g3-footer-hint") + span { +"拖动鼠标旋转视角 · 滚轮缩放" } } - div { Css("g2-mounted-name"); +(Item?.Name ?: "未装备") } } } -private fun react.ChildrenBuilder.Supply(Icon: String, Title: String) { - div { - Css("g2-supply") - img { src = Asset("static/images/$Icon") } - span { +Title } - b { +"0" } - } -} - -private fun PriceText(Item: CatalogItem): String = when (Item.Currency) { - 1 -> "${Item.Price} 水晶" - 2 -> "${Item.Price} 红宝石" - else -> "${Item.Price}" -} - object GarageMount { private var Root: dynamic = null @@ -242,8 +422,7 @@ object GarageMount { if (Root != null && !Force) return if (Root == null) Root = createRoot(El) Root.render(GarageScreen.create()) - val Start: () -> Unit = { Garage3D.Mount("garage-3d") } - js("(function (Fn) { setTimeout(Fn, 0); })")(Start) + Garage3D.Mount("garage-3d") } fun Unmount() { diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/HeadScreen.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/HeadScreen.kt new file mode 100644 index 0000000..7ae7bfc --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/HeadScreen.kt @@ -0,0 +1,479 @@ +// HeadScreen.kt —— HEAD 顶栏(Emotion 版) +// +// 按原版 Kotlin/JS style 函数逐块映射: +// Oti → rankIconContainer +// Dti → containerRightBorder +// Kti → blockLeftPanel +// Jti → containerProgressMainScreen +// 值来自 module_82347 的 style DSL;单位换算:Knr(px) = px / 16 em。 +package App.Browser.Ui + +import csstype.PropertiesBuilder +import emotion.react.css +import react.FC +import react.Props +import react.create +import react.dom.html.HTMLAttributes +import react.dom.html.ReactHTML.b +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.i +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.span + +private fun PropertiesBuilder.Css(Name: String, Value: String) { + asDynamic()[Name] = Value +} + +private fun Asset(Path: String): String = "/play/" + Path + +/** 等级图标:10–31 级有官方资源;低等级回退到 10 级图标。 */ +private fun RankIcon(Rank: Int): String { + val R = if (Rank in 10..31) Rank else 10 + val Hash = mapOf( + 10 to "17447044", 11 to "f4ddcab2", 12 to "c8158aa7", 13 to "2baa3518", + 14 to "1fcc1281", 15 to "eaafa648", 16 to "9d0599ee", 17 to "dd67d583", + 18 to "e86c45fa", 19 to "bc7f519b", 20 to "76063620", 21 to "cef08a4f", + 22 to "0bc84156", 23 to "3c31b410", 24 to "e5342906", 25 to "ef416fe8", + 26 to "128b1504", 27 to "c9a72f49", 28 to "b96f89b9", 29 to "ee5f90ba", + 30 to "5c2d92a0", 31 to "91cbb062", + )[R] ?: "eaafa648" + return Asset("static/images/$R.$Hash.webp") +} + +/** 大厅把当前用户/导航状态放进这个上下文,HEAD 组件直接读。 */ +object HeadContext { + var Page: String = "home" + var Uid: String = "" + var Rank: Int = 1 + var Score: Int = 0 + var LowBound: Int = 0 + var TopBound: Int = 0 + var HasPremium: Boolean = false + var GroupActive: Boolean = true + var Rubies: Int = 0 + var Crystals: Int = 0 + var OnBack: () -> Unit = {} + var OnLogout: () -> Unit = {} + var OnFullscreen: () -> Unit = {} +} + +// ---------------------------------------------------------------- 基础布局 + +private val RootStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "space-between") + Css("width", "100%") + Css("height", "100%") + Css("position", "relative") + Css("background", "linear-gradient(180deg, rgba(0, 25, 38, 0.98), rgba(0, 16, 24, 0.94))") +} + +private val LeftStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("align-items", "center") + Css("height", "100%") +} + +private val RightStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("align-items", "center") + Css("height", "100%") + Css("z-index", "5") +} + +private val Divider = "1px solid rgba(255, 255, 255, 0.25)" + +// ---------------------------------------------------------------- VIP / 退出 + +private fun VipStyle(On: Boolean): PropertiesBuilder.() -> Unit = { + Css("position", "relative") + Css("width", "6em") + Css("height", "6em") + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "center") + Css("cursor", "pointer") + Css( + "background", + "radial-gradient(100% 100% at 0% 0%, rgba(255, 204, 0, 0.75) 0%, " + + "rgba(255, 38, 74, 0.5) 50%, rgba(255, 38, 74, 0) 100%)", + ) + if (!On) { + Css("opacity", "0.55") + Css("filter", "grayscale(0.3)") + } +} + +private val VipIconStyle: PropertiesBuilder.() -> Unit = { + Css("width", "2em") + Css("height", "2em") + Css("background", "url(\"${Asset("static/images/iconButtonPremium.bc17a592.svg")}\") center / contain no-repeat") +} + +private val ExitStyle: PropertiesBuilder.() -> Unit = { + Css("position", "relative") + Css("width", "6em") + Css("height", "6em") + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "center") + Css("border", "0") + Css("box-shadow", "1px 0 0 0 rgba(255, 255, 255, 0.25)") + Css("background", "transparent") + Css("cursor", "pointer") +} + +private val ExitIconStyle: PropertiesBuilder.() -> Unit = { + Css("width", "1.5em") + Css("height", "1.5em") + Css("background", "url(\"${Asset("static/images/back.72d79619.svg")}\") center / contain no-repeat") +} + +// ---------------------------------------------------------------- 组队 + +private fun GroupStyle(Active: Boolean): PropertiesBuilder.() -> Unit = { + Css("position", "relative") + Css("width", "6em") + Css("height", "6em") + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "center") + Css("border-left", Divider) + Css("cursor", "pointer") + if (!Active) Css("opacity", "0.55") +} + +private fun GroupIconStyle(Offset: String): PropertiesBuilder.() -> Unit = { + Css("position", "absolute") + Css("top", "50%") + Css("left", "50%") + Css("width", "1.5em") + Css("height", "1.5em") + Css("margin-top", "-0.75em") + Css("margin-left", "-0.75em") + Css("background", "rgba(191, 212, 255, 0.5)") + Css("mask", "url(\"${Asset("static/images/groupUser.67c9ec61.svg")}\") center / contain no-repeat") + Css("-webkit-mask", "url(\"${Asset("static/images/groupUser.67c9ec61.svg")}\") center / contain no-repeat") + Css("transform", "translateX($Offset)") +} + +// ---------------------------------------------------------------- 等级 / 用户 + +private val RankStyle: PropertiesBuilder.() -> Unit = { + Css("width", "6em") + Css("height", "6em") + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "center") + Css("border-left", Divider) + Css("border-right", Divider) + Css("cursor", "pointer") +} + +private val RankImgStyle: PropertiesBuilder.() -> Unit = { + Css("width", "4.375em") + Css("height", "4.375em") +} + +private val UserStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("flex-direction", "column") + Css("justify-content", "center") + Css("min-width", "10em") + Css("margin-left", "1em") + Css("height", "3.75em") +} + +private val UserNameStyle: PropertiesBuilder.() -> Unit = { + Css("font", "500 1.1em/1.2 BaseFontMedium, sans-serif") + Css("text-shadow", "0 0 0.25em rgba(0, 0, 0, 0.5)") + Css("cursor", "pointer") + Css("white-space", "nowrap") + Css("overflow", "hidden") + Css("text-overflow", "ellipsis") +} + +private val XpBarStyle: PropertiesBuilder.() -> Unit = { + Css("width", "12.5em") + Css("height", "0.25em") + Css("margin-top", "0.375em") + Css("background", "rgba(118, 255, 51, 0.25)") + Css("border-radius", "0.375em") + Css("overflow", "hidden") +} + +private fun XpFillStyle(Ratio: Double): PropertiesBuilder.() -> Unit = { + Css("display", "block") + Css("height", "100%") + Css("width", "${(Ratio * 100).toInt()}%") + Css("background", "rgba(118, 255, 51, 0.8)") + Css("border-radius", "inherit") +} + +private val XpTextStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("align-items", "center") + Css("margin-top", "0.35em") + Css("font", "500 0.9375em/1 BaseFontMedium, sans-serif") + Css("color", "#76ff33") +} + +private val XpIconStyle: PropertiesBuilder.() -> Unit = { + Css("width", "0.8em") + Css("height", "0.8em") + Css("margin-right", "0.235em") + Css("background", "url(\"${Asset("static/images/experience.402435da.svg")}\") center / contain no-repeat") +} + +private val XpMaxStyle: PropertiesBuilder.() -> Unit = { + Css("margin-left", "0.1875em") + Css("color", "rgba(255, 255, 255, 0.35)") +} + +// ---------------------------------------------------------------- 中间 Logo + +private val LogoStyle: PropertiesBuilder.() -> Unit = { + Css("position", "absolute") + Css("left", "50%") + Css("top", "50%") + Css("transform", "translate(-50%, -50%)") + Css("display", "flex") + Css("align-items", "center") + Css("height", "3.75em") + Css("opacity", "0.95") + Css("pointer-events", "none") +} + +private val LogoImgStyle: PropertiesBuilder.() -> Unit = { + Css("height", "3.75em") +} + +// ---------------------------------------------------------------- 右侧货币 / F11 / 退出 + +private val CurrenciesStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("flex-direction", "column") + Css("justify-content", "center") + Css("height", "100%") + Css("width", "9.2em") + Css("box-sizing", "border-box") + Css("border-right", Divider) +} + +private fun CurrencyStyle(BorderBottom: Boolean): PropertiesBuilder.() -> Unit = { + Css("position", "relative") + Css("display", "flex") + Css("align-items", "center") + Css("gap", "0.4em") + Css("width", "100%") + Css("height", "3em") + Css("padding", "0 0.75em 0 0.6em") + Css("box-sizing", "border-box") + Css("cursor", "pointer") + if (BorderBottom) Css("border-bottom", Divider) +} + +private val CoinStyle: PropertiesBuilder.() -> Unit = { + Css("width", "1em") + Css("height", "1em") +} + +private val CurrencyValueStyle: PropertiesBuilder.() -> Unit = { + Css("font", "500 1.05em/1 BaseFontMedium, sans-serif") +} + +private val PlusStyle: PropertiesBuilder.() -> Unit = { + Css("width", "1em") + Css("height", "1em") + Css("margin-left", "auto") + Css("opacity", "0.85") +} + +private val FullscreenStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "center") + Css("gap", "0.3em") + Css("width", "6em") + Css("height", "6em") + Css("border-left", Divider) + Css("cursor", "pointer") +} + +private val FullscreenIconStyle: PropertiesBuilder.() -> Unit = { + Css("width", "1.5em") + Css("height", "1.5em") + Css("background", "#bfd5ff") + Css("mask", "url(\"${Asset("static/images/fullscreen.0b4547f5.svg")}\") center / contain no-repeat") + Css("-webkit-mask", "url(\"${Asset("static/images/fullscreen.0b4547f5.svg")}\") center / contain no-repeat") +} + +private val FullscreenTextStyle: PropertiesBuilder.() -> Unit = { + Css("font", "600 0.75em/1 BaseFontMedium, sans-serif") + Css("color", "rgba(255, 255, 255, 0.65)") +} + +private val LogoutStyle: PropertiesBuilder.() -> Unit = { + Css("display", "flex") + Css("align-items", "center") + Css("justify-content", "center") + Css("width", "6em") + Css("height", "6em") + Css("border", "0") + Css("border-left", Divider) + Css("background", "transparent") + Css("cursor", "pointer") +} + +private val LogoutIconStyle: PropertiesBuilder.() -> Unit = { + Css("width", "1.5em") + Css("height", "1.5em") + Css("background", "#bfd5ff") + Css("mask", "url(\"${Asset("static/images/iconBack.62869951.svg")}\") center / contain no-repeat") + Css("-webkit-mask", "url(\"${Asset("static/images/iconBack.62869951.svg")}\") center / contain no-repeat") +} + +// ---------------------------------------------------------------- 组件 + +private fun react.ChildrenBuilder.CurrencyRow( + Icon: String, + Plus: String, + Value: Int, + UiName: String, + BorderBottom: Boolean, +) { + div { + css(CurrencyStyle(BorderBottom)) + Ui(UiName) + img { src = Asset(Icon); css(CoinStyle) } + span { css(CurrencyValueStyle); +"$Value" } + img { src = Asset(Plus); css(PlusStyle) } + } +} + +val HeadScreen = FC { + val Ratio = if (HeadContext.TopBound > HeadContext.LowBound) { + ((HeadContext.Score - HeadContext.LowBound).toDouble() / + (HeadContext.TopBound - HeadContext.LowBound)).coerceIn(0.0, 1.0) + } else 0.0 + + div { + css(RootStyle) + Ui("head.root") + + // 左:VIP/退出、组队、等级、昵称/经验 + div { + css(LeftStyle) + Ui("head.left") + + if (HeadContext.Page == "home") { + div { + css(VipStyle(HeadContext.HasPremium)) + Ui("head.vip") + i { css(VipIconStyle) } + } + } else { + button { + css(ExitStyle) + Ui("head.exit") + onClick = { HeadContext.OnBack() } + i { css(ExitIconStyle) } + } + } + + div { + css(GroupStyle(HeadContext.GroupActive)) + Ui("head.group") + i { css(GroupIconStyle("-0.8em")); Ui("head.group.g1") } + i { css(GroupIconStyle("0.8em")); Ui("head.group.g2") } + } + + div { + css(RankStyle) + Ui("head.rank") + img { src = RankIcon(HeadContext.Rank); css(RankImgStyle) } + } + + div { + css(UserStyle) + Ui("head.user") + span { css(UserNameStyle); Ui("head.user.name"); +HeadContext.Uid.ifEmpty { "未登录" } } + div { + css(XpBarStyle) + Ui("head.user.xp") + i { css(XpFillStyle(Ratio)); Ui("head.user.xp.fill") } + } + div { + css(XpTextStyle) + Ui("head.user.xp.text") + i { css(XpIconStyle) } + +"${HeadContext.Score}" + span { + css(XpMaxStyle) + +" / ${if (HeadContext.TopBound > 0) HeadContext.TopBound else "—"}" + } + } + } + } + + // 中:Logo + div { + css(LogoStyle) + Ui("head.logo") + img { src = Asset("static/images/logoTankiOnlineChina.0b6ba850.svg"); css(LogoImgStyle) } + } + + // 右:货币、F11、退出 + div { + css(RightStyle) + Ui("head.right") + div { + css(CurrenciesStyle) + Ui("head.currencies") + CurrencyRow( + "static/images/ruby.ce62efbc.svg", + "static/images/addRuby.ff47c8be.svg", + HeadContext.Rubies, + "head.currency.ruby", + true, + ) + CurrencyRow( + "static/images/crystalSmall.242e6c15.svg", + "static/images/addCrystal.af9a397c.svg", + HeadContext.Crystals, + "head.currency.crystal", + false, + ) + } + div { + css(FullscreenStyle) + Ui("head.fullscreen") + onClick = { HeadContext.OnFullscreen() } + i { css(FullscreenIconStyle) } + b { css(FullscreenTextStyle); +"F11" } + } + button { + css(LogoutStyle) + Ui("head.logout") + onClick = { HeadContext.OnLogout() } + i { css(LogoutIconStyle) } + } + } + } +} + +object HeadMount { + private var Root: dynamic = null + + fun Mount() { + val El = web.dom.document.getElementById("head-root") as? web.html.HTMLDivElement ?: return + if (Root == null) Root = react.dom.client.createRoot(El) + Render() + } + + fun Render() { + Root?.render(HeadScreen.create()) + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/HomeScreen.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/HomeScreen.kt new file mode 100644 index 0000000..bbbf3ec --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/HomeScreen.kt @@ -0,0 +1,388 @@ +// HomeScreen.kt —— 大厅首页(React + Emotion) +// +// 结构对照线上 MainScreenComponentStyle / PrimaryMenuItemComponentStyle / FooterComponentStyle: +// containerForMenuGradient(左 50%) +// playButtonContainer(21.875em × 10.75em) +// blockMainMenu(27em 宽,5 项) +// Footer(5 个 5em 图标) +// gradientBlockRight(27.75em,旋转 180°) +// menuBattlePass(22.5em × 10em,right 2.5em / top 8.5em) +// Announcement(22.5em 宽,right/bottom 2.5em) +package App.Browser.Ui + +import App.Browser.Translations +import emotion.react.css +import react.ChildrenBuilder +import react.FC +import react.Props +import react.create +import react.dom.client.createRoot +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.span +import web.dom.document +import web.html.HTMLDivElement + +object HomeContext { + var Stars: Int = 0 + var BattlePasses: Int = 0 + var Tips: List = emptyList() + var OnNavigate: (String) -> Unit = {} +} + +private fun Asset(Path: String): String = "/play/" + Path + +private val HomeRoot: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("inset", "0") + Style("z-index", "1") +} + +private val LeftGradient: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("left", "0") + Style("top", "0") + Style("bottom", "0") + Style("display", "flex") + Style("flex-direction", "column") + Style("align-items", "flex-start") + Style("width", "50%") + Style("padding-top", "10em") + Style("background", "linear-gradient(90deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%)") +} + +private val PlayButton: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("width", "21.875em") + Style("height", "10.75em") + Style("margin-left", "4.375em") + Style("border", "0") + Style("border-radius", "0.5rem") + Style("cursor", "pointer") + Style("background", "url(\"${Asset("static/images/videoplay.79570900.gif")}\") center / cover no-repeat") + Style("box-shadow", "0 0 0 0.063em rgba(118, 255, 51, 0.25)") +} + +private val PlayText: csstype.PropertiesBuilder.() -> Unit = { + Style("font", "500 2.75em/1 ${Ui.FontMedium}") + Style("text-transform", "uppercase") + Style("color", Ui.Accent) + Style("text-shadow", "0 0.15em 0.3em rgba(0, 0, 0, 0.5)") +} + +private val Menu: csstype.PropertiesBuilder.() -> Unit = { + Style("list-style", "none") + Style("margin", "1.1em 0 0 4.375em") + Style("padding", "0") + Style("display", "flex") + Style("flex-direction", "column") + Style("width", "27em") +} + +private val MenuItem: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("height", "4.5em") + Style("cursor", "pointer") +} + +private fun MenuIcon(Url: String): csstype.PropertiesBuilder.() -> Unit = { + Style("width", "3.5em") + Style("height", "3.5em") + Style("margin-left", "0.625em") + Style("background", "#bfd5ff") + Style("mask", "url(\"$Url\") center / contain no-repeat") + Style("-webkit-mask", "url(\"$Url\") center / contain no-repeat") +} + +private val MenuLabel: csstype.PropertiesBuilder.() -> Unit = { + Style("margin-left", "0.5625em") + Style("font", "500 2em/1 ${Ui.FontMedium}") + Style("text-transform", "uppercase") + Style("text-shadow", "0 0.25em 0.25em rgba(0, 0, 0, 0.25)") +} + +private val Footer: csstype.PropertiesBuilder.() -> Unit = { + Style("flex", "1") + Style("display", "flex") + Style("align-items", "flex-end") + Style("width", "100%") +} + +private val FooterList: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("list-style", "none") + Style("margin", "0 0 0.8em 5em") + Style("padding", "0") + Style("height", "5.0625em") +} + +private val FooterItem: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("width", "5em") + Style("height", "5em") + Style("cursor", "pointer") +} + +private fun FooterIcon(Url: String): csstype.PropertiesBuilder.() -> Unit = { + Style("width", "2em") + Style("height", "2em") + Style("background", "#bfd5ff") + Style("mask", "url(\"$Url\") center / contain no-repeat") + Style("-webkit-mask", "url(\"$Url\") center / contain no-repeat") +} + +private val RightGradient: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("right", "0") + Style("top", "0") + Style("bottom", "0") + Style("width", "27.75em") + Style("background", "linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0) 100%)") + Style("opacity", "0.75") + Style("transform", "rotate(180deg)") + Style("pointer-events", "none") +} + +private val BattlePass: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("right", "2.5em") + Style("top", "8.5em") + Style("width", "22.5em") + Style("height", "10em") + Style("padding", "0.5em") + Style("box-sizing", "border-box") + Style("background", "linear-gradient(0deg, rgba(0, 25, 38, 0.25) 0%, #001926 100%)") + Style("border-radius", "0.5rem") + Style("cursor", "pointer") + Style("z-index", "1") + Style("filter", "drop-shadow(0 0.25rem 0.25rem rgba(0, 0, 0, 0.25))") +} + +private val BpHead: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("height", "2.75em") + Style("font-size", "0.9375em") +} + +private val BpRowBase: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("height", "3.125em") + Style("margin", "0 0.3em 0.3em") + Style("border", "0.063em solid rgba(255, 255, 255, 0.1)") + Style("border-radius", "0.3em") + Style("background", "rgba(0, 0, 0, 0.1)") +} + +private val BpRow: csstype.PropertiesBuilder.() -> Unit = { BpRowBase() } + +private val BpRowLocked: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("height", "3.125em") + Style("margin", "0 0.3em 0.3em") + Style("border", "0.063em solid rgba(255, 255, 255, 0.1)") + Style("border-radius", "0.3em") + Style("background", "rgba(0, 0, 0, 0.1)") + Style("opacity", "0.55") +} + +private val BpIcon: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "4em") + Style("height", "2.4em") + Style("margin", "0 0.5em") + Style("background", "url(\"${Asset("static/images/StarMission.d11624d7.svg")}\") center / contain no-repeat") + Style("opacity", "0.9") +} + +private val Announce: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("right", "2.5em") + Style("bottom", "2.5em") + Style("display", "flex") + Style("flex-direction", "column") + Style("width", "22.5em") + Style("min-height", "12.5em") + Style("background", "rgba(0, 25, 38, 0.35)") + Style("border", "0.063em solid rgba(255, 255, 255, 0.25)") + Style("border-radius", "0.5rem") + Style("overflow", "hidden") + Style("backdrop-filter", "blur(0.78em)") +} + +private val AnnounceArt: csstype.PropertiesBuilder.() -> Unit = { + Style("height", "9.5em") + Style( + "background", + "radial-gradient(60% 70% at 50% 40%, rgba(255, 179, 0, 0.18), transparent 70%), " + + "url(\"${Asset("static/images/background.fc2779c1.webp")}\") center / cover no-repeat", + ) +} + +private val AnnounceTitle: csstype.PropertiesBuilder.() -> Unit = { + Style("padding", "0.7em 0.9em") + Style("font", "500 1.125em/1.4 ${Ui.FontMedium}") + Style("background", "linear-gradient(rgba(0, 25, 38, 0), #001926 60%)") +} + +private val TipsRoot: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("left", "50%") + Style("bottom", "8.5em") + Style("transform", "translateX(-50%)") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.35em") + Style("z-index", "5") + Style("pointer-events", "none") +} + +private val TipStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("padding", "0.45em 0.9em") + Style("border-radius", "0.35em") + Style("background", "rgba(0, 18, 28, 0.88)") + Style("border", "1px solid ${Ui.DividerSoft}") + Style("color", "#d8e6f2") + Style("font", "400 0.9em/1.3 ${Ui.FontRegular}") + Style("white-space", "nowrap") +} + +private fun ChildrenBuilder.MenuEntry(Id: String, Key: String, Fallback: String, Icon: String) { + div { + css(MenuItem) + Ui("home.menu.$Id") + onClick = { HomeContext.OnNavigate(Id) } + div { css(MenuIcon(Asset("static/images/$Icon"))) } + span { css(MenuLabel); +Translations.T("lobby.menu.${Key.uppercase()}", Fallback) } + } +} + +private fun ChildrenBuilder.FooterEntry(Id: String, Icon: String) { + div { + css(FooterItem) + Ui("home.footer.$Id") + onClick = { HomeContext.OnNavigate(Id) } + div { css(FooterIcon(Asset("static/images/$Icon"))) } + } +} + +val HomeScreen = FC { + div { + css(HomeRoot) + + div { + css(LeftGradient) + button { + css(PlayButton) + Ui("home.play") + onClick = { HomeContext.OnNavigate("battles") } + span { css(PlayText); +Translations.T("lobby.menu.PLAY", "开始战斗") } + } + div { + css(Menu) + MenuEntry("containers", "Containers", "幸运礼盒", "iconLobbyMenuContainers.f960290f.svg") + MenuEntry("quests", "Quests", "任务", "iconLobbyMenuQuests.54801b8b.svg") + MenuEntry("garage", "Garage", "车库", "iconLobbyMenuGarage.0b623165.svg") + MenuEntry("shop", "Shop", "商店", "iconLobbyMenuStore.e755fe14.svg") + MenuEntry("settings", "Option", "设置", "iconLobbyMenuSettings.cc00d08a.svg") + } + div { + css(Footer) + div { + css(FooterList) + FooterEntry("news", "iconLobbyMenuNews.6efe9ae3.svg") + FooterEntry("chat", "iconLobbyMenuChat.e48a39a4.svg") + FooterEntry("friends", "iconLobbyMenuFriends.28305317.svg") + FooterEntry("clans", "iconLobbyMenuClan.f01ae646.svg") + } + } + } + + div { css(RightGradient) } + + div { + css(BattlePass) + Ui("home.battlepass") + onClick = { HomeContext.OnNavigate("quests") } + div { + css(BpHead) + span { + css { Style("display", "flex"); Style("align-items", "center"); Style("gap", "0.3em") } + img { + src = Asset("static/images/star.01f7e4f8.svg") + css { Style("width", "1.4em"); Style("height", "1.4em") } + } + +"${HomeContext.Stars}" + } + span { css(MutedText); +"星级挑战" } + } + div { + css(BpRow) + div { css(BpIcon) } + div { + div { css { Style("font", "500 1em/1.2 ${Ui.FontBold}") }; +"等级 ${HomeContext.BattlePasses}" } + span { css(MutedText); +"星级挑战" } + } + } + div { + css(BpRowLocked) + div { css(BpIcon) } + div { + div { css { Style("font", "500 1em/1.2 ${Ui.FontBold}") }; +"精英通行证" } + span { css(MutedText); +"未解锁" } + } + } + } + + div { + css(Announce) + Ui("home.announce") + onClick = { HomeContext.OnNavigate("news") } + div { css(AnnounceArt) } + div { css(AnnounceTitle); +Translations.T("lobby.menu.NEWS", "新闻") } + } + + if (HomeContext.Tips.isNotEmpty()) { + div { + css(TipsRoot) + for (Tip in HomeContext.Tips.take(3)) { + div { + css(TipStyle) + +Tip.Str("text", Tip.Str("message", "")) + } + } + } + } + } +} + +object HomeMount { + private var Root: dynamic = null + + fun Mount() { + val El = document.getElementById("home-root") as? HTMLDivElement ?: return + if (Root == null) Root = createRoot(El) + Render() + } + + fun Render() { + Root?.render(HomeScreen.create()) + } + + fun Unmount() { + try { + Root?.unmount() + } catch (_: Throwable) { + } + Root = null + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/LobbyScreens.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/LobbyScreens.kt new file mode 100644 index 0000000..462cac3 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/LobbyScreens.kt @@ -0,0 +1,3599 @@ +// LobbyScreens.kt —— 大厅子页面(商店 / 礼盒 / 战斗 / 任务 / 设置 / 聊天 / 新闻 / 军团 / 好友) +// +// 目标:不再用 HTML 字符串拼页面,全部改成可读的 Kotlin/React + Emotion 组件。 +// 数据优先读现有 RootState;缺失字段用动态字段读取 + 空状态兜底。 +package App.Browser.Ui + +import App.Browser.LobbyClient +import App.State.MainQuestInfo +import App.State.NewsItemData +import App.State.RootState +import emotion.react.css +import react.ChildrenBuilder +import react.FC +import react.Props +import react.create +import react.dom.client.createRoot +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.b +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h2 +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.i +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.input +import react.dom.html.ReactHTML.option +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.select +import react.dom.html.ReactHTML.span +import react.useState +import web.dom.document +import web.html.HTMLDivElement + +/** 大厅子页面上下文:由 LobbyView 写入,React 组件直接读。 */ +object ScreenContext { + var Screen: String = "shop" + var Client: LobbyClient? = null + var OnNavigate: (String) -> Unit = {} + var OnBack: () -> Unit = {} +} + +private fun ClientOrNull(): LobbyClient? = ScreenContext.Client + +private fun StateOrNull(): RootState? = ClientOrNull()?.Router?.Store?.State + +private fun PriceText(Item: CatalogItem): String = when (Item.Currency) { + 0 -> "${Item.Price} 水晶" + 1 -> "${Item.Price} 坦克币" + 2 -> "${Item.Price} 红宝石" + else -> "${Item.Price}" +} + +private fun Asset(Path: String): String = "/play/" + Path + +private val BreadcrumbsHeader: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "relative") + Style("display", "flex") + Style("align-items", "center") + Style("height", "6em") + Style("flex", "0 0 6em") + Style("border-bottom", "1px solid ${Ui.Divider}") + Style("background", "linear-gradient(180deg, rgba(0, 22, 34, 0.98), rgba(0, 14, 22, 0.94))") +} + +private val BreadcrumbsBack: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "6em") + Style("height", "6em") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("border", "0") + Style("box-shadow", "1px 0 0 0 ${Ui.Divider}") + Style("background", "transparent") + Style("color", "#bfd5ff") + Style("font-size", "1.5em") + Style("cursor", "pointer") +} + +private val BreadcrumbsTitle: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("left", "50%") + Style("top", "50%") + Style("transform", "translate(-50%, -50%)") + Style("margin", "0") + Style("font", "500 1.5em/1.2 ${Ui.FontMedium}") + Style("white-space", "nowrap") + Style("pointer-events", "none") +} + +private val BreadcrumbsRight: csstype.PropertiesBuilder.() -> Unit = { + Style("margin-left", "auto") + Style("display", "flex") + Style("align-items", "center") + Style("height", "100%") +} + +private val BreadcrumbsCurrency: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.3em") + Style("height", "3em") + Style("padding", "0 0.8em") + Style("font", "500 1em/1 ${Ui.FontMedium}") +} + +private val BreadcrumbsExit: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "6em") + Style("height", "6em") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("border", "0") + Style("border-left", "1px solid ${Ui.Divider}") + Style("background", "transparent") + Style("color", "#bfd5ff") + Style("font-size", "1.4em") + Style("cursor", "pointer") +} + +private val BreadcrumbsFullscreen: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "6em") + Style("height", "6em") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("gap", "0.3em") + Style("border-left", "1px solid ${Ui.Divider}") + Style("cursor", "pointer") +} + +private val BreadcrumbsFullscreenIcon: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "1.5em") + Style("height", "1.5em") + Style("background", "#bfd5ff") + Style("mask", "url(\"${Asset("static/images/fullscreen.0b4547f5.svg")}\") center / contain no-repeat") + Style("-webkit-mask", "url(\"${Asset("static/images/fullscreen.0b4547f5.svg")}\") center / contain no-repeat") +} + +private val BreadcrumbsFullscreenText: csstype.PropertiesBuilder.() -> Unit = { + Style("font", "600 0.75em/1 ${Ui.FontMedium}") + Style("color", "rgba(255, 255, 255, 0.65)") +} + +// ---------------------------------------------------------------- 通用外壳 + +private fun ChildrenBuilder.ScreenShell( + Title: String, + Subtitle: String = "", + Body: ChildrenBuilder.() -> Unit, +) { + div { + css(ScreenRoot) + Ui("screen." + Title) + div { + css(BreadcrumbsHeader) + button { + css(BreadcrumbsBack) + Ui("screen.back") + onClick = { ScreenContext.OnBack() } + +"←" + } + h2 { + css(BreadcrumbsTitle) + +Title + if (Subtitle.isNotEmpty()) { + span { css(ScreenSubtitle); +Subtitle } + } + } + div { + css(BreadcrumbsRight) + span { + css(BreadcrumbsCurrency) + img { + src = Asset("static/images/ruby.ce62efbc.svg") + css { Style("width", "1em"); Style("height", "1em") } + } + +"${HeadContext.Rubies}" + img { + src = Asset("static/images/addRuby.ff47c8be.svg") + css { Style("width", "1em"); Style("height", "1em"); Style("margin-left", "0.3em"); Style("opacity", "0.85") } + } + } + span { + css(BreadcrumbsCurrency) + img { + src = Asset("static/images/crystalSmall.242e6c15.svg") + css { Style("width", "1em"); Style("height", "1em") } + } + +"${HeadContext.Crystals}" + img { + src = Asset("static/images/addCrystal.af9a397c.svg") + css { Style("width", "1em"); Style("height", "1em"); Style("margin-left", "0.3em"); Style("opacity", "0.85") } + } + } + div { + css(BreadcrumbsFullscreen) + onClick = { HeadContext.OnFullscreen() } + i { css(BreadcrumbsFullscreenIcon) } + b { css(BreadcrumbsFullscreenText); +"F11" } + } + button { + css(BreadcrumbsExit) + Ui("screen.exit") + onClick = { HeadContext.OnLogout() } + +"⏻" + } + } + } + div { + css(ScreenBody) + Body() + } + } +} + +private fun ChildrenBuilder.EmptyState(Text: String) { + div { css(Empty); +Text } +} + +private fun ChildrenBuilder.SectionTitle(Text: String) { + h3 { + css { + Style("margin", "1.2em 0 0.6em") + Style("font", "500 1.1em/1.2 ${Ui.FontMedium}") + Style("color", Ui.Text) + } + +Text + } +} + +private fun ChildrenBuilder.StatRow(Key: String, Value: String) { + div { + css(StatLine) + span { css(MutedText); +Key } + span { +Value } + } +} + +// ---------------------------------------------------------------- 非战斗页通用组件 + +private val Columns: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "flex-start") + Style("gap", "1.1em") + Style("min-height", "0") +} + +private val SideNav: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "15em") + Style("flex", "0 0 15em") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.15em") + Style("padding", "0.4em") + Style("box-sizing", "border-box") + Style("background", "rgba(0, 15, 24, 0.72)") + Style("border", "1px solid ${Ui.DividerSoft}") + Style("border-radius", "0.5em") +} + +private fun NavItemStyle(Active: Boolean): csstype.PropertiesBuilder.() -> Unit = { + Style("position", "relative") + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("gap", "0.5em") + Style("width", "100%") + Style("padding", "0.7em 0.8em") + Style("border", "0") + Style("border-radius", "0.35em") + Style("background", if (Active) "rgba(118, 255, 51, 0.12)" else "transparent") + Style("color", if (Active) Ui.Accent else "rgba(232, 242, 255, 0.82)") + Style("font", "500 0.95em/1.2 ${Ui.FontMedium}") + Style("text-align", "left") + Style("cursor", "pointer") + if (Active) Style("box-shadow", "inset 0.15em 0 0 ${Ui.Accent}") +} + +private val NavCount: csstype.PropertiesBuilder.() -> Unit = { + Style("min-width", "1.6em") + Style("padding", "0.1em 0.35em") + Style("border-radius", "999px") + Style("background", "rgba(191, 213, 255, 0.14)") + Style("color", "rgba(232, 242, 255, 0.72)") + Style("font", "500 0.72em/1.4 ${Ui.FontMedium}") + Style("text-align", "center") +} + +private fun ChildrenBuilder.NavItem(Label: String, Count: Int? = null, Active: Boolean, OnClick: () -> Unit) { + button { + css(NavItemStyle(Active)) + onClick = { OnClick() } + span { +Label } + if (Count != null) span { css(NavCount); +"$Count" } + } +} + +private val ContentPane: csstype.PropertiesBuilder.() -> Unit = { + Style("flex", "1") + Style("min-width", "0") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.8em") +} + +private val PageToolbar: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.6em") + Style("min-height", "2.4em") +} + +private val PaneTitle: csstype.PropertiesBuilder.() -> Unit = { + Style("font", "500 1.35em/1.2 ${Ui.FontMedium}") +} + +private val PaneDesc: csstype.PropertiesBuilder.() -> Unit = { + Style("margin-top", "0.2em") + Style("color", Ui.Muted) + Style("font", "400 0.88em/1.45 ${Ui.FontRegular}") +} + +private val SearchField: csstype.PropertiesBuilder.() -> Unit = { + Style("flex", "1") + Style("min-width", "0") + Style("padding", "0.6em 0.8em") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.4em") + Style("background", "rgba(0, 12, 20, 0.7)") + Style("color", Ui.Text) + Style("font", "400 0.92em/1 ${Ui.FontRegular}") + Style("outline", "none") +} + +private fun ChildrenBuilder.SearchBox(Value: String, Placeholder: String, OnChange: (String) -> Unit) { + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + Style("width", "100%") + } + input { + css(SearchField) + placeholder = Placeholder + value = Value + onChange = { E -> OnChange(E.target.value) } + } + } +} + +private val ToggleBase: csstype.PropertiesBuilder.() -> Unit = { + Style("position", "relative") + Style("width", "2.7em") + Style("height", "1.45em") + Style("padding", "0") + Style("border", "0") + Style("border-radius", "999px") + Style("cursor", "pointer") + Style("transition", "background 120ms ease") +} + +private fun ToggleStyle(On: Boolean): csstype.PropertiesBuilder.() -> Unit = { + ToggleBase() + Style("background", if (On) Ui.Accent else "rgba(191, 213, 255, 0.22)") +} + +private fun ToggleKnobStyle(On: Boolean): csstype.PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("top", "0.2em") + Style("left", if (On) "1.45em" else "0.2em") + Style("width", "1.05em") + Style("height", "1.05em") + Style("border-radius", "50%") + Style("background", if (On) "#001722" else "rgba(255, 255, 255, 0.9)") + Style("transition", "left 120ms ease") +} + +private fun ChildrenBuilder.Toggle(On: Boolean, OnChange: (Boolean) -> Unit) { + button { + css(ToggleStyle(On)) + onClick = { OnChange(!On) } + div { css(ToggleKnobStyle(On)) } + } +} + +private val SettingRowStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "1em") + Style("padding", "0.75em 1em") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") +} + +private val SettingLabel: csstype.PropertiesBuilder.() -> Unit = { + Style("font", "500 0.95em/1.3 ${Ui.FontMedium}") +} + +private val SettingDesc: csstype.PropertiesBuilder.() -> Unit = { + Style("margin-top", "0.15em") + Style("color", Ui.Muted) + Style("font", "400 0.82em/1.4 ${Ui.FontRegular}") +} + +private fun ChildrenBuilder.SettingRow(Title: String, Desc: String = "", Control: ChildrenBuilder.() -> Unit) { + div { + css(SettingRowStyle) + div { + css { + Style("flex", "1") + Style("min-width", "0") + } + div { css(SettingLabel); +Title } + if (Desc.isNotEmpty()) div { css(SettingDesc); +Desc } + } + div { + css { + Style("flex", "0 0 auto") + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + } + Control() + } + } +} + +private val RangeStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "11em") + Style("accent-color", Ui.Accent) + Style("cursor", "pointer") +} + +private val RangeValue: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "3.2em") + Style("text-align", "right") + Style("color", Ui.Cyan) + Style("font", "500 0.9em/1 ${Ui.FontMedium}") +} + +private fun ChildrenBuilder.SliderRow(Title: String, Desc: String, Value: Int, OnChange: (Int) -> Unit) { + SettingRow(Title, Desc) { + input { + css(RangeStyle) + asDynamic()["type"] = "range" + asDynamic()["min"] = "0" + asDynamic()["max"] = "100" + asDynamic()["value"] = "$Value" + onChange = { E -> OnChange(E.target.value.toString().toIntOrNull() ?: Value) } + } + span { css(RangeValue); +"$Value%" } + } +} + +private val SelectStyle: csstype.PropertiesBuilder.() -> Unit = { + Style("min-width", "9em") + Style("padding", "0.4em 0.6em") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.35em") + Style("background", "rgba(0, 12, 20, 0.85)") + Style("color", Ui.Text) + Style("font", "400 0.9em/1 ${Ui.FontRegular}") + Style("outline", "none") +} + +private fun ChildrenBuilder.SelectRow( + Title: String, + Desc: String, + Options: List, + Index: Int, + OnChange: (Int) -> Unit, +) { + SettingRow(Title, Desc) { + select { + css(SelectStyle) + value = Options.getOrElse(Index) { Options.firstOrNull() ?: "" } + onChange = { E -> + val Found = Options.indexOf(E.target.value) + if (Found >= 0) OnChange(Found) + } + for (Option in Options) option { value = Option; +Option } + } + } +} + +private fun ChildrenBuilder.ProgressBar(Current: Int, Target: Int, Height: String = "0.4em") { + val Percent = if (Target <= 0) 0 else (Current * 100 / Target).coerceIn(0, 100) + div { + css { + Style("flex", "1") + Style("height", Height) + Style("background", "rgba(118, 255, 51, 0.16)") + Style("border-radius", "999px") + Style("overflow", "hidden") + } + div { + css { + Style("width", "$Percent%") + Style("height", "100%") + Style("background", Ui.Accent) + Style("border-radius", "999px") + } + } + } +} + +private fun ChildrenBuilder.Pill(Text: String, Color: String = Ui.Cyan) { + span { + css { + Style("display", "inline-flex") + Style("align-items", "center") + Style("padding", "0.12em 0.45em") + Style("border-radius", "0.25em") + Style("background", "rgba(191, 213, 255, 0.12)") + Style("color", Color) + Style("font", "500 0.75em/1.4 ${Ui.FontMedium}") + } + +Text + } +} + +private fun ChildrenBuilder.TableHeader(Cells: List, Columns: String) { + div { + css { + Style("display", "grid") + Style("grid-template-columns", Columns) + Style("gap", "0.5em") + Style("padding", "0.55em 0.8em") + Style("border-bottom", "1px solid ${Ui.Divider}") + Style("color", Ui.Muted) + Style("font", "500 0.78em/1 ${Ui.FontMedium}") + Style("text-transform", "uppercase") + Style("letter-spacing", "0.04em") + } + for (Cell in Cells) span { +Cell } + } +} + +private fun ChildrenBuilder.TableRow(Columns: String, Cells: ChildrenBuilder.() -> Unit) { + div { + css { + Style("display", "grid") + Style("grid-template-columns", Columns) + Style("gap", "0.5em") + Style("align-items", "center") + Style("padding", "0.55em 0.8em") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + Style("font-size", "0.9em") + } + Cells() + } +} + +private fun ChildrenBuilder.UserAvatar(Name: String, Size: String = "2.2em") { + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("width", Size) + Style("height", Size) + Style("flex", "0 0 $Size") + Style("border-radius", "0.4em") + Style("background", "linear-gradient(145deg, rgba(0, 212, 255, 0.28), rgba(118, 255, 51, 0.12))") + Style("color", "#eaf6ff") + Style("font", "600 0.95em/1 ${Ui.FontBold}") + Style("text-transform", "uppercase") + } + +(Name.firstOrNull()?.toString() ?: "?") + } +} + +private val OnlineDot: csstype.PropertiesBuilder.() -> Unit = { + Style("width", "0.5em") + Style("height", "0.5em") + Style("border-radius", "50%") + Style("flex", "0 0 0.5em") +} + +private fun ChildrenBuilder.OnlineState(Online: Boolean) { + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.35em") + Style("color", if (Online) Ui.Accent else Ui.Muted) + Style("font", "400 0.8em/1 ${Ui.FontRegular}") + } + i { + css { + Apply(OnlineDot) + Style("background", if (Online) Ui.Accent else "rgba(255, 255, 255, 0.3)") + } + } + +if (Online) "在线" else "离线" + } +} + +// ---------------------------------------------------------------- 数据读取辅助 + +private fun UserDataOf(Id: Any?): Any? = + StateOrNull()?.RemoteUserDataStorage?.UserIdToData?.get(Id) + +private fun UserNameOf(Id: Any?): String { + val Name = UserDataOf(Id).Str("uid", "") + return Name.ifBlank { if (Id == null) "玩家" else "#$Id" } +} + +private fun UserRankOf(Id: Any?): String = UserDataOf(Id).Str("rank", "—") + +private fun UserClanTagOf(Id: Any?): String = UserDataOf(Id).Str("clanTag", "") + +private fun UserOnlineOf(Id: Any?): Boolean { + val Status = UserDataOf(Id).Str("onlineStatus", "").lowercase() + return Status.contains("online") || Status == "1" || Status == "true" +} + +private fun ClanInfoOf(Id: Any?): Any? = + StateOrNull()?.ClansStorage?.Clans?.get(Id) + +private fun ClanNameOf(Id: Any?): String { + val Name = ClanInfoOf(Id).Str("name", "") + return Name.ifBlank { if (Id == null) "军团" else "#$Id" } +} + +private fun ClanTagOf(Id: Any?): String = ClanInfoOf(Id).Str("tag", "") + +private fun FormatDate(Seconds: Int): String { + if (Seconds <= 0) return "—" + return try { + js("new Date(Seconds * 1000).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })") as String + } catch (_: Throwable) { + "—" + } +} + +private fun AgoText(Seconds: Int): String = when { + Seconds < 60 -> "刚刚" + Seconds < 3600 -> "${Seconds / 60} 分钟前" + Seconds < 86400 -> "${Seconds / 3600} 小时前" + else -> "${Seconds / 86400} 天前" +} + +private fun RewardIcon(Name: String): String { + val N = Name.lowercase() + return when { + N.contains("水晶") || N.contains("crystal") -> "crystalSmall.242e6c15.svg" + N.contains("红宝石") || N.contains("ruby") -> "ruby.ce62efbc.svg" + N.contains("经验") || N.contains("xp") -> "XP.0da1321c.webp" + N.contains("传奇钥匙") || N.contains("legendary") -> "legendary_key_active.287d4376.png" + N.contains("史诗钥匙") || N.contains("epic") -> "epic_key_active.b7887b8b.png" + N.contains("稀有钥匙") || N.contains("rare") -> "rare_key_active.a94e1131.png" + N.contains("钥匙") || N.contains("key") -> "standard_key_active.94214105.png" + N.contains("迷彩") || N.contains("paint") -> "iconCard.23a678cb.svg" + else -> "iconCard.23a678cb.svg" + } +} + +private fun QuestIcon(Type: String): String { + val T = Type.uppercase() + return when { + T.contains("CHALLENGE") || T.contains("BATTLE_PASS") -> "StarMission.d11624d7.svg" + T.contains("CONTRACT") -> "contract.90d7cd8f.svg" + T.contains("WEEKLY") -> "iconLobbyMenuQuests.54801b8b.svg" + else -> "questLockTimer.41d0518e.svg" + } +} + +private fun DynamicProgress(Quest: Any?): Int { + val Progress: dynamic = Quest.asDynamic()["progress"] + if (Progress == null) return 0 + var Max = 0 + val Raw: Any? = Progress + try { + if (Raw is Map<*, *>) { + for ((Key, Value) in Raw) { + if (Key?.toString() == "COMPLETE_QUEST") continue + if (Value is Number) Max = maxOf(Max, Value.toInt()) + } + } else if (jsTypeOf(Progress) == "object") { + val Keys = js("Object.keys(Progress)") as Array + for (Key in Keys) { + if (Key == "COMPLETE_QUEST") continue + val Value = Progress[Key] + if (Value is Number) Max = maxOf(Max, Value.toInt()) + } + } + } catch (_: Throwable) { + } + return Max +} + +private fun DynamicRewards(Quest: Any?): List> { + val Rewards: dynamic = Quest.asDynamic()["rewards"] + val Out = ArrayList>() + if (Rewards == null) return Out + val Raw: Any? = Rewards + try { + if (Raw is List<*>) { + for (Item in Raw) { + val Name = Item.Str("name", "") + val Count = Item.Num("count", 1) + if (Name.isNotBlank()) Out.add(Name to Count) + } + } else if (jsTypeOf(Rewards) == "object") { + val Length = (Rewards.length as? Int) ?: 0 + for (Index in 0 until Length) { + val Item = Rewards[Index] + val Name = Item.Str("name", "") + val Count = Item.Num("count", 1) + if (Name.isNotBlank()) Out.add(Name to Count) + } + } + } catch (_: Throwable) { + } + return Out +} + +// ---------------------------------------------------------------- 商店 + +private fun CategoryIcon(Id: Int): String = when (Id) { + 0 -> "turrets.20be27f5.svg" + 1 -> "hulls.b316ae80.svg" + 2 -> "allPaints.741c65e1.svg" + 3 -> "supplies.522ea46f.svg" + 10 -> "ProtectionModules.41103d70.svg" + 11 -> "Devices.ae5dfc92.svg" + 13 -> "iconLobbyMenuContainers.f960290f.svg" + 14 -> "Drones.07e10f28.svg" + 15 -> "allPaints.741c65e1.svg" + 17 -> "rocket_launcher_cyclone.deb25fdb.svg" + 18 -> "Grenades.25dcf096.svg" + else -> "iconCard.23a678cb.svg" +} + +private fun PaintGradient(Name: String): String { + var Hash = 0 + for (Char in Name) Hash = (Hash * 31 + Char.code) and 0x7FFFFFFF + val Hue = Hash % 360 + val Hue2 = (Hue + 42) % 360 + return "linear-gradient(135deg, hsl($Hue, 58%, 46%), hsl($Hue2, 62%, 28%))" +} + +internal fun ItemIcon(Item: CatalogItem): String { + val N = Item.Name + return when { + N.contains("维修") -> "repairWithBackground.58991a46.svg" + N.contains("护甲") -> "armorWithBackground.3a887dad.svg" + N.contains("伤害") -> "damageWithBackground.4a80d6f3.svg" + N.contains("速度") -> "nitroWithBackground.9d0ffef7.svg" + N.contains("地雷") -> "mineWithBackground.eced36f5.svg" + N.contains("金箱子") -> "Gold box.6efd6936.svg" + // 炮塔 + N.contains("马格南") -> "Magnum.54718a7f.svg" + N.contains("冰风暴") -> "Freeze.51cbde00.svg" + N.contains("镭射炮") -> "new_icon_shaft.4a28c47a.svg" + N.contains("极速炮") -> "Vulcan.eb748cd8.svg" + N.contains("火龙珠") -> "Ricochet.126321e9.svg" + N.contains("雷暴炮") -> "Thunder.5e4fe37f.svg" + N.contains("轰天炮") -> "Smoky.a2623c59.svg" + N.contains("激光炮") -> "Railgun.e2aea740.svg" + N.contains("特斯拉") -> "Tesla.da7544a4.svg" + N.contains("离子炮") -> "Twins.0809b017.svg" + N.contains("火焰炮") -> "Firebird.f17e7721.svg" + N.contains("滑膛炮") -> "Hammer.d7ddf1ba.svg" + N.contains("火箭炮") -> "Striker.4d0c2fad.svg" + N.contains("电磁炮") -> "Gauss.da869a90.svg" + // 底盘 + N.contains("泰坦") -> "Titan.d2bf1806.svg" + N.contains("维京") -> "Viking.45549e00.svg" + N.contains("剑圣") -> "juggernaut.77e55855.svg" + N.contains("猎人") -> "Hunter.82b7d58f.svg" + N.contains("蜂王") -> "Hornet.aa9c33f2.svg" + N.contains("猛犸象") -> "Mammoth.1c266702.svg" + N.contains("主宰者") -> "Dictator.770a5277.svg" + N.contains("黄蜂") -> "Wasp.63098bb6.svg" + N.contains("十字军") -> "Crusader.f14927dc.svg" + N.contains("圣骑士") -> "Paladin.7b656d82.svg" + N.contains("战神") -> "Ares.e0b4f363.svg" + N.contains("蜂鸟") -> "Hopper.17d5fd98.svg" + else -> CategoryIcon(Item.Category) + } +} + +private fun RarityName(Rarity: Int): String = when (Rarity) { + 1 -> "罕见" + 2 -> "稀有" + 3 -> "史诗" + 4 -> "传奇" + 5 -> "异国情调" + else -> "普通" +} + +private fun RarityColor(Rarity: Int): String = when (Rarity) { + 1 -> Ui.Accent + 2 -> Ui.Cyan + 3 -> "#c084fc" + 4 -> "#ffcc00" + 5 -> Ui.Ruby + else -> "rgba(232, 242, 255, 0.78)" +} + +private val ShopScreen = FC { + var Category by useState("all") + var SelectedId by useState(null) + val Items = UiCatalog.Items.filter { it.Buyable && it.Name.isNotBlank() } + val Shown = if (Category == "all") Items else Items.filter { it.Category.toString() == Category } + val Categories = GarageCategories.filter { C -> Items.any { it.Category == C.Id } } + val Selected = Shown.firstOrNull { it.Id == SelectedId } ?: Shown.firstOrNull() + + ScreenShell("商店", "${Items.size} 件可购买") { + div { + css(TabBar) + button { + css(TabStyle(Category == "all")) + onClick = { Category = "all"; SelectedId = null } + +"全部" + } + for (C in Categories) { + button { + css(TabStyle(Category == C.Id.toString())) + onClick = { Category = C.Id.toString(); SelectedId = null } + +C.Label + } + } + } + + div { + css(Columns) + div { + css { + Style("flex", "1") + Style("min-width", "0") + } + if (Shown.isEmpty()) { + EmptyState("离线目录里暂无可购买物品。") + } else { + div { + css(Grid) + for (Item in Shown.take(120)) { + button { + val CardBase = CardStyle(Item.Id == Selected?.Id) + css { + CardBase(this) + Style("padding", "0") + } + onClick = { SelectedId = Item.Id } + div { + css { + Style("height", "5.5em") + Style("border-radius", "0.45em 0.45em 0 0") + if (Item.Category == 2) { + Style("background", PaintGradient(Item.Name)) + } else { + Style("background", "url(\"${Asset("static/images/" + ItemIcon(Item))}\") center / contain no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.5)") + } + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + Style("opacity", "0.92") + } + } + div { + css { Style("padding", "0.65em 0.8em 0.8em") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("gap", "0.4em") + } + div { css(CardTitle); +Item.Name } + if (Item.Premium) Pill("VIP", "#ffcc00") + } + div { css(CardText); +CategoryLabel(Item.Category) } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("margin-top", "0.55em") + } + span { + css { + Style("color", RarityColor(Item.Rarity)) + Style("font", "500 0.78em/1 ${Ui.FontMedium}") + } + +RarityName(Item.Rarity) + } + span { css { Style("color", Ui.Cyan); Style("font", "500 0.9em/1 ${Ui.FontMedium}") }; +PriceText(Item) } + } + } + } + } + } + } + } + + div { + css { + Style("width", "22.5em") + Style("flex", "0 0 22.5em") + Style("align-self", "flex-start") + } + css { + Apply(Panel) + Style("overflow", "hidden") } + if (Selected == null) { + div { + css { + Style("padding", "1.2em") + Style("color", Ui.Muted) + } + +"选择一件物品查看详情。" + } + } else { + div { + css { + Style("height", "11em") + if (Selected.Category == 2) { + Style("background", PaintGradient(Selected.Name)) + } else { + Style("background", "url(\"${Asset("static/images/" + ItemIcon(Selected))}\") center / contain no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.55)") + } + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + } + div { + css { Style("padding", "1em 1.1em 1.2em") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("gap", "0.5em") + } + h2 { css(CardTitle); +Selected.Name } + if (Selected.Premium) Pill("VIP", "#ffcc00") + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.6em") + Style("margin-top", "0.25em") + } + span { css(CardText); +CategoryLabel(Selected.Category) } + span { css { Style("color", RarityColor(Selected.Rarity)); Style("font", "500 0.8em/1 ${Ui.FontMedium}") }; +RarityName(Selected.Rarity) } + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + Style("margin-top", "0.8em") + } + img { + src = Asset( + "static/images/" + when (Selected.Currency) { + 0 -> "crystalSmall.242e6c15.svg" + 1 -> "coin.3a16e831.svg" + 2 -> "ruby.ce62efbc.svg" + else -> "coin.3a16e831.svg" + } + ) + css { Style("width", "1.4em"); Style("height", "1.4em") } + } + span { css { Style("color", Ui.Cyan); Style("font", "500 1.2em/1 ${Ui.FontMedium}") }; +PriceText(Selected) } + } + p { + css { + Style("margin-top", "0.8em") + Style("color", Ui.Muted) + Style("font", "400 0.88em/1.5 ${Ui.FontRegular}") + } + +"购买后可在车库中装备。离线模式下只演示购买确认流程。" + } + div { + css { Style("display", "flex"); Style("gap", "0.5em"); Style("margin-top", "0.8em") } + button { + css(PrimaryButton) + onClick = { + DialogContext.Show( + "购买确认", + "确定购买「${Selected.Name}」吗?\n价格:${PriceText(Selected)}", + "购买", + "取消", + OnConfirm = { + ToastContext.Show("已提交购买:${Selected.Name}(离线模式)") + }, + ) + } + +"购买" + } + button { + css(SecondaryButton) + onClick = { ScreenContext.OnNavigate("garage") } + +"去车库" + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- 幸运礼盒 + +private fun ContainerArt(Key: String): String { + val K = Key.lowercase() + return when { + K.contains("legend") -> "legendary_container.77b89e67.png" + K.contains("epic") -> "epic_container.b0b1a8a3.png" + K.contains("rare") -> "rare_container.64858635.png" + else -> "standard_container.44c6d5f0.png" + } +} + +private fun ContainerKeyIcon(Key: String): String { + val K = Key.lowercase() + return when { + K.contains("legend") -> "legendary_key_active.287d4376.png" + K.contains("epic") -> "epic_key_active.b7887b8b.png" + K.contains("rare") -> "rare_key_active.a94e1131.png" + else -> "standard_key_active.94214105.png" + } +} + +private fun ContainerKeyCount(Keys: Map, Key: String): Int = + Keys[Key] ?: Keys.entries.firstOrNull { it.key.equals(Key, ignoreCase = true) }?.value ?: 0 + +private val ContainersScreen = FC { + var SelectedKey by useState(null) + val State = StateOrNull() + val Names = State?.LootBoxes?.ContainerNames ?: emptyMap() + val Descriptions = State?.LootBoxes?.ContainerDescriptions ?: emptyMap() + val Keys = State?.LootBoxes?.Keys ?: emptyMap() + val Entries = if (Names.isEmpty()) { + listOf( + Triple("standard", "标准礼盒", "每日可开启一次,包含基础奖励。"), + Triple("rare", "精良礼盒", "更高概率获得稀有物品。"), + Triple("epic", "史诗礼盒", "包含史诗与传奇奖励。"), + Triple("legendary", "传奇礼盒", "包含最稀有的物品与迷彩。"), + ) + } else { + Names.map { (K, V) -> Triple(K, V, Descriptions[K] ?: "") } + } + val Selected = Entries.firstOrNull { it.first == SelectedKey } ?: Entries.firstOrNull() + + ScreenShell("幸运礼盒", "${Entries.size} 种礼盒") { + div { + css(Columns) + div { + css { + Style("flex", "1") + Style("min-width", "0") + } + div { + css(Grid) + for ((Key, Name, Desc) in Entries) { + button { + val CardBase = CardStyle(Key == Selected?.first) + css { + CardBase(this) + Style("padding", "0") + } + onClick = { SelectedKey = Key } + div { + css { + Style("height", "8.5em") + Style("border-radius", "0.45em 0.45em 0 0") + Style("background", "url(\"${Asset("static/images/" + ContainerArt(Key))}\") center / contain no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.55)") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + } + div { + css { Style("padding", "0.7em 0.8em 0.85em") } + div { css(CardTitle); +Name } + div { css(CardText); +Desc.ifEmpty { "开启后获得随机奖励。" } } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("margin-top", "0.7em") + } + span { + css { + Style("display", "inline-flex") + Style("align-items", "center") + Style("gap", "0.3em") + Style("color", "#dce9f5") + Style("font", "500 0.85em/1 ${Ui.FontMedium}") + } + img { + src = Asset("static/images/" + ContainerKeyIcon(Key)) + css { Style("width", "1.2em"); Style("height", "1.2em") } + } + +"${ContainerKeyCount(Keys, Key)}" + } + span { css { Style("color", Ui.Cyan); Style("font-size", "0.8em") }; +"查看奖励" } + } + } + } + } + } + } + + div { + css { + Style("width", "22.5em") + Style("flex", "0 0 22.5em") + Style("align-self", "flex-start") + } + css { + Apply(Panel) + Style("overflow", "hidden") } + if (Selected == null) { + div { css { Style("padding", "1.2em"); Style("color", Ui.Muted) }; +"选择一种礼盒。" } + } else { + val (Key, Name, Desc) = Selected + div { + css { + Style("height", "13em") + Style("background", "url(\"${Asset("static/images/" + ContainerArt(Key))}\") center / contain no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.6)") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + } + div { + css { Style("padding", "1em 1.1em 1.2em") } + h2 { css(CardTitle); +Name } + div { css(CardText); +Desc.ifEmpty { "开启后获得随机奖励。" } } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + Style("margin-top", "0.8em") + } + img { + src = Asset("static/images/" + ContainerKeyIcon(Key)) + css { Style("width", "1.5em"); Style("height", "1.5em") } + } + span { css { Style("color", Ui.Cyan); Style("font", "500 1.1em/1 ${Ui.FontMedium}") }; +"${ContainerKeyCount(Keys, Key)} 把钥匙" } + } + div { + css { Style("margin-top", "0.9em") } + div { css(SettingLabel); +"可能获得的奖励" } + div { + css { + Style("display", "flex") + Style("flex-wrap", "wrap") + Style("gap", "0.35em") + Style("margin-top", "0.45em") + } + Pill("普通") + Pill("罕见", Ui.Accent) + Pill("稀有", Ui.Cyan) + Pill("史诗", "#c084fc") + Pill("传奇", "#ffcc00") + Pill("异国情调", Ui.Ruby) + } + } + p { + css { + Style("margin-top", "0.9em") + Style("color", Ui.Muted) + Style("font", "400 0.86em/1.5 ${Ui.FontRegular}") + } + +"离线模式下只演示开启确认流程,真实掉落表在后续接入。" + } + div { + css { Style("display", "flex"); Style("gap", "0.5em"); Style("margin-top", "0.7em") } + button { + css(PrimaryButton) + onClick = { + DialogContext.Show( + "开启礼盒", + "确定开启「$Name」吗?", + "开启", + "取消", + OnConfirm = { + ToastContext.Show("已开启:$Name(离线模式)") + }, + ) + } + +"开启" + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("购买钥匙(离线模式)") } + +"购买钥匙" + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- 战斗选择 + +private val BattleModes = listOf( + Triple("DM", "死亡混战", "竞技场中的每个玩家都是您的敌人,摧毁坦克最多的人获胜。"), + Triple("TDM", "团队对战", "摧毁敌方坦克并保护队友,击杀最多的队伍获胜。"), + Triple("CTF", "互夺军旗", "夺取敌方旗帜并带回基地,夺取最多旗帜的队伍获胜。"), + Triple("CP", "控制点", "占领并守住控制点,比赛结束时得分最高的队伍获胜。"), + Triple("AS", "突袭", "进攻方放置旗帜,防守方保护基地,得分更多的队伍获胜。"), + Triple("JGR", "剑圣", "攻击敌方队伍的剑圣并保护自己,消灭最多敌人的队伍获胜。"), + Triple("SGE", "围城大战", "控制一个点并使敌人远离它,赢得更多积分的团队获胜。"), + Triple("RUGBY", "橄榄球", "把球带到敌方基地,得分最高的团队获胜。"), +) + +private fun BattleModeIcon(Code: String): String = when (Code.uppercase()) { + "DM" -> "dm_mode.cedf5c30.svg" + "TDM" -> "tdm_mode.ef239dba.svg" + "CTF" -> "ctf_mode.fba37902.svg" + "CP" -> "cp_mode.9d327fbc.svg" + "AS" -> "asl_mode.42f836ca.svg" + "JGR" -> "jg_mode.025a9047.svg" + "SGE" -> "sge_mode.4a6035e8.svg" + "RUGBY" -> "rugby.705f26cb.webp" + else -> "battle_pick_classic_mode.5dc581ba.webp" +} + +private fun BattleModeName(Code: String): String = + BattleModes.firstOrNull { it.first == Code.uppercase() }?.second ?: Code + +private val BattlesScreen = FC { + var Mode by useState("all") + var SelectedKey by useState(null) + val State = StateOrNull() + val Battles = State?.BattlesSelect?.Battles ?: emptyMap() + val Filtered = Battles.entries.filter { Entry -> + val BattleMode = Entry.value.Str("mode", Entry.value.Str("battleMode", "")).uppercase() + Mode == "all" || BattleMode == Mode || BattleMode.contains(Mode) + } + val Selected = Filtered.firstOrNull { it.key == SelectedKey } ?: Filtered.firstOrNull() + + ScreenShell("开始战斗", "${Battles.size} 个可用战场") { + SectionTitle("战斗模式") + div { + css { + Style("display", "flex") + Style("flex-wrap", "wrap") + Style("gap", "0.6em") + } + button { + val CardBase = CardStyle(Mode == "all") + css { + CardBase(this) + Style("width", "10em") + Style("height", "5.2em") + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + Style("padding", "0.5em 0.6em") + } + onClick = { Mode = "all"; SelectedKey = null } + div { + css { + Style("width", "2.8em") + Style("height", "2.8em") + Style("flex", "0 0 2.8em") + Style("background", "url(\"${Asset("static/images/battle_pick_classic_mode.5dc581ba.webp")}\") center / contain no-repeat") + Style("border-radius", "0.3em") + } + } + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { css(CardTitle); +"全部模式" } + div { css(CardText); +"${Battles.size} 个战场" } + } + } + for ((Code, Name, Desc) in BattleModes) { + button { + val CardBase = CardStyle(Mode == Code) + css { + CardBase(this) + Style("width", "10em") + Style("height", "5.2em") + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + Style("padding", "0.5em 0.6em") + } + onClick = { Mode = Code; SelectedKey = null } + div { + css { + Style("width", "2.8em") + Style("height", "2.8em") + Style("flex", "0 0 2.8em") + Style("background", "url(\"${Asset("static/images/" + BattleModeIcon(Code))}\") center / contain no-repeat") + Style("border-radius", "0.3em") + } + } + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { css(CardTitle); +Name } + div { css(CardText); +Code } + } + } + } + } + + SectionTitle("战场列表") + div { + css { + Style("display", "flex") + Style("align-items", "flex-start") + Style("gap", "1em") + } + div { + css { + Style("flex", "1") + Style("min-width", "0") + } + if (Filtered.isEmpty()) { + EmptyState("该模式暂无战场。") + } else { + div { + css { + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.6em") + } + for (Entry in Filtered) { + val Battle = Entry.value + val BattleMode = Battle.Str("mode", Battle.Str("battleMode", "")).uppercase() + val Players = Battle.Num("players", 0) + val MaxPlayers = Battle.Num("maxPlayers", 0) + button { + val CardBase = CardStyle(Entry.key == Selected?.key) + css { + CardBase(this) + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.7em") + Style("padding", "0.6em 0.8em") + } + onClick = { SelectedKey = Entry.key } + div { + css { + Style("width", "2.6em") + Style("height", "2.6em") + Style("flex", "0 0 2.6em") + Style("background", "url(\"${Asset("static/images/" + BattleModeIcon(BattleMode))}\") center / contain no-repeat") + } + } + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.45em") + } + span { css(CardTitle); +Battle.Str("name", "战场") } + Pill(BattleModeName(BattleMode), Ui.Cyan) + } + div { css(CardText); +Battle.Str("map", "未知地图") } + } + div { + css { + Style("width", "7em") + Style("flex", "0 0 7em") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.25em") + } + span { css { Style("color", Ui.Muted); Style("font-size", "0.78em"); Style("text-align", "right") }; +"$Players/$MaxPlayers" } + ProgressBar(Players, MaxPlayers, "0.3em") + } + } + } + } + } + } + + div { + css { + Style("width", "22.5em") + Style("flex", "0 0 22.5em") + Style("align-self", "flex-start") + } + css(Panel) + if (Selected == null) { + div { css { Style("padding", "1.2em"); Style("color", Ui.Muted) }; +"选择一场战斗。" } + } else { + val Battle = Selected.value + val BattleMode = Battle.Str("mode", Battle.Str("battleMode", "")).uppercase() + val Players = Battle.Num("players", 0) + val MaxPlayers = Battle.Num("maxPlayers", 0) + div { + css { + Style("height", "8em") + Style("background", "url(\"${Asset("static/images/" + BattleModeIcon(BattleMode))}\") center / contain no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.55)") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + } + div { + css { Style("padding", "1em 1.1em 1.2em") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("gap", "0.5em") + } + h2 { css(CardTitle); +Battle.Str("name", "战场") } + Pill(BattleModeName(BattleMode), Ui.Cyan) + } + div { css(CardText); +Battle.Str("map", "未知地图") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.6em") + Style("margin-top", "0.7em") + } + ProgressBar(Players, MaxPlayers) + span { css(MutedText); +"$Players / $MaxPlayers" } + } + StatRow("模式", BattleModeName(BattleMode)) + StatRow("地图", Battle.Str("map", "未知地图")) + StatRow("等级限制", Battle.Str("rankRange", "—")) + StatRow("战场编号", Selected.key.toString()) + button { + css(PrimaryButton) + onClick = { + DialogContext.Show( + "加入战斗", + "确定加入「${Battle.Str("name", "战场")}」吗?", + "加入", + "取消", + OnConfirm = { ToastContext.Show("已加入:${Battle.Str("name", "战场")}(离线模式)") }, + ) + } + +"加入" + } + } + } + } + } + + div { + css { + Style("display", "flex") + Style("justify-content", "flex-end") + Style("margin-top", "1em") + } + button { + css(SecondaryButton) + onClick = { + DialogContext.Show( + "创建战场", + "创建战场需要完整协议链路,离线模式暂不开放。", + "知道了", + "取消", + ) + } + +"创建战场" + } + } + } +} + +// ---------------------------------------------------------------- 任务 + +private data class QuestBucket( + val Key: String, + val Name: String, + val Description: String, + val Type: String, + val Id: Long?, +) + +private fun QuestBuckets(State: RootState?): List { + val Categories = State?.QuestCategories?.Categories ?: emptyList() + if (Categories.isNotEmpty()) { + return Categories.sortedBy { it.index }.map { Category -> + QuestBucket( + Key = Category.id.toString(), + Name = Category.name.ifBlank { "任务" }, + Description = Category.description, + Type = Category.type, + Id = Category.id, + ) + } + } + return listOf( + QuestBucket("main", "主要", "完成特殊任务,赢取超酷奖励。", "MAIN", null), + QuestBucket("daily", "每日", "完成当前任务后,新任务将自动解锁。", "DAILY", null), + QuestBucket("weekly", "每周", "完成每周任务,领取额外奖励。", "WEEKLY", null), + QuestBucket("challenge", "星级挑战", "参与战斗赢取星星徽章,每提升一个挑战等级,即可解锁更多专属奖励。", "CHALLENGE", null), + QuestBucket("contracts", "合约", "激活合约并完成指定目标,领取额外奖励。", "CONTRACT", null), + ) +} + +private fun QuestMatches(Quest: MainQuestInfo, Bucket: QuestBucket): Boolean { + if (Bucket.Id != null) return Quest.categoryId == Bucket.Id + val Type = Quest.type.uppercase() + return when (Bucket.Key) { + "daily" -> Type.contains("REWARD") && !Type.contains("WEEKLY") + "weekly" -> Type.contains("WEEKLY") + "challenge" -> Quest.battlePass || Quest.eventPass || Type.contains("CHALLENGE") + "contracts" -> false + else -> !Type.contains("REWARD") && !Type.contains("WEEKLY") && !Quest.battlePass && !Quest.eventPass + } +} + +private data class QuestView( + val Name: String, + val Description: String, + val Type: String, + val Current: Int, + val Target: Int, + val Completed: Boolean, + val Premium: Boolean, + val Pinned: Boolean, + val Rewards: List>, +) + +private fun QuestViewOf(Quest: MainQuestInfo): QuestView { + val Target = Quest.quantity.coerceAtLeast(1) + val Current = if (Quest.completed) Target else DynamicProgress(Quest).coerceIn(0, Target) + return QuestView( + Name = Quest.name.ifBlank { "任务" }, + Description = Quest.description, + Type = Quest.type, + Current = Current, + Target = Target, + Completed = Quest.completed, + Premium = Quest.premium, + Pinned = Quest.pinned, + Rewards = DynamicRewards(Quest), + ) +} + +private fun DemoQuests(Key: String): List = when (Key) { + "daily" -> listOf( + QuestView( + "每天完成任意任务", "完成当前任务后,新任务将自动解锁。", "DAILY", + 3, 8, false, false, true, + listOf("普通钥匙" to 3, "水晶" to 50), + ), + ) + "weekly" -> listOf( + QuestView( + "在一周内造成 50000 点伤害", "完成每周任务,领取额外奖励。", "WEEKLY", + 18400, 50000, false, false, false, + listOf("水晶" to 300, "经验值" to 500), + ), + QuestView( + "赢得 10 场战斗", "在任意模式中取得胜利。", "WEEKLY", + 4, 10, false, false, false, + listOf("稀有钥匙" to 1, "水晶" to 200), + ), + ) + "challenge" -> listOf( + QuestView( + "获得 10 颗星星", "参与战斗赢取星星徽章,解锁星级挑战奖励。", "CHALLENGE", + 4, 10, false, false, false, + listOf("史诗钥匙" to 1, "水晶" to 150), + ), + QuestView( + "达到星级挑战 5 级", "提升挑战等级以解锁更多专属奖励。", "CHALLENGE", + 2, 5, false, true, false, + listOf("传奇钥匙" to 1), + ), + ) + "contracts" -> emptyList() + else -> listOf( + QuestView( + "取得第一场胜利", "在任意战斗模式中取得 1 场胜利。", "MAIN", + 0, 1, false, false, true, + listOf("水晶" to 50, "经验值" to 100), + ), + QuestView( + "完成 5 场战斗", "在任意战斗模式中完成 5 场战斗。", "MAIN", + 2, 5, false, false, false, + listOf("普通钥匙" to 1, "水晶" to 100), + ), + QuestView( + "邀请好友加入游戏", "邀请好友并一起完成一场战斗。", "MAIN", + 0, 1, false, false, false, + listOf("红宝石" to 5), + ), + ) +} + +private fun ChildrenBuilder.QuestRewards(Rewards: List>) { + if (Rewards.isEmpty()) return + div { + css { + Style("display", "flex") + Style("flex-wrap", "wrap") + Style("align-items", "center") + Style("gap", "0.4em") + Style("margin-top", "0.5em") + } + for ((Name, Count) in Rewards) { + span { + css { + Style("display", "inline-flex") + Style("align-items", "center") + Style("gap", "0.25em") + Style("padding", "0.15em 0.45em") + Style("border-radius", "0.25em") + Style("background", "rgba(191, 213, 255, 0.10)") + Style("color", "#dce9f5") + Style("font", "500 0.78em/1.4 ${Ui.FontMedium}") + } + img { + src = Asset("static/images/" + RewardIcon(Name)) + css { Style("width", "1.1em"); Style("height", "1.1em") } + } + +"$Name × $Count" + } + } + } +} + +private fun ChildrenBuilder.QuestCard(Quest: QuestView, IsChallenge: Boolean) { + val Rewards = Quest.Rewards + div { + css { + Apply(Panel) + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.9em") + Style("padding", "0.85em 1em") + } + div { + css { + Style("width", "3.2em") + Style("height", "3.2em") + Style("flex", "0 0 3.2em") + Style("background", "url(\"${Asset("static/images/" + QuestIcon(Quest.Type))}\") center / contain no-repeat") + Style("opacity", "0.9") + } + } + div { + css { + Style("flex", "1") + Style("min-width", "0") + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.45em") + Style("flex-wrap", "wrap") + } + span { css(CardTitle); +Quest.Name } + if (Quest.Premium) Pill("VIP", "#ffcc00") + if (Quest.Pinned) Pill("置顶", Ui.Cyan) + if (IsChallenge) Pill("星级挑战", Ui.Accent) + } + if (Quest.Description.isNotBlank()) { + div { css(CardText); +Quest.Description } + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.6em") + Style("margin-top", "0.55em") + } + ProgressBar(Quest.Current, Quest.Target) + span { css(MutedText); +"${Quest.Current} / ${Quest.Target}" } + } + QuestRewards(Rewards) + } + div { + css { + Style("display", "flex") + Style("flex-direction", "column") + Style("align-items", "flex-end") + Style("gap", "0.4em") + Style("flex", "0 0 auto") + } + if (Quest.Completed) { + button { + css(PrimaryButton) + onClick = { + ToastContext.Show("已领取「${Quest.Name}」的奖励(离线模式)") + } + +"领取奖励" + } + } else { + button { + css(SecondaryButton) + onClick = { ScreenContext.OnNavigate("battles") } + +"去完成" + } + } + if (Rewards.isNotEmpty()) span { css(MutedText); +"${Rewards.size} 项奖励" } + } + } +} + +private data class DemoContract(val Name: String, val Description: String, val Progress: String) + +private val DemoContracts = listOf( + DemoContract("每日合约", "完成 3 场战斗并领取每日奖励。", "2 / 3"), + DemoContract("每周合约", "在一周内赢得 5 场战斗。", "1 / 5"), +) + +private val QuestsScreen = FC { + var SelectedKey by useState(null) + val State = StateOrNull() + val Buckets = QuestBuckets(State) + val Quests = State?.MainQuest?.Quests ?: emptyList() + val Bucket = Buckets.firstOrNull { it.Key == SelectedKey } ?: Buckets.firstOrNull() + val IsDemo = Quests.isEmpty() + val Shown = if (Bucket == null) { + emptyList() + } else if (IsDemo) { + DemoQuests(Bucket.Key) + } else { + Quests.filter { QuestMatches(it, Bucket) }.map { QuestViewOf(it) } + } + val Completed = Shown.count { it.Completed } + val IsChallenge = Bucket != null && + (Bucket.Key == "challenge" || Bucket.Type.uppercase().contains("CHALLENGE")) + val IsContracts = Bucket != null && + (Bucket.Key == "contracts" || Bucket.Type.uppercase().contains("CONTRACT")) + val Contracts = State?.ContractState?.Contracts ?: emptyMap() + + ScreenShell("任务", "${Quests.size} 个任务") { + div { + css(Columns) + div { + css(SideNav) + for (Item in Buckets) { + val Count = if (Item.Key == "contracts") { + if (IsDemo) DemoContracts.size else Contracts.size + } else if (IsDemo) { + DemoQuests(Item.Key).size + } else { + Quests.count { QuestMatches(it, Item) } + } + NavItem(Item.Name, Count, Item == Bucket) { SelectedKey = Item.Key } + } + } + div { + css(ContentPane) + if (Bucket == null) { + EmptyState("暂无任务分类。") + } else { + div { + css(PageToolbar) + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { css(PaneTitle); +Bucket.Name } + if (Bucket.Description.isNotBlank()) { + div { css(PaneDesc); +Bucket.Description } + } + } + if (IsDemo) Pill("离线示例", Ui.Muted) + if (Completed > 0) { + button { + css(PrimaryButton) + onClick = { ToastContext.Show("已领取 $Completed 个任务的奖励(离线模式)") } + +"领取所有 ($Completed)" + } + } + } + + if (IsChallenge) { + div { + css { + Apply(Panel) + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.8em") + Style("padding", "0.8em 1em") + Style("background", "linear-gradient(90deg, rgba(255, 204, 0, 0.14), rgba(0, 18, 28, 0.8))") + } + img { + src = Asset("static/images/star.01f7e4f8.svg") + css { Style("width", "1.8em"); Style("height", "1.8em") } + } + div { + css { Style("flex", "1") } + div { css(SettingLabel); +"星级挑战" } + div { css(SettingDesc); +"当前星星:${HomeContext.Stars} · 等级 ${HomeContext.BattlePasses}" } + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("购买星级挑战奖励加成(离线模式)") } + +"购买解锁奖励加成" + } + } + } + + if (IsContracts) { + if (Contracts.isEmpty() && !IsDemo) { + EmptyState("当前没有可用的合约。") + } else if (IsDemo) { + for (Contract in DemoContracts) { + div { + css { + Apply(Panel) + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.8em") + Style("padding", "0.85em 1em") + } + div { + css { + Style("width", "3.2em") + Style("height", "3.2em") + Style("flex", "0 0 3.2em") + Style("background", "url(\"${Asset("static/images/contract.90d7cd8f.svg")}\") center / contain no-repeat") + } + } + div { + css { Style("flex", "1") } + div { css(CardTitle); +Contract.Name } + div { css(CardText); +Contract.Description } + span { css(MutedText); +"进度 ${Contract.Progress}" } + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("已领取「${Contract.Name}」的奖励(离线模式)") } + +"领取奖励" + } + } + } + } else { + for ((Key, Value) in Contracts) { + div { + css { + Apply(Panel) + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.8em") + Style("padding", "0.85em 1em") + } + div { + css { + Style("width", "3.2em") + Style("height", "3.2em") + Style("flex", "0 0 3.2em") + Style("background", "url(\"${Asset("static/images/contract.90d7cd8f.svg")}\") center / contain no-repeat") + } + } + div { + css { Style("flex", "1") } + div { css(CardTitle); +"合约 #$Key" } + div { css(CardText); +"完成合约目标后即可领取奖励。" } + val Rewards: dynamic = Value.asDynamic()["Rewards"] + val Count = (Rewards?.length as? Int) ?: 0 + if (Count > 0) span { css(MutedText); +"$Count 项奖励" } + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("已领取合约 #$Key 的奖励(离线模式)") } + +"领取奖励" + } + } + } + } + } else if (Shown.isEmpty()) { + EmptyState("该分类暂时没有任务。") + } else { + div { + css { + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.55em") + } + for (Quest in Shown.take(80)) { + QuestCard(Quest, IsChallenge) + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- 设置 + +private val SettingsTabs = listOf("游戏显示", "画面", "音效", "操控", "账户", "战斗", "安全", "支持") + +private sealed class SettingEntry { + data class Header(val Text: String) : SettingEntry() + data class Toggle( + val Id: String, + val Label: String, + val Desc: String = "", + val Default: Boolean = false, + ) : SettingEntry() + data class Slider( + val Id: String, + val Label: String, + val Desc: String = "", + val Default: Int = 50, + ) : SettingEntry() + data class Choice( + val Id: String, + val Label: String, + val Desc: String = "", + val Options: List, + val Default: Int = 0, + ) : SettingEntry() + data class Action( + val Label: String, + val Desc: String, + val Button: String, + val Toast: String, + ) : SettingEntry() + data class Info( + val Label: String, + val Value: String, + val Desc: String = "", + ) : SettingEntry() + data class Keys(val Rows: List>) : SettingEntry() +} + +private fun SettingsEntries(Tab: Int, UserName: String): List = when (Tab) { + 0 -> listOf( + SettingEntry.Header("常规"), + SettingEntry.Choice( + "language", "语言", "允许您将游戏语言更改为可用选项之一。", + listOf("简体中文", "English", "Русский", "Deutsch", "Français", "Español"), 0, + ), + SettingEntry.Toggle("showDropZones", "显示空投区", "显示道具箱在地图掉落的位置", true), + SettingEntry.Toggle("alternateCamera", "备用视角", "开启备用视角功能", false), + SettingEntry.Toggle("showYourDrone", "显示您的无人机", "在屏幕上显示您的无人机", true), + SettingEntry.Toggle("showOtherDrone", "显示其他无人机", "显示其他无人机", true), + SettingEntry.Toggle("showChat", "显示聊天窗口", "显示聊天窗口", true), + SettingEntry.Toggle("showNotifications", "显示通知", "在屏幕上显示您的通知", true), + SettingEntry.Toggle("receivePresents", "接收礼物", "允许您接收其他玩家的礼物", true), + SettingEntry.Header("音频与通知"), + SettingEntry.Toggle("backgroundSound", "背景声音", "启用背景音效", true), + SettingEntry.Toggle("blurMute", "后台自动静音", "标签页失焦时静音战斗", true), + SettingEntry.Toggle("disableBattleInvites", "屏蔽战斗邀请", "屏蔽好友组队邀请", false), + SettingEntry.Info("浏览器通知", "已启用", "当游戏不是活动窗口时从其他玩家那里获取信息"), + SettingEntry.Action( + "激活兑换码", + "兑换码可让您获得水晶、VIP 天数、金箱子、迷彩和车库中的一些物品。", + "激活", + "兑换码功能将在账户协议接入后开放。", + ), + ) + 1 -> listOf( + SettingEntry.Header("图形质量"), + SettingEntry.Choice( + "quality", "图形质量", "允许您在精美的图形和快速的性能之间做出最佳选择。", + listOf("最低", "低", "中", "高", "最高", "自定义"), 3, + ), + SettingEntry.Toggle("showFps", "显示帧数和延迟", "显示您的帧数和延迟", false), + SettingEntry.Toggle("skybox", "显示天空", "显示地图天空", true), + SettingEntry.Toggle("autoQuality", "自动图形质量", "自动设置图形质量", true), + SettingEntry.Header("阴影与光照"), + SettingEntry.Toggle("dynamicShadows", "动态阴影", "启用动态阴影", true), + SettingEntry.Toggle("dynamicLighting", "动态照明", "启用动态照明", true), + SettingEntry.Toggle("shadows", "阴影", "启用阴影", true), + SettingEntry.Toggle("softShadows", "软阴影", "启用软阴影", true), + SettingEntry.Toggle("softParticles", "软粒子", "启用软粒子", true), + SettingEntry.Toggle("ssao", "环境光遮蔽", "启用环境光遮蔽", true), + SettingEntry.Toggle("fog", "雾效", "启用雾效", true), + SettingEntry.Header("效果与分辨率"), + SettingEntry.Choice("decals", "射弹孔与爆炸痕迹", "更改战斗中地图上显示的射击和爆炸痕迹。", listOf("关闭", "低", "中", "高", "最大"), 2), + SettingEntry.Choice("trees", "树木图形质量", "更改灌木和树木的渲染质量。", listOf("精简", "低", "中", "高"), 2), + SettingEntry.Choice("resolution", "图像分辨率", "在战斗中更改游戏屏幕分辨率,这会明显影响性能。", listOf("最低", "非常低", "低", "最高"), 3), + SettingEntry.Choice("fpsLimit", "帧数限制", "限制游戏的最大帧数。", listOf("30", "60", "120", "最高"), 1), + SettingEntry.Toggle("vsync", "垂直同步", "启用垂直同步", false), + SettingEntry.Toggle("antialiasing", "抗锯齿", "启用抗锯齿", true), + SettingEntry.Toggle("dust", "弹壳与扬尘", "显示移动轨迹下的弹壳与扬尘", true), + SettingEntry.Toggle("anisotropic", "各向异性过滤", "更改远处纹理的图像质量", true), + ) + 2 -> listOf( + SettingEntry.Header("音量"), + SettingEntry.Slider("masterVolume", "主音量", "允许您更改主音量", 80), + SettingEntry.Slider("musicVolume", "音乐音量", "允许您更改大厅音乐的音量", 60), + SettingEntry.Slider("ambientVolume", "环境音量", "允许您更改游戏中的背景声音", 50), + SettingEntry.Slider("sfxVolume", "音效音量", "允许您更改游戏中的战斗声音(射击、爆炸声)", 90), + SettingEntry.Slider("uiVolume", "界面音量", "允许您更改界面的音量", 70), + SettingEntry.Header("命中提示"), + SettingEntry.Slider("hitmarkPlayer", "受到敌方伤害时的音量", "允许您更改敌人射击您坦克的音量", 60), + SettingEntry.Slider("hitmarkEnemy", "对敌方造成伤害时的音量", "允许您更改您自己射击敌方坦克的音量", 60), + SettingEntry.Toggle("backgroundSound", "背景声音", "启用背景音效", true), + SettingEntry.Toggle("blurMute", "后台自动静音", "标签页失焦时静音战斗", true), + ) + 3 -> listOf( + SettingEntry.Header("输入设备"), + SettingEntry.Choice("inputDevice", "输入设备", "选择用于控制坦克的输入设备。", listOf("鼠标 + 键盘", "键盘", "游戏手柄", "触摸屏"), 0), + SettingEntry.Slider("sensitivity", "镜头灵敏度", "调整鼠标移动时的镜头灵敏度。", 50), + SettingEntry.Toggle("invertShaft", "反转垂直轴方向", "反转狙击模式垂直轴方向", false), + SettingEntry.Toggle("inversedBackward", "倒车时倒转转向", "倒车时反转转向", false), + SettingEntry.Toggle("inversedCamera", "反转镜头控制", "垂直移动瞄准摇杆时反转镜头方向", false), + SettingEntry.Toggle("mouseAcceleration", "系统鼠标加速", "如果鼠标操作时镜头经常突然跳动,请禁用此选项。", false), + SettingEntry.Header("按键绑定"), + SettingEntry.Keys( + listOf( + "坦克前进" to "W", + "坦克后退" to "S", + "坦克左转" to "A", + "坦克右转" to "D", + "炮塔左转" to "←", + "炮塔右转" to "→", + "射击" to "空格", + "使用维修工具" to "1", + "使用护甲提升" to "2", + "使用伤害提升" to "3", + "使用速度提升" to "4", + "布设地雷" to "5", + "投掷手榴弹" to "G", + "终极技能" to "F", + "暂停" to "Esc", + "战绩统计" to "Tab", + "快速进入车库" to "L", + ), + ), + ) + 4 -> listOf( + SettingEntry.Header("账户信息"), + SettingEntry.Info("昵称", UserName.ifBlank { "未登录" }), + SettingEntry.Info("您的位置", "中国"), + SettingEntry.Info("绑定邮箱", "未绑定", "绑定邮箱后可以更安全地找回账号。"), + SettingEntry.Header("账户安全"), + SettingEntry.Action( + "更改密码和电子邮件", + "通过验证当前密码来更新登录邮箱和密码。", + "更改", + "更改密码和电子邮件将在账户协议接入后开放。", + ), + SettingEntry.Action( + "绑定邮箱", + "绑定您的邮箱以保护您的账号!", + "连接", + "邮箱绑定功能将在账户协议接入后开放。", + ), + SettingEntry.Action( + "更改昵称", + "首次更改昵称免费。", + "更改昵称", + "昵称修改功能将在账户协议接入后开放。", + ), + SettingEntry.Header("账号管理"), + SettingEntry.Action( + "账号删除", + "提交申请后,您将有 30 天时间取消此决定;超过该期限后,账号将无法恢复。", + "删除账号", + "账号删除功能将在账户协议接入后开放。", + ), + ) + 5 -> listOf( + SettingEntry.Header("界面"), + SettingEntry.Toggle("aimDot", "在屏幕中间显示一个点", "显示一个点状光标,以便于镜头定位", false), + SettingEntry.Toggle("showDamage", "显示伤害", "显示您对敌人造成的伤害生命值", true), + SettingEntry.Toggle("autoSuicide", "自动自毁", "如果您的坦克翻车或者侧翻,将会开启自动自毁。", true), + SettingEntry.Toggle("showStatusName", "状态效果文字提示", "显示状态效果的指示文字", true), + SettingEntry.Toggle("centeredLayout", "屏幕中央的战斗界面", "在屏幕底部的中央显示战斗界面的信息。", false), + SettingEntry.Toggle("showGoldBoxCreator", "显示谁投放了金箱子", "在掉落金箱子时显示投放者昵称", true), + SettingEntry.Toggle("showTankPointer", "显示伤害方向指示器", "受到伤害时显示指向伤害来源的指示器", true), + SettingEntry.Toggle("autoSupply", "战斗中自动激活道具", "在战斗中自动使用已装备的道具", false), + SettingEntry.Toggle("showButtonLabels", "按钮上显示快捷键", "显示战斗界面按钮所绑定的快捷键", true), + SettingEntry.Header("瞄准与缩放"), + SettingEntry.Slider("battleUiScale", "战斗画面尺寸", "调整战斗界面的大小", 100), + SettingEntry.Slider("aimOpacity", "镜头中心点不透明度", "调整镜头中心点的透明度", 80), + SettingEntry.Choice("aimScale", "镜头中心点大小", "调整镜头中心点的大小。", listOf("最小", "中等", "巨大", "特大", "最大"), 1), + ) + 6 -> listOf( + SettingEntry.Header("双重身份验证"), + SettingEntry.Info("状态", "未启用", "双重身份验证可提高您账号的安全性。"), + SettingEntry.Action( + "启用双重身份验证", + "完成 3 个简单步骤,让您的账号更安全:安装验证程序、扫描二维码、输入安全码。", + "启用", + "双重身份验证将在账户协议接入后开放。", + ), + SettingEntry.Header("隐私"), + SettingEntry.Toggle("hideProfile", "隐藏您的个人资料", "在公共应用编程接口中隐藏您的游戏统计信息和个人档案信息", false), + SettingEntry.Header("会话"), + SettingEntry.Info("登录记录", "—"), + SettingEntry.Info("会话过期时间", "7 天"), + SettingEntry.Action( + "退出所有设备", + "让所有已登录设备上的会话立即失效。", + "退出", + "会话管理将在账户协议接入后开放。", + ), + ) + else -> listOf( + SettingEntry.Header("客户端"), + SettingEntry.Info("客户端版本", "Kotlin/JS"), + SettingEntry.Info("协议版本", "S3"), + SettingEntry.Info("渲染模式", "WebGL"), + SettingEntry.Header("帮助与政策"), + SettingEntry.Action( + "技术支持", + "请使用另一台设备的摄像头扫描支持门户二维码,或在浏览器中打开支持网站。", + "打开支持网站", + "支持网站将在外部浏览器打开。", + ), + SettingEntry.Action( + "使用许可及服务协议", + "查看游戏的使用许可及服务协议。", + "打开", + "政策页面将在外部浏览器打开。", + ), + SettingEntry.Action( + "隐私政策", + "查看游戏的隐私政策。", + "打开", + "政策页面将在外部浏览器打开。", + ), + SettingEntry.Action( + "第三方共享个人信息清单", + "查看第三方共享个人信息清单。", + "打开", + "政策页面将在外部浏览器打开。", + ), + ) +} + +private val SettingsScreen = FC { + var Tab by useState(0) + var Toggles by useState>(emptyMap()) + var Sliders by useState>(emptyMap()) + var Choices by useState>(emptyMap()) + val UserName = StateOrNull()?.User?.Uid ?: "" + val Entries = SettingsEntries(Tab, UserName) + + fun ResetAll() { + Toggles = emptyMap() + Sliders = emptyMap() + Choices = emptyMap() + ToastContext.Show("已重置当前设置(离线模式)") + } + + ScreenShell("设置", SettingsTabs.getOrElse(Tab) { "" }) { + div { + css(Columns) + div { + css(SideNav) + for ((Index, Name) in SettingsTabs.withIndex()) { + NavItem(Name, null, Tab == Index) { Tab = Index } + } + } + div { + css(ContentPane) + div { + css(PageToolbar) + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { css(PaneTitle); +SettingsTabs.getOrElse(Tab) { "设置" } } + div { css(PaneDesc); +"修改后的设置会在下一次进入战斗时生效。" } + } + button { + css(SecondaryButton) + onClick = { ResetAll() } + +"全部重置" + } + } + + div { + css(Panel) + for (Entry in Entries) { + when (Entry) { + is SettingEntry.Header -> { + div { + css { + Style("padding", "0.55em 1em") + Style("background", "rgba(0, 12, 20, 0.45)") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + Style("color", Ui.Cyan) + Style("font", "500 0.82em/1.3 ${Ui.FontMedium}") + Style("text-transform", "uppercase") + Style("letter-spacing", "0.05em") + } + +Entry.Text + } + } + is SettingEntry.Toggle -> { + val On = Toggles[Entry.Id] ?: Entry.Default + SettingRow(Entry.Label, Entry.Desc) { + Toggle(On) { Toggles = Toggles + (Entry.Id to it) } + } + } + is SettingEntry.Slider -> { + val Value = Sliders[Entry.Id] ?: Entry.Default + SliderRow(Entry.Label, Entry.Desc, Value) { + Sliders = Sliders + (Entry.Id to it) + } + } + is SettingEntry.Choice -> { + val Index = Choices[Entry.Id] ?: Entry.Default + SelectRow(Entry.Label, Entry.Desc, Entry.Options, Index) { + Choices = Choices + (Entry.Id to it) + } + } + is SettingEntry.Action -> { + SettingRow(Entry.Label, Entry.Desc) { + button { + css(SecondaryButton) + onClick = { ToastContext.Show(Entry.Toast) } + +Entry.Button + } + } + } + is SettingEntry.Info -> { + SettingRow(Entry.Label, Entry.Desc) { + span { css(MutedText); +Entry.Value } + } + } + is SettingEntry.Keys -> { + div { + css { + Style("display", "grid") + Style("grid-template-columns", "1fr 1fr") + Style("gap", "0.1em 1.2em") + Style("padding", "0.6em 1em 0.9em") + } + for ((Action, Key) in Entry.Rows) { + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("padding", "0.35em 0") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + span { css(MutedText); +Action } + span { + css { + Style("min-width", "2.2em") + Style("padding", "0.2em 0.45em") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.25em") + Style("background", "rgba(0, 12, 20, 0.7)") + Style("text-align", "center") + Style("font", "500 0.8em/1.3 ${Ui.FontMedium}") + } + +Key + } + } + } + } + } + } + } + } + + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("gap", "0.6em") + Style("margin-top", "0.2em") + } + button { + css(SecondaryButton) + onClick = { ScreenContext.OnBack() } + +"返回 [Z]" + } + button { + css(SecondaryButton) + onClick = { ResetAll() } + +"全部重置" + } + } + } + } + } +} + +// ---------------------------------------------------------------- 聊天 + +private fun ChatChannelName(Key: String): String { + val Raw = Key.substringAfterLast('.').uppercase() + return when (Raw) { + "PUBLIC", "GENERAL", "GLOBAL", "ALL" -> "公共" + "BATTLE", "TEAM" -> "战斗" + "CLAN" -> "军团" + "DIRECT", "PRIVATE", "PM" -> "私聊" + else -> Raw.ifBlank { Key }.replace("_", " ") + } +} + +private fun ToObjectList(Value: Any?): List { + if (Value == null) return emptyList() + if (Value is List<*>) return Value + val Out = ArrayList() + try { + if (jsTypeOf(Value) == "object") { + val Length = (Value.asDynamic().length as? Int) ?: 0 + for (Index in 0 until Length) Out.add(Value.asDynamic()[Index]) + } + } catch (_: Throwable) { + } + return Out +} + +private fun ChildrenBuilder.ChatMessageRow(Message: Any?, Self: Boolean = false) { + if (Message == null) return + if (Message is String) { + div { + css { + Style("padding", "0.4em 0.7em") + Style("color", "#dbe8f4") + Style("font", "400 0.9em/1.45 ${Ui.FontRegular}") + } + +Message + } + return + } + val Author = Message.Str("sourceUser", Message.Str("uid", "玩家")) + val Text = Message.Str("text", Message.Str("message", "")) + val Seconds = Message.Num("timePassedInSec", 0) + val IsSystem = Message.Str("messageType", "").uppercase().contains("SYSTEM") + div { + css { + Style("display", "flex") + Style("gap", "0.6em") + Style("padding", "0.5em 0.7em") + Style("border-radius", "0.35em") + Style("background", if (Self) "rgba(118, 255, 51, 0.08)" else "transparent") + if (IsSystem) Style("background", "rgba(0, 212, 255, 0.06)") + } + UserAvatar(Author, "2em") + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + } + span { + css { + Style("color", if (Self) Ui.Accent else Ui.Cyan) + Style("font", "500 0.86em/1.3 ${Ui.FontMedium}") + } + +Author + } + if (Seconds > 0) span { css(MutedText); +AgoText(Seconds) } + } + div { + css { + Style("margin-top", "0.15em") + Style("color", if (IsSystem) Ui.Muted else "#dbe8f4") + Style("font", "400 0.9em/1.45 ${Ui.FontRegular}") + Style("word-break", "break-word") + } + +Text + } + } + } +} + +private val ChatScreen = FC { + var Selected by useState(null) + var Draft by useState("") + var Sent by useState>>(emptyList()) + val State = StateOrNull() + val Map = State?.ChatState?.MessagesByChannel ?: emptyMap() + val ClanMessages = State?.ClanChatState?.Messages ?: emptyList() + val HasClan = State?.Clan?.HasClan == true + + val Channels = ArrayList>>() + for ((Key, Value) in Map) { + val Name = Key?.toString() ?: "" + if (Name.isNotBlank()) Channels.add(Name to ToObjectList(Value)) + } + if (Channels.isEmpty()) { + Channels.add("PUBLIC" to emptyList()) + Channels.add("BATTLE" to emptyList()) + } + if (HasClan || ClanMessages.isNotEmpty()) { + if (Channels.none { it.first.equals("CLAN", true) }) { + Channels.add("CLAN" to ClanMessages) + } + } + val Channel = Channels.firstOrNull { it.first == Selected } ?: Channels.first() + val Messages = Channel.second + val SentHere = Sent.filter { it.first == Channel.first }.map { it.second } + + ScreenShell("聊天", "${Messages.size} 条消息") { + div { + css(Columns) + div { + css(SideNav) + for ((Key, List) in Channels) { + NavItem(ChatChannelName(Key), List.size, Key == Channel.first) { Selected = Key } + } + } + div { + css(ContentPane) + div { + css(PageToolbar) + div { + css { Style("flex", "1") } + div { css(PaneTitle); +"# ${ChatChannelName(Channel.first)}" } + div { css(PaneDesc); +"在这里与其他玩家交流。请遵守游戏规则,不要发布违规内容。" } + } + Pill("${Messages.size} 条", Ui.Cyan) + } + + div { + css { + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.15em") + Style("min-height", "18em") + Style("max-height", "58vh") + Style("overflow-y", "auto") + Style("padding", "0.4em") + Style("background", "rgba(0, 12, 20, 0.42)") + Style("border", "1px solid ${Ui.DividerSoft}") + Style("border-radius", "0.5em") + } + if (Messages.isEmpty() && SentHere.isEmpty()) { + div { + css { + Style("padding", "3em 1em") + Style("text-align", "center") + Style("color", Ui.Muted) + } + +"该频道暂无消息。" + } + } else { + for (Message in Messages.takeLast(120)) { + ChatMessageRow(Message) + } + for (Line in SentHere) { + ChatMessageRow( + js("({ sourceUser: '我', text: Line, timePassedInSec: 0 })"), + Self = true, + ) + } + } + } + + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + } + input { + css(SearchField) + placeholder = "在这里写下您的信息" + value = Draft + onChange = { E -> Draft = E.target.value } + } + button { + css(PrimaryButton) + onClick = { + if (Draft.isNotBlank()) { + Sent = Sent + (Channel.first to Draft) + Draft = "" + } + } + img { + src = Asset("static/images/sendMessage.395c6c96.svg") + css { Style("width", "1.1em"); Style("height", "1.1em") } + } + +"发送" + } + } + } + } + } +} + +// ---------------------------------------------------------------- 新闻 + +private fun NewsImage(Url: String): String { + if (Url.isBlank()) return Asset("static/images/background.fc2779c1.webp") + return if (Url.startsWith("http") || Url.startsWith("/") || Url.startsWith("data:")) { + Url + } else { + Asset(Url) + } +} + +private fun DemoNews(): List { + val Now = (js("Math.floor(Date.now() / 1000)") as Number).toInt() + return listOf( + NewsItemData( + dateInSeconds = Now, + description = "本地中继已连接。你可以在车库中查看已装配的坦克,浏览商店、任务、军团和好友界面。\n\n" + + "离线模式下只演示界面结构与交互流程,真实数据会在连接服务器后自动替换。", + endDate = 0, + header = "欢迎来到 3D 坦克离线客户端", + id = 1L, + imageUrl = "static/images/background.fc2779c1.webp", + ), + NewsItemData( + dateInSeconds = Now - 86400 * 2, + description = "参与战斗赢取星星,提升挑战等级并解锁专属奖励。\n\n" + + "购买星级挑战奖励加成后,还可以获得额外的黄金奖励轨道。", + endDate = 0, + header = "星级挑战新赛季开启", + id = 2L, + imageUrl = "static/images/battle_main_pick_match.f9aabe76.webp", + ), + NewsItemData( + dateInSeconds = Now - 86400 * 5, + description = "标准、精良、史诗与传奇礼盒现已加入新的奖励池。\n\n" + + "使用对应钥匙开启礼盒,有机会获得水晶、红宝石、迷彩与稀有装备。", + endDate = 0, + header = "幸运礼盒内容更新", + id = 3L, + imageUrl = "static/images/standard_container.44c6d5f0.png", + ), + ) +} + +private val NewsScreen = FC { + var SelectedId by useState(null) + val State = StateOrNull() + val RealNews = State?.News?.News ?: emptyList() + val IsDemo = RealNews.isEmpty() + val News = if (IsDemo) DemoNews() else RealNews + val Unread = State?.News?.UnreadNewsCounter ?: 0 + val Current = News.firstOrNull { it.id == SelectedId } ?: News.firstOrNull() + + ScreenShell("新闻", if (Unread > 0) "$Unread 条未读" else "${News.size} 条") { + if (News.isEmpty()) { + EmptyState("暂无新闻。") + } else { + if (IsDemo) { + div { + css(PageToolbar) + Pill("离线示例", Ui.Muted) + span { css(MutedText); +"以下为离线预览内容,连接服务器后会显示真实新闻。" } + } + } + div { + css(Columns) + div { + css { + Style("width", "24em") + Style("flex", "0 0 24em") + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.45em") + Style("max-height", "64vh") + Style("overflow-y", "auto") + Style("padding-right", "0.2em") + } + for (Item in News) { + button { + val CardBase = CardStyle(Item.id == Current?.id) + css { + CardBase(this) + Style("display", "flex") + Style("gap", "0.6em") + Style("padding", "0.6em") + } + onClick = { SelectedId = Item.id } + div { + css { + Style("width", "4.6em") + Style("height", "3.4em") + Style("flex", "0 0 4.6em") + Style("border-radius", "0.3em") + Style("background", "url(\"${NewsImage(Item.imageUrl)}\") center / contain no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.75)") + } + } + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { + css { + Style("font", "500 0.9em/1.3 ${Ui.FontMedium}") + Style("overflow", "hidden") + Style("display", "-webkit-box") + Style("-webkit-line-clamp", "2") + Style("-webkit-box-orient", "vertical") + } + +Item.header + } + div { css(MutedText); +FormatDate(Item.dateInSeconds) } + } + } + } + } + + div { + css(ContentPane) + if (Current == null) { + EmptyState("选择一条新闻查看详情。") + } else { + div { + css { + Apply(Panel) + Style("overflow", "hidden") + Style("padding", "0") + } + div { + css { + Style("height", "15em") + Style("background", "url(\"${NewsImage(Current.imageUrl)}\") center / cover no-repeat") + Style("background-color", "rgba(0, 12, 20, 0.7)") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + } + div { + css { Style("padding", "1.1em 1.3em 1.4em") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.6em") + Style("color", Ui.Muted) + Style("font", "400 0.82em/1.3 ${Ui.FontRegular}") + } + +FormatDate(Current.dateInSeconds) + if (IsDemo) Pill("离线示例", Ui.Muted) else if (Unread > 0) Pill("新闻", Ui.Cyan) + } + h2 { + css { + Style("margin", "0.35em 0 0") + Style("font", "500 1.5em/1.3 ${Ui.FontMedium}") + } + +Current.header + } + div { + css { + Style("margin-top", "1em") + Style("color", "#dbe8f4") + Style("font", "400 0.95em/1.65 ${Ui.FontRegular}") + Style("white-space", "pre-wrap") + } + +Current.description + } + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- 军团 + +private val ClanTabs = listOf("信息", "成员", "请求", "邀请") + +private fun ClanRoleName(Permission: Int, IsFounder: Boolean): String = when { + IsFounder -> "最高指挥官" + Permission == 1 -> "最高指挥官" + Permission == 2 -> "指挥官" + Permission == 3 -> "军官" + Permission == 4 -> "上士" + Permission == 5 -> "老兵" + Permission == 6 -> "二等兵" + Permission == 7 -> "新兵" + else -> "成员" +} + +private fun ChildrenBuilder.ClanHero(State: RootState?) { + val Clan = State?.Clan + val Members = Clan?.ClanAcceptedMembers ?: emptySet() + div { + css { + Apply(Panel) + Style("position", "relative") + Style("display", "flex") + Style("align-items", "center") + Style("gap", "1.2em") + Style("padding", "1.2em") + Style("overflow", "hidden") + Style("background", + "linear-gradient(90deg, rgba(0, 12, 20, 0.94) 30%, rgba(0, 12, 20, 0.45)), " + + "url(\"${Asset("static/images/Clan_image_zh.e0f14e78.webp")}\") center / cover no-repeat") + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("width", "5em") + Style("height", "5em") + Style("flex", "0 0 5em") + Style("border-radius", "0.6em") + Style("background", "rgba(0, 12, 20, 0.75)") + Style("border", "1px solid rgba(191, 213, 255, 0.35)") + Style("box-shadow", "inset 0 0 1.2em rgba(0, 212, 255, 0.18)") + Style("font", "700 1.3em/1 ${Ui.FontBold}") + Style("color", Ui.Cyan) + } + +(Clan.Str("Tag", "军团")) + } + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { + css { + Style("display", "flex") + Style("align-items", "baseline") + Style("gap", "0.6em") + Style("flex-wrap", "wrap") + } + span { css { Style("font", "500 1.6em/1.2 ${Ui.FontMedium}") }; +Clan.Str("Name", "军团") } + span { css { Style("color", Ui.Muted); Style("font-size", "0.9em") }; +"[${Clan.Str("Tag", "")}]" } + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "1.2em") + Style("margin-top", "0.45em") + Style("color", Ui.Muted) + Style("font", "400 0.85em/1.3 ${Ui.FontRegular}") + Style("flex-wrap", "wrap") + } + span { +"创始人:${UserNameOf(Clan?.CreatorId)}" } + span { +"成员:${Members.size} / 50" } + span { +"创建:${FormatDate(Clan.Num("CreateTime", 0))}" } + } + } + div { + css { + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.4em") + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("编辑军团档案(离线模式)") } + +"编辑档案" + } + button { + css(SecondaryButton) + onClick = { + DialogContext.Show( + "离开军团?", + "确定要离开当前军团吗?离开后 24 小时内不能加入另一个军团。", + "离开", + "取消", + OnConfirm = { ToastContext.Show("已离开军团(离线模式)") }, + ) + } + +"离开军团" + } + } + } +} + +private fun ChildrenBuilder.ClanMembersTable(State: RootState?) { + val Clan = State?.Clan + val Members = (Clan?.ClanAcceptedMembers ?: emptySet()).toList() + val Stats = Clan?.ClanMembersStatistics ?: emptyMap() + val CreatorId = Clan?.CreatorId + val Columns = "1.9fr 6em 6em 4em 4em 7em 5.5em" + + div { + css(Panel) + TableHeader( + listOf("名称", "职务", "经验值", "击杀", "死亡", "上次访问", "操作"), + Columns, + ) + for (Member in Members.take(100)) { + val Stat = Stats[Member] + val Permission = Stat.Num("permission", Stat.Num("Permission", 0)) + val IsFounder = Member == CreatorId + TableRow(Columns) { + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.5em") + Style("min-width", "0") + } + UserAvatar(UserNameOf(Member), "1.9em") + div { + css { Style("min-width", "0") } + div { + css { + Style("overflow", "hidden") + Style("text-overflow", "ellipsis") + Style("white-space", "nowrap") + Style("font", "500 0.9em/1.3 ${Ui.FontMedium}") + } + +UserNameOf(Member) + } + OnlineState(UserOnlineOf(Member)) + } + } + span { css(MutedText); +ClanRoleName(Permission, IsFounder) } + span { css(MutedText); +Stat.Str("experience", Stat.Str("Experience", "—")) } + span { +Stat.Str("kills", Stat.Str("Kills", "0")) } + span { +Stat.Str("deaths", Stat.Str("Deaths", "0")) } + span { css(MutedText); +Stat.Str("lastVisit", Stat.Str("LastVisit", "—")) } + div { + css { Style("display", "flex"); Style("gap", "0.3em") } + if (!IsFounder) { + button { + css { + Style("padding", "0.25em 0.45em") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.25em") + Style("background", "transparent") + Style("color", "#cfe0f0") + Style("font", "400 0.75em/1.2 ${Ui.FontRegular}") + Style("cursor", "pointer") + } + onClick = { + DialogContext.Show( + "移出军团", + "确定要把 ${UserNameOf(Member)} 移出军团吗?", + "移除", + "取消", + OnConfirm = { ToastContext.Show("已移出 ${UserNameOf(Member)}(离线模式)") }, + ) + } + +"移出" + } + } + } + } + } + if (Members.isEmpty()) { + div { css { Style("padding", "2em"); Style("text-align", "center"); Style("color", Ui.Muted) }; +"暂无成员数据。" } + } + } +} + +private fun ChildrenBuilder.ClanRequests(State: RootState?) { + val Clan = State?.Clan + val Incoming = (Clan?.IncomingUsers ?: emptySet()).toList() + val Outgoing = (Clan?.OutgoingUsers ?: emptySet()).toList() + val Columns = "1.9fr 8em 9em" + + SectionTitle("加入申请") + div { + css(Panel) + TableHeader(listOf("玩家", "状态", "操作"), Columns) + if (Incoming.isEmpty()) { + div { css { Style("padding", "1.4em"); Style("text-align", "center"); Style("color", Ui.Muted) }; +"没有待处理的加入申请。" } + } else { + for (User in Incoming) { + TableRow(Columns) { + div { + css { Style("display", "flex"); Style("align-items", "center"); Style("gap", "0.5em") } + UserAvatar(UserNameOf(User), "1.9em") + span { +UserNameOf(User) } + } + span { css(MutedText); +"申请加入" } + div { + css { Style("display", "flex"); Style("gap", "0.35em") } + button { + css(PrimaryButton) + onClick = { ToastContext.Show("已接受 ${UserNameOf(User)} 的申请(离线模式)") } + +"接受" + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("已拒绝 ${UserNameOf(User)} 的申请(离线模式)") } + +"拒绝" + } + } + } + } + } + } + + SectionTitle("已发送邀请") + div { + css(Panel) + TableHeader(listOf("玩家", "状态", "操作"), Columns) + if (Outgoing.isEmpty()) { + div { css { Style("padding", "1.4em"); Style("text-align", "center"); Style("color", Ui.Muted) }; +"没有已发送的邀请。" } + } else { + for (User in Outgoing) { + TableRow(Columns) { + div { + css { Style("display", "flex"); Style("align-items", "center"); Style("gap", "0.5em") } + UserAvatar(UserNameOf(User), "1.9em") + span { +UserNameOf(User) } + } + span { css(MutedText); +"等待回应" } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("已取消对 ${UserNameOf(User)} 的邀请(离线模式)") } + +"取消" + } + } + } + } + } +} + +private fun ChildrenBuilder.ClanInvitations(State: RootState?) { + val Clan = State?.Clan + val Incoming = (Clan?.IncomingClans ?: emptySet()).toList() + val Outgoing = (Clan?.OutgoingClans ?: emptySet()).toList() + val Columns = "1.9fr 7em 9em" + + SectionTitle("收到的邀请") + div { + css(Panel) + TableHeader(listOf("军团", "标记", "操作"), Columns) + if (Incoming.isEmpty()) { + div { css { Style("padding", "1.4em"); Style("text-align", "center"); Style("color", Ui.Muted) }; +"没有收到军团邀请。" } + } else { + for (ClanId in Incoming) { + TableRow(Columns) { + span { +ClanNameOf(ClanId) } + span { css(MutedText); +"[${ClanTagOf(ClanId)}]" } + div { + css { Style("display", "flex"); Style("gap", "0.35em") } + button { + css(PrimaryButton) + onClick = { ToastContext.Show("已接受 ${ClanNameOf(ClanId)} 的邀请(离线模式)") } + +"接受" + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("已拒绝 ${ClanNameOf(ClanId)} 的邀请(离线模式)") } + +"拒绝" + } + } + } + } + } + } + + SectionTitle("已申请的军团") + div { + css(Panel) + TableHeader(listOf("军团", "标记", "操作"), Columns) + if (Outgoing.isEmpty()) { + div { css { Style("padding", "1.4em"); Style("text-align", "center"); Style("color", Ui.Muted) }; +"没有已申请的军团。" } + } else { + for (ClanId in Outgoing) { + TableRow(Columns) { + span { +ClanNameOf(ClanId) } + span { css(MutedText); +"[${ClanTagOf(ClanId)}]" } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("已取消对 ${ClanNameOf(ClanId)} 的申请(离线模式)") } + +"取消申请" + } + } + } + } + } +} + +private val ClansScreen = FC { + var Tab by useState(0) + val State = StateOrNull() + val Clan = State?.Clan + val HasClan = Clan?.HasClan == true + val Members = Clan?.ClanAcceptedMembers ?: emptySet() + val Requests = (Clan?.IncomingUsers?.size ?: 0) + (Clan?.OutgoingUsers?.size ?: 0) + val Invitations = (Clan?.IncomingClans?.size ?: 0) + (Clan?.OutgoingClans?.size ?: 0) + + ScreenShell("军团", if (HasClan) "[${Clan.Str("Tag", "")}] ${Members.size} 名成员" else "尚未加入军团") { + if (!HasClan) { + div { + css { + Apply(Panel) + Style("position", "relative") + Style("display", "flex") + Style("flex-direction", "column") + Style("align-items", "center") + Style("gap", "0.6em") + Style("padding", "2.4em 2em") + Style("text-align", "center") + Style("overflow", "hidden") + Style("background", + "linear-gradient(180deg, rgba(0, 12, 20, 0.72), rgba(0, 12, 20, 0.94)), " + + "url(\"${Asset("static/images/Clan_image_zh.e0f14e78.webp")}\") center / cover no-repeat") + } + div { css { Style("font", "500 1.8em/1.2 ${Ui.FontMedium}") }; +"创建你自己的军团" } + div { + css { + Style("max-width", "34em") + Style("color", Ui.Muted) + Style("font", "400 0.95em/1.6 ${Ui.FontRegular}") + } + +"在这里您可以创建自己的军团,与好友组队并获得特殊军团奖励。随着军团规模扩大,成员的奖励会越来越多。" + } + div { + css { Style("display", "flex"); Style("gap", "0.6em"); Style("margin-top", "0.6em") } + button { + css(PrimaryButton) + onClick = { + DialogContext.Show( + "创建军团", + "创建军团需要军团许可证。离线模式下只演示创建流程。", + "创建", + "取消", + OnConfirm = { ToastContext.Show("已提交创建军团(离线模式)") }, + ) + } + +"创建军团" + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("军团搜索将在社交链路补齐后开放。") } + +"搜索军团" + } + } + } + + div { + css { + Style("display", "grid") + Style("grid-template-columns", "1fr 1fr") + Style("gap", "0.9em") + Style("margin-top", "0.9em") + } + div { + css { + Apply(Panel) + Style("padding", "1.1em") } + div { css(SettingLabel); +"军团许可证" } + div { css(SettingDesc); +"要创建自己的军团,您必须购买军团许可证。" } + div { + css { Style("display", "flex"); Style("align-items", "center"); Style("gap", "0.5em"); Style("margin-top", "0.8em") } + if (Clan?.HasLicense == true) { + Pill("已拥有", Ui.Accent) + } else { + Pill("未拥有", Ui.Ruby) + } + button { + css(SecondaryButton) + onClick = { ToastContext.Show("购买军团许可证(离线模式)") } + +"购买军团许可证" + } + } + } + div { + css { + Apply(Panel) + Style("padding", "1.1em") } + div { css(SettingLabel); +"加入军团" } + div { css(SettingDesc); +"通过军团名称或军团标记搜索军团,发送加入请求。" } + div { + css { Style("margin-top", "0.8em") } + SearchBox("", "军团名称或 [标记") { } + } + } + } + } else { + ClanHero(State) + + div { + css(TabBar) + for ((Index, Name) in ClanTabs.withIndex()) { + val Count = when (Index) { + 1 -> Members.size + 2 -> Requests + 3 -> Invitations + else -> null + } + button { + css(TabStyle(Tab == Index)) + onClick = { Tab = Index } + +Name + if (Count != null && Count > 0) { + span { + css { + Style("margin-left", "0.4em") + Style("padding", "0.1em 0.35em") + Style("border-radius", "999px") + Style("background", "rgba(191, 213, 255, 0.14)") + Style("font-size", "0.75em") + } + +"$Count" + } + } + } + } + } + + when (Tab) { + 0 -> { + div { + css { + Style("display", "grid") + Style("grid-template-columns", "1.4fr 1fr") + Style("gap", "0.9em") + Style("align-items", "start") + } + div { + css { + Apply(Panel) + Style("padding", "1.1em") } + div { css(SettingLabel); +"军团信息" } + div { + css { + Style("margin-top", "0.5em") + Style("color", "#dbe8f4") + Style("font", "400 0.92em/1.6 ${Ui.FontRegular}") + Style("white-space", "pre-wrap") + } + +Clan.Str("Description", "暂无军团简介。") + } + } + div { + css { + Apply(Panel) + Style("padding", "0.2em 1em") } + StatRow("军团创始人", UserNameOf(Clan?.CreatorId)) + StatRow("创建时间", FormatDate(Clan.Num("CreateTime", 0))) + StatRow("军团标记", Clan.Str("Tag", "—")) + StatRow("玩家", "${Members.size} / 50") + StatRow("加入申请", "${Clan?.IncomingUsers?.size ?: 0}") + StatRow("已发邀请", "${Clan?.OutgoingUsers?.size ?: 0}") + } + } + } + 1 -> ClanMembersTable(State) + 2 -> ClanRequests(State) + else -> ClanInvitations(State) + } + } + } +} + +// ---------------------------------------------------------------- 好友 + +private fun ChildrenBuilder.MiniButton(Label: String, Accent: Boolean = false, OnClick: () -> Unit) { + button { + css { + Style("padding", "0.32em 0.6em") + Style("border", if (Accent) "0" else "1px solid ${Ui.Divider}") + Style("border-radius", "0.3em") + Style("background", if (Accent) Ui.Cyan else "rgba(191, 213, 255, 0.08)") + Style("color", if (Accent) "#001722" else Ui.Text) + Style("font", "500 0.78em/1.2 ${Ui.FontMedium}") + Style("cursor", "pointer") + Style("white-space", "nowrap") + } + onClick = { OnClick() } + +Label + } +} + +private fun ChildrenBuilder.FriendRow( + Name: String, + Rank: String, + Online: Boolean, + ClanTag: String, + Actions: ChildrenBuilder.() -> Unit, +) { + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.7em") + Style("padding", "0.55em 0.8em") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + } + UserAvatar(Name, "2.4em") + div { + css { Style("flex", "1"); Style("min-width", "0") } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.45em") + } + span { css { Style("font", "500 0.92em/1.3 ${Ui.FontMedium}") }; +Name } + if (ClanTag.isNotBlank()) Pill("[$ClanTag]", Ui.Cyan) + } + div { + css { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "0.8em") + Style("margin-top", "0.15em") + } + span { css(MutedText); +"等级 $Rank" } + OnlineState(Online) + } + } + div { + css { Style("display", "flex"); Style("align-items", "center"); Style("gap", "0.35em") } + Actions() + } + } +} + +private data class DemoFriend( + val Name: String, + val Rank: String, + val Online: Boolean, + val ClanTag: String, +) + +private val DemoFriends = listOf( + DemoFriend("天际猎手", "少校", true, "3DT"), + DemoFriend("铁狼", "上尉", true, ""), + DemoFriend("新星", "中尉", false, "TANK"), +) + +private val DemoRequests = listOf( + DemoFriend("烈焰", "上士", true, ""), + DemoFriend("霜刃", "中士", false, "ICE"), +) + +private val FriendsScreen = FC { + var Tab by useState(0) + var Query by useState("") + val State = StateOrNull() + val Friends = State?.Friends + val Accepted = (Friends?.Accepted ?: emptySet()).toList() + val Incoming = (Friends?.Incoming ?: emptySet()).toList() + val Known = State?.RemoteUserDataStorage?.UserIdToData ?: emptyMap() + val IsDemo = Accepted.isEmpty() && Incoming.isEmpty() && Known.isEmpty() + val FriendCount = if (IsDemo) DemoFriends.size else Accepted.size + val RequestCount = if (IsDemo) DemoRequests.size else Incoming.size + val Filter: (Any?) -> Boolean = { Id -> + Query.isBlank() || UserNameOf(Id).contains(Query, ignoreCase = true) + } + val ShownFriends = Accepted.filter(Filter) + val ShownRequests = Incoming.filter(Filter) + val SearchResults = if (Tab != 2 || Query.isBlank()) { + emptyList() + } else { + Known.entries + .filter { (Id, _) -> + UserNameOf(Id).contains(Query, ignoreCase = true) && + Id !in Accepted && Id !in Incoming + } + .map { it.key } + .take(50) + } + + ScreenShell("好友", if (IsDemo) "离线示例" else "$FriendCount 位好友 · $RequestCount 个请求") { + div { + css(TabBar) + for ((Index, Name) in listOf("好友", "请求", "搜索").withIndex()) { + val Count = when (Index) { + 0 -> FriendCount + 1 -> RequestCount + else -> null + } + button { + css(TabStyle(Tab == Index)) + onClick = { Tab = Index } + +Name + if (Count != null && Count > 0) { + span { + css { + Style("margin-left", "0.4em") + Style("padding", "0.1em 0.35em") + Style("border-radius", "999px") + Style("background", "rgba(191, 213, 255, 0.14)") + Style("font-size", "0.75em") + } + +"$Count" + } + } + } + } + } + + div { + css(PageToolbar) + div { + css { Style("flex", "1") } + SearchBox( + Query, + if (Tab == 2) "输入一个昵称" else "搜索", + ) { Query = it } + } + if (IsDemo) Pill("离线示例", Ui.Muted) + if (Tab == 1 && (Incoming.isNotEmpty() || IsDemo)) { + button { + css(SecondaryButton) + onClick = { + DialogContext.Show( + "拒绝所有请求", + "确定要拒绝所有好友请求吗?", + "拒绝所有", + "取消", + OnConfirm = { ToastContext.Show("已拒绝所有好友请求(离线模式)") }, + ) + } + +"拒绝所有" + } + } + } + + when (Tab) { + 0 -> { + div { + css(Panel) + if (IsDemo) { + for (Friend in DemoFriends) { + FriendRow(Friend.Name, Friend.Rank, Friend.Online, Friend.ClanTag) { + MiniButton("私聊") { ScreenContext.OnNavigate("chat") } + MiniButton("邀请") { ToastContext.Show("已邀请 ${Friend.Name} 加入小组(离线模式)") } + MiniButton("删除") { + DialogContext.Show( + "从好友列表中删除", + "从您的好友列表删除 ${Friend.Name}?", + "移除", + "否", + OnConfirm = { ToastContext.Show("已删除好友 ${Friend.Name}(离线模式)") }, + ) + } + } + } + } else if (ShownFriends.isEmpty()) { + div { + css { Style("padding", "2.4em"); Style("text-align", "center"); Style("color", Ui.Muted) } + +if (Accepted.isEmpty()) "还没有好友。切换到搜索页添加好友。" else "没有匹配的好友。" + } + } else { + for (UserId in ShownFriends) { + FriendRow( + UserNameOf(UserId), + UserRankOf(UserId), + UserOnlineOf(UserId), + UserClanTagOf(UserId), + ) { + MiniButton("私聊") { ScreenContext.OnNavigate("chat") } + MiniButton("邀请") { ToastContext.Show("已邀请 ${UserNameOf(UserId)} 加入小组(离线模式)") } + MiniButton("删除") { + DialogContext.Show( + "从好友列表中删除", + "从您的好友列表删除 ${UserNameOf(UserId)}?", + "移除", + "否", + OnConfirm = { ToastContext.Show("已删除好友 ${UserNameOf(UserId)}(离线模式)") }, + ) + } + } + } + } + } + } + 1 -> { + div { + css(Panel) + if (IsDemo) { + for (Friend in DemoRequests) { + FriendRow(Friend.Name, Friend.Rank, Friend.Online, Friend.ClanTag) { + MiniButton("接受", Accent = true) { + ToastContext.Show("已接受 ${Friend.Name} 的好友请求(离线模式)") + } + MiniButton("拒绝") { + ToastContext.Show("已拒绝 ${Friend.Name} 的好友请求(离线模式)") + } + } + } + } else if (ShownRequests.isEmpty()) { + div { + css { Style("padding", "2.4em"); Style("text-align", "center"); Style("color", Ui.Muted) } + +if (Incoming.isEmpty()) "没有待处理的请求。" else "没有匹配的请求。" + } + } else { + for (UserId in ShownRequests) { + FriendRow( + UserNameOf(UserId), + UserRankOf(UserId), + UserOnlineOf(UserId), + UserClanTagOf(UserId), + ) { + MiniButton("接受", Accent = true) { + ToastContext.Show("已接受 ${UserNameOf(UserId)} 的好友请求(离线模式)") + } + MiniButton("拒绝") { + ToastContext.Show("已拒绝 ${UserNameOf(UserId)} 的好友请求(离线模式)") + } + } + } + } + } + } + else -> { + div { + css(Panel) + if (Query.isBlank()) { + div { + css { Style("padding", "2.4em"); Style("text-align", "center"); Style("color", Ui.Muted) } + +"输入昵称以搜索玩家。" + } + } else if (SearchResults.isEmpty()) { + div { + css { Style("padding", "2.4em"); Style("text-align", "center"); Style("color", Ui.Muted) } + +"没有找到匹配的玩家。" + } + } else { + for (UserId in SearchResults) { + FriendRow( + UserNameOf(UserId), + UserRankOf(UserId), + UserOnlineOf(UserId), + UserClanTagOf(UserId), + ) { + MiniButton("添加好友", Accent = true) { + ToastContext.Show("好友请求已发送给 ${UserNameOf(UserId)}(离线模式)") + } + } + } + } + } + } + } + } +} + +// ---------------------------------------------------------------- 战斗结算 + +private val BattleResultScreen = FC { + var Tab by useState(0) + val State = StateOrNull() + val Result = State?.BattleResult + val Rewards = Result?.Rewards ?: emptyList() + val Users = State?.BattleUsers + val UpdatedQuests = State?.MainQuest?.LastBattleUpdatedQuests ?: emptyList() + + ScreenShell("战斗结算") { + div { + css { + Apply(Panel) + Style("padding", "1.2em") + Style("text-align", "center") + } + h2 { css(ScreenTitle); +"战斗结束" } + p { css(MutedText); +"本场战斗已完成。" } + } + + div { + css(TabBar) + for ((Index, Name) in listOf("统计", "奖励", "任务").withIndex()) { + button { + css(TabStyle(Tab == Index)) + onClick = { Tab = Index } + +Name + } + } + } + + when (Tab) { + 0 -> { + val Online = Users?.OnlineUsers ?: emptySet() + if (Online.isEmpty()) { + EmptyState("暂无战斗统计。") + } else { + div { + css(Panel) + div { + css { + Style("display", "grid") + Style("grid-template-columns", "1fr 4em 4em 4em") + Style("gap", "0.5em") + Style("padding", "0.6em 0.8em") + Style("border-bottom", "1px solid ${Ui.Divider}") + Style("font", "500 0.9em/1 ${Ui.FontMedium}") + } + span { +"玩家" } + span { +"等级" } + span { +"击杀" } + span { +"分数" } + } + for (UserId in Online.take(40)) { + val Stat = Users?.Stats?.get(UserId) + div { + css { + Style("display", "grid") + Style("grid-template-columns", "1fr 4em 4em 4em") + Style("gap", "0.5em") + Style("padding", "0.45em 0.8em") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") + Style("font-size", "0.9em") + } + span { +Users?.Uids?.get(UserId).Str("uid", "玩家") } + span { +Users?.Ranks?.get(UserId).Str("rank", "1") } + span { +Stat.Num("kills", 0).toString() } + span { +Stat.Num("score", 0).toString() } + } + } + } + } + } + 1 -> { + if (Rewards.isEmpty()) { + EmptyState("本场没有奖励记录。") + } else { + div { + css(Grid) + for (Reward in Rewards.take(60)) { + div { + css(CardStyle()) + div { css(CardTitle); +Reward.Str("name", Reward.Str("itemName", "奖励")) } + div { css(CardText); +"× ${Reward.Num("count", Reward.Num("amount", 1))}" } + div { css(CardText); +"稀有度 ${Reward.Num("rarity", 0)}" } + } + } + } + } + } + else -> { + if (UpdatedQuests.isEmpty()) { + EmptyState("本场没有任务进度更新。") + } else { + div { + css { + Style("display", "flex") + Style("flex-direction", "column") + Style("gap", "0.6em") + } + for (Quest in UpdatedQuests.take(40)) { + div { + css(CardStyle()) + div { css(CardTitle); +Quest.Str("name", Quest.Str("title", "任务")) } + div { css(CardText); +"进度 ${Quest.Num("progress", 0)}/${Quest.Num("target", 1)}" } + } + } + } + } + } + } + + SectionTitle("经验") + div { + css { + Apply(Panel) + Style("padding", "0.2em 1em") } + StatRow("基础经验", Result?.InitialCurrentUserXP.Str("value", "—")) + StatRow("加成奖励", "+${Result?.IncreasedReward ?: 0}") + StatRow("简单奖励", "+${Result?.SimpleReward ?: 0}") + } + + div { + css { + Style("display", "flex") + Style("justify-content", "center") + Style("margin-top", "1.2em") + } + button { + css(PrimaryButton) + onClick = { ScreenContext.OnBack() } + +"返回大厅" + } + } + } +} + +// ---------------------------------------------------------------- 路由 + +private val FallbackScreen = FC { + ScreenShell("页面") { + EmptyState("该页面尚未接入。") + } +} + +val LobbyScreen = FC { + when (ScreenContext.Screen) { + "shop" -> ShopScreen() + "containers" -> ContainersScreen() + "battles" -> BattlesScreen() + "quests" -> QuestsScreen() + "settings" -> SettingsScreen() + "chat" -> ChatScreen() + "news" -> NewsScreen() + "clans" -> ClansScreen() + "friends" -> FriendsScreen() + "battle-result" -> BattleResultScreen() + else -> FallbackScreen() + } +} + +object ScreenMount { + private var Root: dynamic = null + + fun Mount() { + val El = document.getElementById("screen-root") as? HTMLDivElement ?: return + if (Root == null) Root = createRoot(El) + Render() + } + + fun Render() { + Root?.render(LobbyScreen.create()) + } + + fun Unmount() { + try { + Root?.unmount() + } catch (_: Throwable) { + } + Root = null + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/UiTheme.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/UiTheme.kt new file mode 100644 index 0000000..bbfe07c --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Ui/UiTheme.kt @@ -0,0 +1,233 @@ +// UiTheme.kt —— 全站 UI 的 Emotion token 与通用样式 +// +// 目标:把原版 module_82347 的尺寸/颜色/层级抽成可读 token, +// 供 LobbyScreens / GarageScreen / HeadScreen 共用。 +package App.Browser.Ui + +import csstype.PropertiesBuilder +import react.dom.html.HTMLAttributes + +internal object Ui { + // 颜色 + const val Bg = "#001926" + const val BgDeep = "#000b12" + const val Panel = "rgba(0, 18, 28, 0.82)" + const val PanelSoft = "rgba(0, 18, 28, 0.55)" + const val Divider = "rgba(255, 255, 255, 0.25)" + const val DividerSoft = "rgba(191, 213, 255, 0.12)" + const val Text = "#ffffff" + const val Muted = "rgba(255, 255, 255, 0.55)" + const val Accent = "#76ff33" + const val Cyan = "#00d4ff" + const val Ruby = "#ff4a6a" + const val Crystal = "#7fd4ff" + const val Danger = "#ff5a5a" + + // 字体 + const val FontRegular = "BaseFontRegular, \"Segoe UI\", sans-serif" + const val FontMedium = "BaseFontMedium, \"Segoe UI\", sans-serif" + const val FontBold = "BaseFontBold, \"Segoe UI\", sans-serif" +} + +/** 在 Emotion 的 PropertiesBuilder 里直接写 CSS 字符串,避免被 csstype 类型卡住。 */ +internal fun PropertiesBuilder.Style(Name: String, Value: String) { + asDynamic()[Name] = Value +} + +/** 把已有的样式块合并进当前 css lambda,避免连续两次 css(...) 互相覆盖。 */ +internal fun PropertiesBuilder.Apply(Base: PropertiesBuilder.() -> Unit) { + Base(this) +} + +/** 稳定标记,便于后续 DOM/尺寸断言。 */ +internal fun HTMLAttributes<*>.Ui(Name: String) { + asDynamic()["data-ui"] = Name +} + +internal val ScreenRoot: PropertiesBuilder.() -> Unit = { + Style("position", "absolute") + Style("inset", "0") + Style("display", "flex") + Style("flex-direction", "column") + Style("min-height", "0") + Style("background", "linear-gradient(180deg, rgba(0, 12, 20, 0.86), rgba(0, 8, 14, 0.94))") + Style("color", Ui.Text) + Style("font-family", Ui.FontRegular) + Style("overflow", "hidden") +} + +internal val ScreenHeader: PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("gap", "1em") + Style("height", "4em") + Style("flex", "0 0 4em") + Style("padding", "0 2em") + Style("box-sizing", "border-box") + Style("border-bottom", "1px solid ${Ui.Divider}") + Style("background", "rgba(0, 16, 24, 0.72)") +} + +internal val ScreenTitle: PropertiesBuilder.() -> Unit = { + Style("font", "500 1.5em/1.2 ${Ui.FontMedium}") + Style("margin", "0") +} + +internal val ScreenSubtitle: PropertiesBuilder.() -> Unit = { + Style("font", "400 0.95em/1.3 ${Ui.FontRegular}") + Style("color", Ui.Muted) + Style("margin-left", "0.5em") +} + +internal val ScreenBody: PropertiesBuilder.() -> Unit = { + Style("flex", "1") + Style("min-height", "0") + Style("overflow", "auto") + Style("padding", "1.5em 2em 2em") + Style("box-sizing", "border-box") +} + +internal val BackButton: PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("width", "2.5em") + Style("height", "2.5em") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.4em") + Style("background", "rgba(4, 12, 20, 0.7)") + Style("color", "#cfe0f0") + Style("cursor", "pointer") + Style("font-size", "1em") +} + +internal val TabBar: PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "stretch") + Style("gap", "0.25em") + Style("border-bottom", "1px solid ${Ui.Divider}") + Style("margin-bottom", "1em") + Style("overflow-x", "auto") +} + +internal fun TabStyle(Active: Boolean): PropertiesBuilder.() -> Unit = { + Style("position", "relative") + Style("padding", "0.7em 1.2em") + Style("border", "0") + Style("background", "transparent") + Style("color", if (Active) Ui.Accent else Ui.Muted) + Style("font", "500 1em/1 ${Ui.FontMedium}") + Style("cursor", "pointer") + Style("white-space", "nowrap") + if (Active) { + Style("box-shadow", "inset 0 -0.15em 0 ${Ui.Accent}") + } +} + +internal val Panel: PropertiesBuilder.() -> Unit = { + Style("background", Ui.Panel) + Style("border", "1px solid ${Ui.DividerSoft}") + Style("border-radius", "0.5em") + Style("backdrop-filter", "blur(24px)") + Style("-webkit-backdrop-filter", "blur(24px)") +} + +internal val PrimaryButton: PropertiesBuilder.() -> Unit = { + Style("display", "inline-flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("gap", "0.4em") + Style("padding", "0.65em 1.3em") + Style("border", "0") + Style("border-radius", "0.45em") + Style("background", Ui.Cyan) + Style("color", "#001722") + Style("font", "500 1em/1 ${Ui.FontMedium}") + Style("cursor", "pointer") +} + +internal val SecondaryButton: PropertiesBuilder.() -> Unit = { + Style("display", "inline-flex") + Style("align-items", "center") + Style("justify-content", "center") + Style("gap", "0.4em") + Style("padding", "0.6em 1.1em") + Style("border", "1px solid ${Ui.Divider}") + Style("border-radius", "0.45em") + Style("background", "rgba(191, 213, 255, 0.08)") + Style("color", Ui.Text) + Style("font", "500 0.95em/1 ${Ui.FontMedium}") + Style("cursor", "pointer") +} + +internal fun CardStyle(Selected: Boolean = false): PropertiesBuilder.() -> Unit = { + Style("position", "relative") + Style("box-sizing", "border-box") + Style("padding", "0.8em") + Style("border-radius", "0.5em") + Style("background", if (Selected) "rgba(118, 255, 51, 0.12)" else "rgba(191, 213, 255, 0.10)") + Style("border", if (Selected) "1px solid ${Ui.Accent}" else "1px solid ${Ui.DividerSoft}") + Style("color", Ui.Text) + Style("cursor", "pointer") + Style("text-align", "left") + Style("transition", "border-color 120ms ease, background 120ms ease") +} + +internal val CardTitle: PropertiesBuilder.() -> Unit = { + Style("font", "500 1em/1.3 ${Ui.FontMedium}") + Style("white-space", "nowrap") + Style("overflow", "hidden") + Style("text-overflow", "ellipsis") +} + +internal val CardText: PropertiesBuilder.() -> Unit = { + Style("margin-top", "0.35em") + Style("color", Ui.Muted) + Style("font", "400 0.9em/1.4 ${Ui.FontRegular}") +} + +internal val Grid: PropertiesBuilder.() -> Unit = { + Style("display", "grid") + Style("grid-template-columns", "repeat(auto-fill, minmax(14em, 1fr))") + Style("gap", "0.8em") +} + +internal val Empty: PropertiesBuilder.() -> Unit = { + Style("padding", "3em 1.5em") + Style("text-align", "center") + Style("color", Ui.Muted) + Style("border", "1px dashed ${Ui.DividerSoft}") + Style("border-radius", "0.5em") +} + +internal val StatLine: PropertiesBuilder.() -> Unit = { + Style("display", "flex") + Style("align-items", "center") + Style("justify-content", "space-between") + Style("padding", "0.45em 0") + Style("border-bottom", "1px solid ${Ui.DividerSoft}") +} + +internal val MutedText: PropertiesBuilder.() -> Unit = { + Style("color", Ui.Muted) + Style("font", "400 0.9em/1.4 ${Ui.FontRegular}") +} + +/** 从 Kotlin/JS 动态对象读字段;未知字段或 null 接收者返回 fallback。 */ +internal fun Any?.Str(Name: String, Fallback: String = ""): String { + if (this == null) return Fallback + val V = this.asDynamic()[Name] + return if (V == null) Fallback else V.toString() +} + +internal fun Any?.Num(Name: String, Fallback: Int = 0): Int { + if (this == null) return Fallback + val V = this.asDynamic()[Name] + return if (V == null) Fallback else (V as? Number)?.toInt() ?: Fallback +} + +internal fun Any?.Bool(Name: String, Fallback: Boolean = false): Boolean { + if (this == null) return Fallback + val V = this.asDynamic()[Name] + return if (V == null) Fallback else V as? Boolean ?: Fallback +} diff --git a/TankiOffline/app/src/jsMain/resources/Garage3D3.js b/TankiOffline/app/src/jsMain/resources/Garage3D3.js new file mode 100644 index 0000000..3da4e96 --- /dev/null +++ b/TankiOffline/app/src/jsMain/resources/Garage3D3.js @@ -0,0 +1,189 @@ +// Garage3D3.js —— 车库 3D:官方 3DS 环境 + 坦克预览 +// +// 3DS 是 Z-up,TDSLoader 不会自动转轴;先绕 X 轴 -90° 再按包围盒缩放, +// 这样室内场景才会落在地面上,而不是“浮在坦克上方”。 +// 材质保留原始贴图(之前重建 MeshStandardMaterial 会丢掉贴图)。 +import * as THREE from 'three'; +import { TDSLoader } from '/vendor/three/TDSLoader.js'; +import { OrbitControls } from '/vendor/three/OrbitControls.js'; + +const MapUrl = 'http://127.0.0.1:8082/res/613/2501/252/46/32047724143075/object.3ds'; +let Current = null; + +function Mount(CanvasId) { + if (Current && Current.scene) return; + const canvas = document.getElementById(CanvasId); + if (!canvas) { + if (Mount.Retry === undefined) Mount.Retry = 0; + if (Mount.Retry++ < 100) setTimeout(() => Mount(CanvasId), 100); + return; + } + Mount.Retry = 0; + + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true }); + renderer.setClearColor(0x060d14, 1); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.15; + + const scene = new THREE.Scene(); + scene.fog = new THREE.Fog(0x060d14, 28, 62); + + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 400); + camera.position.set(0, 3.6, 11.5); + + const controls = new OrbitControls(camera, canvas); + controls.target.set(0, 2.0, 0); + controls.enablePan = false; + controls.enableZoom = true; + controls.minDistance = 6; + controls.maxDistance = 22; + controls.autoRotate = false; + controls.enableDamping = true; + controls.dampingFactor = 0.08; + + scene.add(new THREE.HemisphereLight(0xcfe4ff, 0x0a141e, 1.6)); + const Sun = new THREE.DirectionalLight(0xffffff, 2.2); + Sun.position.set(6, 10, 7); + Sun.castShadow = true; + Sun.shadow.mapSize.set(2048, 2048); + Sun.shadow.camera.near = 1; + Sun.shadow.camera.far = 60; + Sun.shadow.camera.left = -18; + Sun.shadow.camera.right = 18; + Sun.shadow.camera.top = 18; + Sun.shadow.camera.bottom = -18; + scene.add(Sun); + const Rim = new THREE.DirectionalLight(0x3f9fe0, 1.4); + Rim.position.set(-7, 5, -6); + scene.add(Rim); + const Spot = new THREE.PointLight(0xfff0d0, 40, 30, 2); + Spot.position.set(0, 7, 3); + scene.add(Spot); + + // 环境场景没加载出来时的兜底地面(加载成功后隐藏) + const Fallback = new THREE.Group(); + const Floor = new THREE.Mesh( + new THREE.CircleGeometry(9, 64), + new THREE.MeshStandardMaterial({ color: 0x0a1620, roughness: 0.95, metalness: 0.05 }), + ); + Floor.rotation.x = -Math.PI / 2; + Floor.receiveShadow = true; + Fallback.add(Floor); + const Platform = new THREE.Mesh( + new THREE.CylinderGeometry(3.2, 3.4, 0.22, 64), + new THREE.MeshStandardMaterial({ color: 0x1b2c3a, roughness: 0.55, metalness: 0.55 }), + ); + Platform.position.y = 0.11; + Platform.receiveShadow = true; + Fallback.add(Platform); + scene.add(Fallback); + + const Loader = new TDSLoader(); + Loader.load( + MapUrl, + (Obj) => { + // 3DS: Z-up -> Y-up + Obj.rotation.x = -Math.PI / 2; + Obj.updateMatrixWorld(true); + + Obj.traverse((N) => { + if (!N.isMesh) return; + N.castShadow = true; + N.receiveShadow = true; + const Mats = Array.isArray(N.material) ? N.material : [N.material]; + for (const M of Mats) { + if (!M) continue; + if (M.map) M.map.colorSpace = THREE.SRGBColorSpace; + if (M.emissiveMap) M.emissiveMap.colorSpace = THREE.SRGBColorSpace; + M.side = THREE.DoubleSide; + M.needsUpdate = true; + } + }); + + const Box = new THREE.Box3().setFromObject(Obj); + const Size = Box.getSize(new THREE.Vector3()); + // 只用房间本体计算缩放:Sky/bg 的包围盒会把整个场景压得极小。 + let Fit = Box; + Obj.traverse((N) => { + if (N.isMesh && N.name === 'room0') Fit = new THREE.Box3().setFromObject(N); + }); + const FitSize = Fit.getSize(new THREE.Vector3()); + const Span = Math.max(FitSize.x, FitSize.y, FitSize.z) || 1; + const Scale = 24 / Span; + Obj.scale.setScalar(Scale); + Obj.updateMatrixWorld(true); + + const Box2 = new THREE.Box3().setFromObject(Obj); + // 平台在原始坐标原点:只把地面抬到 y=0,不横向平移,保持坦克位在中心。 + // 地面高度按房间本体计算,避免被 Sky/bg 的包围盒抬高。 + let RoomBox = new THREE.Box3(); + Obj.traverse((N) => { + if (N.isMesh && N.name === 'room0') RoomBox.setFromObject(N); + }); + Obj.position.y -= RoomBox.min.y; + scene.add(Obj); + Fallback.visible = false; + + window.__garage3dMap = { + rawSize: [Size.x, Size.y, Size.z], + fitSize: [FitSize.x, FitSize.y, FitSize.z], + scale: Scale, + box: [Box2.min.x, Box2.min.y, Box2.min.z, Box2.max.x, Box2.max.y, Box2.max.z], + }; + }, + undefined, + (Err) => { + window.__garage3dMapError = String(Err && Err.message ? Err.message : Err); + }, + ); + + function Resize() { + const Rect = (canvas.parentElement || canvas).getBoundingClientRect(); + const W = Math.max(1, Math.floor(Rect.width)); + const H = Math.max(1, Math.floor(Rect.height)); + if (canvas.width !== W || canvas.height !== H) { + renderer.setSize(W, H, false); + camera.aspect = W / H; + camera.updateProjectionMatrix(); + } + } + const Observer = new ResizeObserver(Resize); + Observer.observe(canvas.parentElement || canvas); + Resize(); + + let Alive = true; + function Frame() { + if (!Alive) return; + Resize(); + controls.update(); + renderer.render(scene, camera); + requestAnimationFrame(Frame); + } + Frame(); + + Current = { + scene, + camera, + renderer, + controls, + dispose() { + Alive = false; + Observer.disconnect(); + controls.dispose(); + renderer.dispose(); + Current = null; + window.__garage3d = null; + }, + }; + window.__garage3d = Current; +} + +function Dispose() { + if (Current) Current.dispose(); +} + +window.Garage3D = { mount: Mount, dispose: Dispose }; diff --git a/TankiOffline/app/src/jsMain/resources/Lobby3D.js b/TankiOffline/app/src/jsMain/resources/Lobby3D.js new file mode 100644 index 0000000..fb8e6eb --- /dev/null +++ b/TankiOffline/app/src/jsMain/resources/Lobby3D.js @@ -0,0 +1,154 @@ +// Lobby3D.js —— 大厅固定机位 3D 背景(官方车库 3DS 环境) +// +// 与车库共用同一份 3DS 资源,但这里不接 OrbitControls: +// 相机固定在 (0, 3.6, 11.5) 看向平台,作为大厅的静态背景。 +import * as THREE from 'three'; +import { TDSLoader } from '/vendor/three/TDSLoader.js'; + +const MapUrl = 'http://127.0.0.1:8082/res/613/2501/252/46/32047724143075/object.3ds'; +let Current = null; +let Retry = 0; + +function Mount() { + if (Current) return; + const canvas = document.getElementById('lobby-canvas'); + if (!canvas) { + if (Retry++ < 200) setTimeout(Mount, 100); + return; + } + Retry = 0; + + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true }); + renderer.setClearColor(0x060d14, 1); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.15; + + const scene = new THREE.Scene(); + scene.fog = new THREE.Fog(0x060d14, 28, 70); + + const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 400); + camera.position.set(0, 3.6, 11.5); + camera.lookAt(0, 2.0, 0); + let CamPos = new THREE.Vector3(0, 3.6, 11.5); + let CamTarget = new THREE.Vector3(0, 2.0, 0); + + scene.add(new THREE.HemisphereLight(0xcfe4ff, 0x0a141e, 1.5)); + const Sun = new THREE.DirectionalLight(0xffffff, 2.0); + Sun.position.set(6, 10, 7); + scene.add(Sun); + const Rim = new THREE.DirectionalLight(0x3f9fe0, 1.3); + Rim.position.set(-7, 5, -6); + scene.add(Rim); + const Spot = new THREE.PointLight(0xfff0d0, 40, 30, 2); + Spot.position.set(0, 7, 3); + scene.add(Spot); + + const Fallback = new THREE.Mesh( + new THREE.CircleGeometry(9, 64), + new THREE.MeshStandardMaterial({ color: 0x0a1620, roughness: 0.95, metalness: 0.05 }), + ); + Fallback.rotation.x = -Math.PI / 2; + scene.add(Fallback); + + new TDSLoader().load( + MapUrl, + (Obj) => { + Obj.rotation.x = -Math.PI / 2; + Obj.updateMatrixWorld(true); + Obj.traverse((N) => { + if (!N.isMesh) return; + const Mats = Array.isArray(N.material) ? N.material : [N.material]; + for (const M of Mats) { + if (!M) continue; + if (M.map) M.map.colorSpace = THREE.SRGBColorSpace; + if (M.emissiveMap) M.emissiveMap.colorSpace = THREE.SRGBColorSpace; + M.side = THREE.DoubleSide; + M.needsUpdate = true; + } + }); + let Fit = new THREE.Box3().setFromObject(Obj); + Obj.traverse((N) => { + if (N.isMesh && N.name === 'room0') Fit = new THREE.Box3().setFromObject(N); + }); + const FitSize = Fit.getSize(new THREE.Vector3()); + const Span = Math.max(FitSize.x, FitSize.y, FitSize.z) || 1; + Obj.scale.setScalar(24 / Span); + Obj.updateMatrixWorld(true); + // 地面高度按房间本体计算;Sky/bg 的包围盒会把房间整体抬高到相机上方。 + let RoomBox = new THREE.Box3(); + Obj.traverse((N) => { + if (N.isMesh && N.name === 'room0') RoomBox.setFromObject(N); + }); + Obj.position.y -= RoomBox.min.y; + scene.add(Obj); + Obj.updateMatrixWorld(true); + + // 3DS 自带的摄像机/目标方块:cam0 = 机位,cam_t0 = 看向点。 + // 之前用固定坐标导致机位在房间另一侧(画面左右反、俯仰也不对)。 + Obj.traverse((N) => { + if (N.name === 'cam0') { + N.getWorldPosition(CamPos); + N.visible = false; + } else if (N.name === 'cam_t0') { + N.getWorldPosition(CamTarget); + N.visible = false; + } + }); + camera.position.copy(CamPos); + camera.up.set(0, 1, 0); + camera.lookAt(CamTarget); + camera.updateProjectionMatrix(); + window.__lobby3dCamera = { pos: CamPos.toArray(), target: CamTarget.toArray() }; + Fallback.visible = false; + window.__lobby3dMap = { fitSize: [FitSize.x, FitSize.y, FitSize.z], scale: 24 / Span }; + }, + undefined, + (Err) => { + window.__lobby3dMapError = String(Err && Err.message ? Err.message : Err); + }, + ); + + function Resize() { + const Rect = (canvas.parentElement || canvas).getBoundingClientRect(); + const W = Math.max(1, Math.floor(Rect.width)); + const H = Math.max(1, Math.floor(Rect.height)); + if (canvas.width !== W || canvas.height !== H) { + renderer.setSize(W, H, false); + camera.aspect = W / H; + camera.updateProjectionMatrix(); + } + } + const Observer = new ResizeObserver(Resize); + Observer.observe(canvas.parentElement || canvas); + Resize(); + + let Alive = true; + function Frame() { + if (!Alive) return; + Resize(); + renderer.render(scene, camera); + requestAnimationFrame(Frame); + } + Frame(); + + Current = { + scene, + camera, + renderer, + dispose() { + Alive = false; + Observer.disconnect(); + renderer.dispose(); + Current = null; + window.__lobby3d = null; + }, + }; + window.__lobby3d = Current; +} + +window.Lobby3D = { mount: Mount, dispose: () => Current && Current.dispose() }; +Mount(); diff --git a/TankiOffline/app/src/jsMain/resources/index.html b/TankiOffline/app/src/jsMain/resources/index.html index 1087445..a9f3786 100644 --- a/TankiOffline/app/src/jsMain/resources/index.html +++ b/TankiOffline/app/src/jsMain/resources/index.html @@ -5,11 +5,12 @@ TankiOffline · 大厅 - + - + +
    diff --git a/TankiOffline/app/src/jsMain/resources/lobby3.css b/TankiOffline/app/src/jsMain/resources/lobby3.css new file mode 100644 index 0000000..51d787e --- /dev/null +++ b/TankiOffline/app/src/jsMain/resources/lobby3.css @@ -0,0 +1,945 @@ +/* lobby.css —— 大厅主界面(尺寸/颜色对照 3dtank.com/play/ 线上计算样式) + + 线上根字号 12.96px(.ksc-37),这里用同样的 max(min(1.48148vh, 1vw), 3px), + 因此 6em = 78px、21.875em = 284px、22.5em = 292px,与真实 DOM 一致。 */ +@font-face { + font-family: BaseFontRegular; + src: url("/play/static/fonts/Rubik-Regular.7e001ec0.woff") format("woff"); +} +@font-face { + font-family: BaseFontMedium; + src: url("/play/static/fonts/Rubik-Medium.fc871fe9.woff") format("woff"); +} +@font-face { + font-family: BaseFontBold; + src: url("/play/static/fonts/Rubik-Bold.26ae1fce.woff") format("woff"); +} + +html, body, #app { height: 100%; } +body { margin: 0; overflow: hidden; background: #001926; color: #fff; } + +.ms-root { + position: relative; + width: 100%; + height: 100%; + font-size: max(min(1.48148vh, 1vw), 3px); + font-family: BaseFontRegular, "Segoe UI", sans-serif; + color: #fff; + user-select: none; + overflow: hidden; +} +#lobby-canvas { + position: absolute; inset: 0; width: 100%; height: 100%; + display: block; background: #001926; +} +.ms-bg { + position: absolute; inset: 0; + background: + linear-gradient(180deg, rgba(0, 25, 38, 0.35), rgba(0, 12, 20, 0.55)), + url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat; + filter: saturate(0.9) brightness(0.8); +} + +/* ---------------- 顶栏 6em ---------------- */ +.ms-top { + position: absolute; top: 0; left: 0; + display: flex; align-items: center; justify-content: space-between; + width: 100%; height: 6em; z-index: 2; + border-bottom: 1px solid rgba(255, 255, 255, 0.25); + background: linear-gradient(180deg, rgba(0, 12, 20, 0.55), rgba(0, 12, 20, 0.05)); +} +.ms-top-left { display: flex; align-items: center; height: 100%; } +.rank-icon { + width: 5.9375em; height: 6em; display: flex; align-items: center; justify-content: center; + border-right: 1px solid rgba(255, 255, 255, 0.25); cursor: pointer; +} +.rank-icon:hover { background-color: rgba(255, 255, 255, 0.05); } +.rank-icon img { width: 4.375em; height: 4.375em; } +.user-progress { + display: flex; flex-direction: column; justify-content: center; + min-width: 10em; margin-left: 1em; height: 3.75em; +} +.user-name { + font: 500 1.1em/1.2 BaseFontMedium, sans-serif; + text-shadow: 0 0 0.25em rgba(0, 0, 0, 0.5); cursor: pointer; +} +.user-name:hover { color: #ffbc09; } +.xp-bar { + width: 12.5em; height: 0.25em; margin-top: 0.375em; + background: rgba(118, 255, 51, 0.25); border-radius: 0.375em; overflow: hidden; +} +.xp-bar i { display: block; height: 100%; background: rgba(118, 255, 51, 0.8); border-radius: inherit; } +.xp-text { + display: flex; align-items: center; margin-top: 0.35em; + font: 500 0.9375em/1 BaseFontMedium, sans-serif; color: #76ff33; + text-shadow: 0 0 0.25em rgba(0, 0, 0, 0.5); +} +.xp-text span { color: rgba(255, 255, 255, 0.35); margin-left: 0.2em; } +.xp-icon { + width: 0.85em; height: 0.85em; margin-right: 0.25em; + background: url("/play/static/images/experience.402435da.svg") center / contain no-repeat; +} +.ms-top-logo { + position: absolute; left: 0; right: 0; margin: auto; + display: flex; align-items: center; justify-content: center; + height: 3.75em; pointer-events: none; opacity: 0.95; +} +.ms-top-logo img { height: 3.75em; } +.ms-top-right { display: flex; align-items: center; justify-content: flex-end; height: 6em; z-index: 5; } +.ms-currencies { display: flex; flex-direction: column; justify-content: center; height: 6em; } +.ms-currency { + position: relative; display: flex; align-items: center; gap: 0.4em; + width: 9.4em; height: 3em; padding: 0 0.75em 0 0.6em; cursor: pointer; +} +.ms-currency .coin { width: 1em; height: 1em; } +.ms-currency span { font: 500 1.05em/1 BaseFontMedium, sans-serif; } +.ms-currency .plus { width: 1em; height: 1em; margin-left: auto; opacity: 0.85; } +.ms-currency:hover { background: rgba(255, 255, 255, 0.06); } +.ms-fullscreen { + display: flex; align-items: center; justify-content: center; gap: 0.3em; + width: 6em; height: 6em; cursor: pointer; +} +.ms-fullscreen i { + width: 1.5em; height: 1.5em; background: #bfd5ff; + mask: url("/play/static/images/fullscreen.0b4547f5.svg") center / contain no-repeat; + -webkit-mask: url("/play/static/images/fullscreen.0b4547f5.svg") center / contain no-repeat; +} +.ms-fullscreen b { font: 600 0.75em/1 BaseFontMedium, sans-serif; color: rgba(255, 255, 255, 0.65); } +.ms-fullscreen:hover i { background: #fff; } +.ms-logout { + display: flex; align-items: center; justify-content: center; + width: 6em; height: 6em; border: 0; background: transparent; cursor: pointer; +} +.ms-logout i { + width: 1.5em; height: 1.5em; background: #bfd5ff; + mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat; + -webkit-mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat; +} +.ms-logout:hover { background: rgba(255, 255, 255, 0.05); } +.ms-logout:hover i { background: #fff; } + +/* ---------------- 左侧 50% ---------------- */ +.ms-home { position: absolute; inset: 0; z-index: 1; } +.ms-left-gradient { + position: absolute; left: 0; top: 0; bottom: 0; + display: flex; flex-direction: column; align-items: flex-start; + width: 50%; padding-top: 10em; + background: linear-gradient(90deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%); +} +.ms-play { + display: flex; align-items: center; justify-content: center; + width: 21.875em; height: 10.75em; margin-left: 4.375em; + border: 0; border-radius: 0.5rem; cursor: pointer; + background: url("/play/static/images/videoplay.79570900.gif") center / cover no-repeat; + box-shadow: 0 0 0 0.063em rgba(118, 255, 51, 0.25); +} +.ms-play span { + font: 500 2.75em/1 BaseFontMedium, sans-serif; text-transform: uppercase; + color: #76ff33; text-shadow: 0 0.15em 0.3em rgba(0, 0, 0, 0.5); +} +.ms-play:hover { box-shadow: 0 0 0 2px #76ff33; } + +.ms-menu { + list-style: none; margin: 1.1em 0 0 4.375em; padding: 0; + display: flex; flex-direction: column; width: 27em; +} +.ms-menu-item { + display: flex; align-items: center; height: 4.5em; cursor: pointer; +} +.ms-menu-item:hover .ms-menu-icon { background-color: #fff; } +.ms-menu-item:hover span { color: #ffbc09; } +.ms-menu-icon { + width: 3.5em; height: 3.5em; margin-left: 0.625em; background-color: #bfd5ff; + mask-position: center; mask-repeat: no-repeat; mask-size: contain; + -webkit-mask-position: center; -webkit-mask-repeat: no-repeat; -webkit-mask-size: contain; + transition: background-color 0.15s; +} +.ms-menu-icon.icon-containers { mask-image: url("/play/static/images/iconLobbyMenuContainers.f960290f.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuContainers.f960290f.svg"); } +.ms-menu-icon.icon-quests { mask-image: url("/play/static/images/iconLobbyMenuQuests.54801b8b.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuQuests.54801b8b.svg"); } +.ms-menu-icon.icon-garage { mask-image: url("/play/static/images/iconLobbyMenuGarage.0b623165.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuGarage.0b623165.svg"); } +.ms-menu-icon.icon-shop { mask-image: url("/play/static/images/iconLobbyMenuStore.e755fe14.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuStore.e755fe14.svg"); } +.ms-menu-icon.icon-settings { mask-image: url("/play/static/images/iconLobbyMenuSettings.cc00d08a.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuSettings.cc00d08a.svg"); } +.ms-menu-item span { + margin-left: 0.5625em; font: 500 2em/1 BaseFontMedium, sans-serif; + text-transform: uppercase; text-shadow: 0 0.25em 0.25em rgba(0, 0, 0, 0.25); + transition: color 0.15s; +} + +.ms-footer { flex: 1; display: flex; align-items: flex-end; width: 100%; } +.ms-footer ul { + display: flex; align-items: center; list-style: none; + margin: 0 0 0.8em 5em; padding: 0; height: 5.0625em; +} +.ms-footer-item { + display: flex; align-items: center; justify-content: center; + width: 5em; height: 5em; cursor: pointer; +} +.ms-footer-item:hover { border-radius: 6.25em; box-shadow: 0 0 0 0.125em #fff; } +.ms-footer-icon { + width: 2em; height: 2em; background: #bfd5ff; + mask-position: center; mask-repeat: no-repeat; mask-size: contain; + -webkit-mask-position: center; -webkit-mask-repeat: no-repeat; -webkit-mask-size: contain; +} +.ms-footer-item:hover .ms-footer-icon { background: #fff; } +.ms-footer-icon.icon-news { mask-image: url("/play/static/images/iconLobbyMenuNews.6efe9ae3.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuNews.6efe9ae3.svg"); } +.ms-footer-icon.icon-chat { mask-image: url("/play/static/images/iconLobbyMenuChat.e48a39a4.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuChat.e48a39a4.svg"); } +.ms-footer-icon.icon-friends { mask-image: url("/play/static/images/iconLobbyMenuFriends.28305317.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuFriends.28305317.svg"); } +.ms-footer-icon.icon-clans { mask-image: url("/play/static/images/iconLobbyMenuClan.f01ae646.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuClan.f01ae646.svg"); } + +/* ---------------- 右侧 ---------------- */ +.ms-right-gradient { + position: absolute; right: 0; top: 0; bottom: 0; width: 27.75em; + background: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0) 100%); + opacity: 0.75; transform: rotate(180deg); pointer-events: none; +} +.ms-battle-pass { + position: absolute; right: 2.5em; top: 8.5em; + width: 22.5em; height: 10em; padding: 0.5em; box-sizing: border-box; + background: linear-gradient(0deg, rgba(0, 25, 38, 0.25) 0%, #001926 100%); + border-radius: 0.5rem; cursor: pointer; z-index: 1; + filter: drop-shadow(0 0.25rem 0.25rem rgba(0, 0, 0, 0.25)); +} +.ms-battle-pass:hover { box-shadow: 0 0 0 0.125em #bfd5ff; } +.ms-bp-head { + display: flex; align-items: center; justify-content: space-between; + height: 2.75em; font-size: 0.9375em; +} +.ms-bp-head span { display: flex; align-items: center; gap: 0.3em; } +.ms-bp-head img { width: 1em; height: 1em; } +.ms-bp-row { + display: flex; align-items: center; height: 3.125em; margin: 0 0.3em 0.3em; + border: 0.063em solid rgba(255, 255, 255, 0.1); border-radius: 0.3em; + background: rgba(0, 0, 0, 0.1); +} +.ms-bp-row i { + width: 4em; height: 2.4em; margin: 0 0.5em; + background: url("/play/static/images/StarMission.d11624d7.svg") center / contain no-repeat; + opacity: 0.9; +} +.ms-bp-row div { display: flex; flex-direction: column; font-size: 0.9375em; } +.ms-bp-row b { font: 500 1em/1.2 BaseFontBold, sans-serif; } +.ms-bp-row span { font-size: 0.8em; } +.ms-bp-row.locked { opacity: 0.55; } +.ms-bp-row.locked i { filter: grayscale(1); } +.ms-announce { + position: absolute; right: 2.5em; bottom: 2.5em; + display: flex; flex-direction: column; width: 22.5em; min-height: 12.5em; + background: rgba(0, 25, 38, 0.35); border: 0.063em solid rgba(255, 255, 255, 0.25); + border-radius: 0.5rem; overflow: hidden; backdrop-filter: blur(0.78em); +} +.ms-announce:hover { border-color: #bfd5ff; } +.ms-announce-art { + height: 9.5em; + background: + radial-gradient(60% 70% at 50% 40%, rgba(255, 179, 0, 0.18), transparent 70%), + url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat; +} +.ms-announce-title { + padding: 0.7em 0.9em; font: 500 1.125em/1.4 BaseFontMedium, sans-serif; + background: linear-gradient(rgba(0, 25, 38, 0), #001926 60%); +} + +/* ---------------- 屏幕 / 返回 / 底部信息 ---------------- */ +.ms-content { + position: absolute; inset: 0; z-index: 6; + padding: 7em 3em 2.5em; box-sizing: border-box; overflow: auto; + background: linear-gradient(180deg, rgba(0, 12, 20, 0.86), rgba(0, 8, 14, 0.94)); +} +.hidden { display: none !important; } +.ms-back { + position: absolute; top: 6.8em; left: 1.6em; z-index: 8; + padding: 0.55em 1.1em; border-radius: 0.4em; cursor: pointer; + border: 1px solid rgba(255, 255, 255, 0.25); background: rgba(4, 12, 20, 0.7); + color: #cfe0f0; font: 500 0.95em/1 BaseFontMedium, sans-serif; +} +.ms-back:hover { border-color: #fff; color: #fff; } +.ms-screen { max-width: 1120px; margin: 0 auto; } +.ms-screen-title { margin: 0 0 1em; font: 600 2em/1.2 BaseFontMedium, sans-serif; } +.ms-screen-title small { margin-left: 0.6em; color: rgba(255, 255, 255, 0.5); font-size: 0.55em; font-weight: 400; } +.ms-empty { + padding: 3em 1.5em; text-align: center; color: rgba(255, 255, 255, 0.55); + border: 1px dashed rgba(255, 255, 255, 0.18); border-radius: 0.5em; +} +.ms-chat-box { + height: 24em; overflow: auto; padding: 0.7em; border-radius: 0.5em; + background: rgba(0, 8, 14, 0.7); font: 12px/1.55 Consolas, monospace; +} +.ms-log-line { padding: 0.1em 0; border-bottom: 1px solid rgba(255, 255, 255, 0.06); color: #b8c9da; } +.ms-news-list { display: flex; flex-direction: column; gap: 0.8em; } +.ms-news { + padding: 1em; border-radius: 0.5em; border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(4, 14, 22, 0.6); +} +.ms-news b { display: block; margin-bottom: 0.4em; } +.ms-news p { margin: 0; color: rgba(255, 255, 255, 0.6); font-size: 0.85em; white-space: pre-wrap; } +.ms-client-info { + position: absolute; right: 0; bottom: 0; height: 1.3rem; z-index: 101; + display: flex; align-items: center; justify-content: flex-end; + padding-right: 1.8rem; pointer-events: none; + font-size: 0.9375em; letter-spacing: 0.0375em; color: rgba(255, 255, 255, 0.7); +} +.ms-client-info i { margin: 0 0.4rem; font-style: normal; color: rgba(255, 255, 255, 0.6); } + +/* ---------------- React 车库(尺寸随根字号) ---------------- */ +.ms-garage-screen { padding-top: 0; } +#garage-root { height: calc(100vh - 9.5em); min-height: 28em; } +.garage { + display: flex; flex-direction: column; gap: 0.9em; height: 100%; min-height: 0; +} +.garage-head { display: flex; align-items: center; justify-content: space-between; gap: 0.9em; } +.garage-head h2 { margin: 0; font: 600 1.7em/1.2 BaseFontMedium, sans-serif; color: #fff; } +.garage-head h2 small { margin-left: 0.5em; color: rgba(255, 255, 255, 0.45); font-size: 0.5em; font-weight: 400; } +.garage-search { + width: 18em; padding: 0.55em 0.9em; border-radius: 0.4em; + border: 1px solid rgba(255, 255, 255, 0.16); background: rgba(3, 10, 16, 0.72); + color: #fff; outline: none; font-size: 0.9em; +} +.garage-search:focus { border-color: rgba(255, 179, 0, 0.6); } +.garage-tabs { display: flex; flex-wrap: wrap; gap: 0.45em; } +.garage-tab { + display: inline-flex; align-items: center; gap: 0.5em; + padding: 0.45em 0.9em; border-radius: 0.35em; font-size: 0.85em; + border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(4, 12, 20, 0.62); color: #c7d7e6; +} +.garage-tab span { color: rgba(255, 255, 255, 0.45); font-size: 0.8em; } +.garage-tab:hover { border-color: rgba(255, 255, 255, 0.3); } +.garage-tab.active { + border-color: #8a5d00; color: #241800; font-weight: 700; + background: linear-gradient(180deg, #ffc93d, #f29500); +} +.garage-tab.active span { color: #4a3200; } +.garage-main { flex: 1; min-height: 0; display: flex; gap: 1em; } +.garage-grid { + flex: 1; min-width: 0; overflow: auto; align-content: start; + display: grid; grid-template-columns: repeat(auto-fill, minmax(9.5em, 1fr)); gap: 0.55em; padding-right: 0.3em; +} +.garage-card { + position: relative; display: flex; flex-direction: column; align-items: stretch; + padding: 0; border-radius: 0.4em; overflow: hidden; text-align: left; + border: 1px solid rgba(255, 255, 255, 0.1); + border-top: 2px solid var(--rarity, #9aa3b2); + background: linear-gradient(180deg, rgba(20, 34, 48, 0.92), rgba(6, 12, 18, 0.94)); + color: #fff; cursor: pointer; +} +.garage-card:hover { border-color: var(--rarity, #9aa3b2); transform: translateY(-1px); } +.garage-card.selected { box-shadow: 0 0 0 1px var(--rarity, #ffb300), 0 0 1.2em rgba(255, 179, 0, 0.18); } +.garage-card-art { + height: 4.6em; display: flex; align-items: center; justify-content: center; + font: 800 1.9em/1 BaseFontBold, sans-serif; color: var(--rarity, #9aa3b2); + background: radial-gradient(closest-side, color-mix(in srgb, var(--rarity, #9aa3b2) 38%, transparent), transparent 72%); +} +.garage-card-name { padding: 0 0.6em; font-size: 0.85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.garage-card-meta { + padding: 0.15em 0.6em 0.55em; display: flex; align-items: center; justify-content: space-between; gap: 0.4em; + color: rgba(255, 255, 255, 0.5); font-size: 0.72em; +} +.garage-price { color: #ffd76a; } +.garage-premium { + position: absolute; top: 0.35em; right: 0.35em; padding: 0 0.35em; border-radius: 0.2em; + background: #ffb300; color: #241800; font: 800 0.65em/1.1em BaseFontBold, sans-serif; +} +.garage-empty { grid-column: 1 / -1; padding: 3em; text-align: center; color: rgba(255, 255, 255, 0.55); } +.garage-detail { + width: 22em; flex: 0 0 22em; display: flex; flex-direction: column; gap: 0.55em; + padding: 0.8em; border-radius: 0.5em; border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(6, 14, 22, 0.78); +} +.garage-detail h3 { margin: 0 0 0.2em; font: 600 1.25em/1.3 BaseFontMedium, sans-serif; } +.garage-preview { + height: 14em; border-radius: 0.4em; border: 1px solid rgba(255, 255, 255, 0.08); + background: + radial-gradient(60% 60% at 50% 45%, rgba(63, 159, 224, 0.16), transparent 70%), + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent); +} +.garage-preview-label { color: rgba(255, 255, 255, 0.45); font-size: 0.72em; text-align: center; margin-top: -0.2em; } +.garage-props { display: flex; flex-direction: column; gap: 0.3em; margin-top: 0.5em; } +.garage-prop { + display: flex; align-items: center; justify-content: space-between; + padding: 0.35em 0.55em; border-radius: 0.25em; background: rgba(255, 255, 255, 0.03); font-size: 0.82em; +} +.garage-prop span { color: rgba(255, 255, 255, 0.5); } +.garage-prop b { font-weight: 600; } + +.rarity-0 { --rarity: #9aa3b2; } +.rarity-1 { --rarity: #5fbf6a; } +.rarity-2 { --rarity: #3f8fe0; } +.rarity-3 { --rarity: #a05fe0; } +.rarity-4 { --rarity: #e0a13f; } +.rarity-5 { --rarity: #e05f5f; } + +@media (max-width: 900px) { + .ms-top-logo { display: none; } + .ms-battle-pass, .ms-announce { display: none; } + .garage-detail { display: none; } + .ms-content { padding: 7em 1em 2em; } +} + +/* ---------------- 车库(原版布局) ---------------- */ +.ms-content { padding: 0; } +.ms-screen { padding: 7em 3em 2.5em; box-sizing: border-box; } +.ms-garage-screen { padding: 0; height: 100%; } +#garage-root { height: 100%; min-height: 0; } +.g2 { + display: flex; flex-direction: column; height: 100%; min-height: 0; + background: linear-gradient(180deg, rgba(0, 12, 20, 0.35), rgba(0, 8, 14, 0.75)); +} +.g2-head { + display: flex; align-items: center; height: 6em; flex: 0 0 6em; + border-bottom: 1px solid rgba(255, 255, 255, 0.25); + background: linear-gradient(180deg, rgba(0, 12, 20, 0.6), rgba(0, 12, 20, 0.12)); +} +.g2-back { + width: 6em; height: 6em; display: flex; align-items: center; justify-content: center; + border: 0; background: transparent; cursor: pointer; +} +.g2-back:hover { background: rgba(255, 255, 255, 0.05); } +.g2-back-icon { + width: 1.5em; height: 1.2em; background: #bfd5ff; + mask: url("/play/static/images/iconBackArrow.9f950ca9.svg") center / contain no-repeat; + -webkit-mask: url("/play/static/images/iconBackArrow.9f950ca9.svg") center / contain no-repeat; +} +.g2-title { + position: absolute; left: 0; right: 0; margin: auto; width: 12em; text-align: center; + font: 500 1.4em/1 BaseFontMedium, sans-serif; text-transform: uppercase; + pointer-events: none; +} +.g2-currencies { margin-left: auto; display: flex; align-items: center; gap: 1.2em; padding-right: 1.5em; font: 500 1em/1 BaseFontMedium, sans-serif; } +.g2-currencies span { display: inline-flex; align-items: center; gap: 0.35em; } +.g2-currencies .coin { width: 1.1em; height: 1.1em; } + +.g2-body { flex: 1; min-height: 0; display: flex; gap: 1em; padding: 4em 6em; box-sizing: border-box; } +.g2-left { width: 22em; flex: 0 0 22em; display: flex; flex-direction: column; gap: 0.35em; } +.g2-gs { + display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.5em; + padding: 0.6em 0.8em; border-radius: 0.4em; background: rgba(0, 20, 32, 0.55); + border: 1px solid rgba(255, 255, 255, 0.12); +} +.g2-gs img { width: 2.2em; height: 1.1em; } +.g2-gs b { font: 700 1.8em/1 BaseFontBold, sans-serif; color: #fff; } +.g2-param { + display: flex; align-items: center; justify-content: space-between; + padding: 0.45em 0.7em; border-radius: 0.3em; background: rgba(3, 12, 20, 0.5); + border: 1px solid rgba(255, 255, 255, 0.06); font-size: 0.85em; +} +.g2-param span { color: rgba(255, 255, 255, 0.55); } +.g2-resists { display: flex; gap: 0.5em; margin-top: auto; } +.g2-resist { + width: 4em; height: 5em; display: flex; align-items: center; justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 0.4em; + background: rgba(0, 18, 30, 0.5); color: rgba(255, 255, 255, 0.35); +} + +.g2-scene { + flex: 1; min-width: 0; position: relative; display: flex; flex-direction: column; + align-items: center; justify-content: center; border-radius: 0.5em; + border: 1px solid rgba(255, 255, 255, 0.06); + background: + radial-gradient(60% 55% at 50% 45%, rgba(63, 159, 224, 0.14), transparent 70%), + linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent); +} +.g2-scene-art { + font: 800 16em/1 BaseFontBold, sans-serif; color: rgba(63, 159, 224, 0.18); + text-shadow: 0 0 0.2em rgba(0, 0, 0, 0.4); user-select: none; +} +.g2-scene-caption { + position: absolute; bottom: 1.2em; left: 0; right: 0; text-align: center; + font: 500 1em/1.4 BaseFontMedium, sans-serif; color: rgba(255, 255, 255, 0.75); +} +.g2-scene-caption span { display: block; font-size: 0.65em; color: rgba(255, 255, 255, 0.35); } +.g2-drawer-toggle { + position: absolute; left: 1em; bottom: 1em; padding: 0.6em 1.4em; + border-radius: 0.4em; border: 1px solid #8a5d00; cursor: pointer; + background: linear-gradient(180deg, #ffc93d, #f29500); color: #241800; + font: 700 0.9em/1 BaseFontBold, sans-serif; +} + +.g2-right { width: 23em; flex: 0 0 23em; display: grid; grid-template-columns: 1fr 1fr; gap: 0.6em; align-content: start; } +.g2-mounted { + display: flex; flex-direction: column; padding: 0.5em; border-radius: 0.4em; + border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(3, 12, 20, 0.55); +} +.g2-mounted-title { font-size: 0.72em; color: rgba(255, 255, 255, 0.5); text-transform: uppercase; } +.g2-mounted-art { + height: 4.6em; display: flex; align-items: center; justify-content: center; + font: 800 1.8em/1 BaseFontBold, sans-serif; color: var(--rarity, #9aa3b2); + background: radial-gradient(closest-side, color-mix(in srgb, var(--rarity, #9aa3b2) 30%, transparent), transparent 72%); +} +.g2-mounted-name { font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +.g2-supplies { + flex: 0 0 9.6em; display: flex; align-items: center; justify-content: center; gap: 1.2em; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 10, 18, 0.55); +} +.g2-supply { + width: 7.5em; height: 4em; display: flex; align-items: center; gap: 0.5em; + justify-content: center; border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.35em; background: rgba(3, 12, 20, 0.6); font-size: 0.72em; +} +.g2-supply img { width: 1.8em; height: 1.8em; object-fit: contain; } +.g2-supply span { color: rgba(255, 255, 255, 0.6); } +.g2-supply b { color: #fff; } + +.g2-drawer { + position: absolute; inset: 6em 0 0 0; z-index: 12; display: flex; flex-direction: column; + padding: 1em 2em 1.5em; background: rgba(2, 8, 14, 0.94); backdrop-filter: blur(6px); +} +.g2-drawer-head { display: flex; align-items: center; gap: 1em; margin-bottom: 0.8em; } +.g2-drawer-head b { font: 600 1.3em/1 BaseFontMedium, sans-serif; } +.g2-drawer-head .garage-search { margin-left: auto; } +.g2-drawer-close { + padding: 0.5em 1.2em; border-radius: 0.35em; border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.05); color: #fff; cursor: pointer; +} +.g2-drawer-grid { flex: 1; min-height: 0; overflow: auto; margin-top: 0.8em; } + +@media (max-width: 900px) { + .g2-left, .g2-right { display: none; } + .g2-supplies { gap: 0.4em; } + .g2-supply { width: auto; padding: 0 0.5em; } + .g2-supply span { display: none; } +} + +/* 顶栏:VIP / 组队图标(对照线上 .ksc-38 / .ksc-40) */ +.premium-block { + position: relative; width: 6em; height: 6em; + display: flex; align-items: center; justify-content: center; + background: radial-gradient(100% 100% at 0% 0%, rgba(255, 204, 0, 0.75) 0%, rgba(255, 38, 74, 0.5) 50%, rgba(255, 38, 74, 0) 100%); +} +.premium-block.off { opacity: 0.35; filter: grayscale(0.6); } +.premium-icon { + width: 2em; height: 2em; + background: url("/play/static/images/iconButtonPremium.bc17a592.svg") center / contain no-repeat; +} +.group-block { + position: relative; width: 6em; height: 6em; + display: flex; align-items: center; justify-content: center; +} +.group-icon { + position: absolute; width: 1.5em; height: 1.5em; + background: rgba(191, 212, 255, 0.5); + mask: url("/play/static/images/groupUser.67c9ec61.svg") center / contain no-repeat; + -webkit-mask: url("/play/static/images/groupUser.67c9ec61.svg") center / contain no-repeat; +} +.group-icon.g1 { transform: translateX(-0.5em); } +.group-icon.g2 { transform: translateX(0.5em); } +.premium-block + .group-block + .rank-icon { border-left: 1px solid rgba(255, 255, 255, 0.25); } + +/* 车库全屏:3D 覆盖整页,左右参数/配件与底部道具作为浮层,header 保留 */ +.g2 { position: relative; } +.g2-body { + position: absolute; inset: 6em 0 0 0; display: block; padding: 0; gap: 0; +} +.g2-scene { + position: absolute; inset: 0; border: 0; border-radius: 0; background: #080f16; +} +.g2-left { + position: absolute; left: 2em; top: 2em; width: 22em; max-height: calc(100% - 4em); + overflow: auto; padding: 1em; box-sizing: border-box; + background: rgba(4, 14, 22, 0.82); border: 1px solid rgba(255, 255, 255, 0.10); + border-radius: 0.5em; backdrop-filter: blur(6px); +} +.g2-right { + position: absolute; right: 2em; top: 2em; width: 23em; padding: 1em; + box-sizing: border-box; background: rgba(4, 14, 22, 0.82); + border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(6px); +} +.g2-supplies { + position: absolute; left: 0; right: 0; bottom: 0; height: 9.6em; + background: rgba(3, 10, 16, 0.82); backdrop-filter: blur(6px); +} +.g2-scene-caption { bottom: 10.5em; pointer-events: none; } +.g2-resists { margin-top: 1em; } + +.g2-head { position: relative; z-index: 5; } +.g2-scene { z-index: 0; } +.g2-left, .g2-right { z-index: 3; } +.g2-supplies { z-index: 3; } +.g2-drawer-toggle { z-index: 4; } + +.g2-exit { width: 6em; height: 6em; display: flex; align-items: center; justify-content: center; border: 0; background: transparent; cursor: pointer; } +.g2-exit:hover { background: rgba(255,255,255,.05); } +.g2-exit-icon { width: 1.5em; height: 1.5em; background: #bfd5ff; mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat; -webkit-mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat; } +.g2-exit:hover .g2-exit-icon { background: #fff; } + +/* ===================================================================== + 车库(lobby3)—— 按线上 GarageCommon / GarageMenu 结构 + ===================================================================== */ +#lobby-content.garage { + inset: 6em 0 0 0; height: calc(100% - 6em); padding: 0; overflow: hidden; z-index: 1; + background: none; +} +.ms-garage-screen { max-width: none; margin: 0; height: 100%; padding: 0; } +#garage-root { position: absolute; inset: 0; height: auto; min-height: 0; } + +.g3 { + position: absolute; inset: 0; overflow: hidden; + background: #060d14; +} +.g3-stage { position: absolute; inset: 4em 0 10.6em 0; overflow: hidden; } +.g3-canvas { + position: absolute; inset: 0; width: 100%; height: 100%; + display: block; background: #060d14; overflow: hidden; +} +.g3-vignette { + position: absolute; inset: 0; pointer-events: none; + background: + radial-gradient(120% 90% at 50% 45%, rgba(0, 0, 0, 0) 35%, rgba(0, 8, 14, 0.55) 100%), + linear-gradient(180deg, rgba(0, 12, 20, 0.35), rgba(0, 8, 14, 0.2) 40%, rgba(0, 8, 14, 0.55)); +} + +/* ---------------- 分类栏 4em(线上 52px) ---------------- */ +.g3-menu { + position: absolute; top: 0; left: 0; right: 0; height: 4em; z-index: 6; + display: flex; align-items: center; justify-content: center; +} +.g3-menu-arrow { + position: absolute; left: 1.6em; top: 50%; transform: translateY(-50%); + width: 2.6em; height: 2.6em; display: flex; align-items: center; justify-content: center; + border: 0; background: transparent; cursor: pointer; opacity: 0.85; +} +.g3-menu-arrow:hover { opacity: 1; } +.g3-menu-arrow-icon { + width: 1.2em; height: 1.2em; background: #bfd5ff; + mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat; + -webkit-mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat; +} +.g3-tabs { display: flex; align-items: stretch; height: 100%; } +.g3-tab { + position: relative; height: 100%; padding: 0 1.25em; border: 0; cursor: pointer; + background: transparent; color: #fff; + font: 400 1.0625em/1 BaseFontRegular, "Segoe UI", sans-serif; + white-space: nowrap; +} +.g3-tab:hover { color: #d8f7c7; } +.g3-tab.active { color: #76ff33; } +.g3-tab-line { + position: absolute; left: 1.25em; right: 1.25em; bottom: 0.35em; height: 0.12em; + background: #76ff33; border-radius: 0.1em; +} +.g3-menu-keys { position: absolute; right: 2em; display: flex; gap: 0.5em; } +.g3-key { + width: 2em; height: 2em; display: flex; align-items: center; justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.35); border-radius: 0.3em; + color: rgba(255, 255, 255, 0.75); font: 500 0.85em/1 BaseFontMedium, sans-serif; +} +.g3-menu-line { + position: absolute; left: 0; right: 0; bottom: 0; height: 1px; + background: rgba(255, 255, 255, 0.25); +} + +/* ---------------- 左侧描述 22.5em × 22.5em ---------------- */ +.g3-left { + position: absolute; left: 6em; top: 4em; width: 22.5em; height: 22.5em; z-index: 3; + display: flex; flex-direction: column; box-sizing: border-box; + backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); + background: linear-gradient(180deg, rgba(0, 18, 28, 0.58), rgba(0, 14, 22, 0.36)); + border: 1px solid rgba(191, 213, 255, 0.10); + border-radius: 0.5em; +} +.g3-name { + height: 2.3em; display: flex; align-items: center; padding: 0 0.4em; + font: 500 1.5em/1 BaseFontMedium, sans-serif; color: #fff; + text-shadow: 0 0.05em 0.3em rgba(0, 0, 0, 0.6); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.g3-caption { + flex: 1; min-height: 0; margin-top: 0.35em; padding: 0.56em 0.75em 0.75em; + box-sizing: border-box; border-radius: 0.5em; + background: rgba(0, 0, 0, 0.18); + display: flex; flex-direction: column; +} +.g3-caption p { + margin: 0; flex: 1; overflow: hidden; + font: 400 1em/1.45 BaseFontRegular, "Segoe UI", sans-serif; + color: rgba(255, 255, 255, 0.92); +} +.g3-caption-foot { display: flex; align-items: center; gap: 0.5em; margin-top: 0.45em; } +.g3-caption-foot span { color: #76ff33; font-size: 0.95em; cursor: pointer; } +.g3-hotkey { + margin: 0; min-width: 1.5em; height: 1.5em; padding: 0 0.3em; box-sizing: border-box; + display: flex; align-items: center; justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.45); border-radius: 0.25em; + font: 500 0.85em/1 BaseFontMedium, sans-serif; color: #fff; +} +.g3-hotkey.dark { border-color: rgba(0, 0, 0, 0.45); color: rgba(0, 0, 0, 0.75); } +.g3-device-row { display: flex; gap: 0.5em; margin-top: 0.5em; } +.g3-device { + flex: 1; height: 2.6em; display: flex; align-items: center; justify-content: center; gap: 0.45em; + border-radius: 0.35em; background: rgba(191, 213, 255, 0.10); + border: 1px solid rgba(191, 213, 255, 0.18); +} +.g3-device h2 { margin: 0; font: 500 0.95em/1 BaseFontMedium, sans-serif; color: #fff; } +.g3-device-icon { width: 1.5em; height: 1.5em; object-fit: contain; } + +/* ---------------- 右侧属性 22.5em ---------------- */ +.g3-right { + position: absolute; right: 6em; top: 4em; width: 22.5em; z-index: 3; + padding: 0.4em 0.6em 0.7em; box-sizing: border-box; + backdrop-filter: blur(30px); -webkit-backdrop-filter: blur(30px); + background: linear-gradient(180deg, rgba(0, 18, 28, 0.58), rgba(0, 14, 22, 0.36)); + border: 1px solid rgba(191, 213, 255, 0.10); + border-radius: 0.5em; +} +.g3-part-name h2 { + margin: 0 0 0.25em; font: 500 1.5em/1.15 BaseFontMedium, sans-serif; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.g3-upgrades { display: flex; gap: 0.32em; align-items: center; margin: 0.45em 0 0.2em; } +.g3-upgrade { flex: 1; height: 1.05em; border-radius: 0.18em; background: rgba(191, 213, 255, 0.16); } +.g3-upgrade.current { background: #76ff33; box-shadow: 0 0 0.5em rgba(118, 255, 51, 0.5); } +.g3-stats { display: flex; flex-direction: column; } +.g3-stat { display: flex; align-items: center; height: 1.72em; } +.g3-stat-key { flex: 1; color: #fff; font-size: 0.95em; white-space: nowrap; } +.g3-stat-val { margin-left: 0.3em; color: #fff; font-size: 0.95em; white-space: nowrap; } +.g3-stat-delta { margin-left: 0.3em; width: 2.4em; text-align: right; color: #76ff33; font-weight: 700; font-size: 0.95em; } + +/* ---------------- 两个大按钮(线上 292×78) ---------------- */ +.g3-actions { + position: absolute; right: 6em; top: 19.4em; width: 22.5em; z-index: 3; + display: flex; flex-direction: column; gap: 0.6em; +} +.g3-action { + position: relative; width: 100%; height: 6em; box-sizing: border-box; + display: flex; align-items: center; justify-content: space-between; + padding: 0 1em; border: 0; border-radius: 0.75em; cursor: pointer; + background: rgba(191, 213, 255, 0.14); + box-shadow: inset 0 0 0 1px rgba(191, 213, 255, 0.22); + color: #fff; overflow: hidden; +} +.g3-action:hover { background: rgba(191, 213, 255, 0.2); } +.g3-action.primary { background: #00d4ff; color: #001722; box-shadow: none; } +.g3-action.primary:hover { background: #3fe0ff; } +.g3-action.equipped { background: rgba(118, 255, 51, 0.16); box-shadow: inset 0 0 0 1px rgba(118, 255, 51, 0.55); } +.g3-action-icon { width: 3em; height: 3em; display: flex; align-items: center; justify-content: center; } +.g3-coin { + width: 1.6em; height: 1.6em; display: inline-block; + background: url("/play/static/images/coin.3a16e831.svg") center / contain no-repeat; +} +.g3-check { + width: 1.5em; height: 1.5em; display: inline-block; background: #76ff33; + mask: url("/play/static/images/iconChecker.90480a5c.svg") center / contain no-repeat; + -webkit-mask: url("/play/static/images/iconChecker.90480a5c.svg") center / contain no-repeat; +} +.g3-price { display: flex; align-items: center; gap: 0.35em; font: 500 1.25em/1 BaseFontMedium, sans-serif; } +.g3-action-label { font: 500 1.35em/1 BaseFontMedium, sans-serif; } +.g3-action .g3-hotkey { position: absolute; left: 4.4em; top: 1em; } + +/* ---------------- 底部物品条 ---------------- */ +.g3-carousel { + position: absolute; left: 0; right: 0; bottom: 2.6em; height: 9em; z-index: 5; + display: flex; gap: 0.6em; align-items: stretch; + padding: 0 6em; box-sizing: border-box; + overflow-x: auto; overflow-y: hidden; + scrollbar-width: none; -ms-overflow-style: none; +} +.g3-carousel::-webkit-scrollbar { display: none; } +.g3-item { + position: relative; flex: 0 0 12.5em; height: 9em; box-sizing: border-box; + padding: 0; border: 0; border-radius: 0.5em; cursor: pointer; + background: rgba(191, 213, 255, 0.15); + box-shadow: inset 0 0 0 0.06em rgba(191, 213, 255, 0.30); + overflow: hidden; text-align: left; +} +.g3-item:hover { background: rgba(191, 213, 255, 0.22); } +.g3-item.selected { + box-shadow: inset 0 0 0 0.12em #76ff33, 0 0 1.2em rgba(118, 255, 51, 0.35); +} +.g3-item-name { + padding: 0.75em 0.75em 0; color: #bfd5ff; + font: 400 0.95em/1.2 BaseFontRegular, "Segoe UI", sans-serif; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.g3-item-art { position: absolute; left: 0; right: 0; bottom: 0; height: 6.2em; display: flex; align-items: center; justify-content: center; } +.g3-item-img { max-width: 92%; max-height: 100%; object-fit: contain; filter: drop-shadow(0 0.3em 0.5em rgba(0, 0, 0, 0.55)); } +.g3-item-owned { + position: absolute; right: 0.45em; bottom: 0.4em; padding: 0.15em 0.4em; + border-radius: 0.2em; background: rgba(118, 255, 51, 0.18); + color: #76ff33; font-size: 0.72em; +} +.g3-footer-hint { + position: absolute; left: 50%; bottom: 13em; transform: translateX(-50%); + color: rgba(255, 255, 255, 0.4); font-size: 0.85em; pointer-events: none; z-index: 2; +} + +/* ---------------- 中央坦克预览 ---------------- */ +.g3-preview { + position: absolute; left: 50%; top: 48%; transform: translate(-50%, -50%); + width: 30em; height: 22em; pointer-events: none; z-index: 2; +} +.g3-preview-hull { + position: absolute; left: 0; right: 0; bottom: 1.5em; margin: auto; + width: 100%; object-fit: contain; + filter: drop-shadow(0 1.2em 1.6em rgba(0, 0, 0, 0.65)); +} +.g3-preview-turret { + position: absolute; left: 14%; top: 6%; width: 72%; object-fit: contain; + filter: drop-shadow(0 0.8em 1.2em rgba(0, 0, 0, 0.55)); +} +.g3-preview-single { + position: absolute; inset: 8% 0 0; margin: auto; max-width: 100%; max-height: 100%; + object-fit: contain; filter: drop-shadow(0 1.2em 1.6em rgba(0, 0, 0, 0.65)); +} + +@media (max-width: 1100px) { + .g3-left { left: 1.2em; width: 20em; height: 20em; } + .g3-right { right: 1.2em; width: 20em; } + .g3-actions { right: 1.2em; width: 20em; } + .g3-carousel { padding: 0 1.2em; } + .g3-menu-keys { display: none; } +} +@media (max-width: 820px) { + .g3-left, .g3-right, .g3-actions { display: none; } + .g3-preview { width: 22em; height: 16em; } + .g3-carousel { padding: 0 0.8em; } + .g3-tab { padding: 0 0.7em; font-size: 0.95em; } +} +.g3-item-icon { + width: 5.4em; height: 4.6em; object-fit: contain; opacity: 0.7; + filter: drop-shadow(0 0.3em 0.5em rgba(0, 0, 0, 0.55)); +} +.g3-item.selected .g3-item-icon { opacity: 1; } +/* 大厅 3D 背景:隐藏原静态背景图 */ +.ms-bg { display: none; } +#lobby-canvas { z-index: 0; } + +/* ---------------- 迷彩(PAINT)专用选择位置 ---------------- */ +.g3.paint-mode .g3-left, +.g3.paint-mode .g3-right, +.g3.paint-mode .g3-actions, +.g3.paint-mode .g3-carousel { display: none; } + +.g3-paints { + position: absolute; left: 6em; top: 4em; width: 22.5em; max-height: 22.5em; + display: flex; flex-direction: column; gap: 0.35em; + padding: 0.5em; box-sizing: border-box; + background: linear-gradient(180deg, rgba(0, 18, 28, 0.78), rgba(0, 14, 22, 0.60)); + border: 1px solid rgba(191, 213, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + overflow-y: auto; z-index: 4; +} +.g3-paints::-webkit-scrollbar { width: 0.35em; } +.g3-paints::-webkit-scrollbar-thumb { background: rgba(191, 213, 255, 0.25); border-radius: 0.2em; } +.g3-paint { + display: flex; align-items: center; gap: 0.5em; + width: 100%; height: 2.5em; padding: 0 0.5em; box-sizing: border-box; + border: 0; border-radius: 0.35em; cursor: pointer; + background: rgba(191, 213, 255, 0.08); color: #fff; text-align: left; +} +.g3-paint:hover { background: rgba(191, 213, 255, 0.16); } +.g3-paint.selected { box-shadow: inset 0 0 0 0.12em #76ff33; background: rgba(118, 255, 51, 0.10); } +.g3-paint-name { + font: 400 0.95em/1.2 BaseFontRegular, "Segoe UI", sans-serif; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.g3-paint-info { + position: absolute; right: 6em; top: 4em; width: 22.5em; height: 22.5em; + display: flex; flex-direction: column; box-sizing: border-box; + padding: 0.4em 0.6em 0.7em; + background: linear-gradient(180deg, rgba(0, 18, 28, 0.78), rgba(0, 14, 22, 0.60)); + border: 1px solid rgba(191, 213, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + z-index: 4; +} +.g3-paint-info .g3-caption { flex: 1; min-height: 0; } +.g3-paint-info .g3-action { margin-top: 0.75em; flex: 0 0 auto; } +.g3-menu-center { display: flex; align-items: center; height: 100%; gap: 1em; } +/* 底盘预览稍微下移,靠近 3D 平台 */ +.g3-preview { top: 52%; } +.g3-carousel { padding: 0 1em; justify-content: center; overflow: visible; } +/* 车库:默认炮塔/底盘选择条可拖动,卡片选中绿底、hover 白边 */ +.g3-carousel { + padding: 0 6em; justify-content: flex-start; overflow: visible; + cursor: grab; touch-action: none; user-select: none; +} +.g3-carousel:active { cursor: grabbing; } +.g3-item.selected { background: rgba(118, 255, 51, 0.18); box-shadow: inset 0 0 0 0.12em #76ff33; } +.g3-item:hover { box-shadow: inset 0 0 0 0.06em #ffffff; } + +/* 迷彩:按 PaintsCollectionComponentStyle 的 blockPaints / description 结构 */ +.g3-paint-desc { + position: absolute; left: 6em; top: 4em; width: 22.5em; height: 12.5em; + display: flex; flex-direction: column; box-sizing: border-box; + padding: 0.5em 0.6em; + background: linear-gradient(180deg, rgba(0, 18, 28, 0.78), rgba(0, 14, 22, 0.60)); + border: 1px solid rgba(191, 213, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + z-index: 4; +} +.g3-paint-desc .g3-caption { flex: 1; min-height: 0; } +.g3-paint-desc .g3-action { margin-top: 0.5em; flex: 0 0 auto; height: 3em; } + +.g3-paint-grid { + position: absolute; left: 6em; right: 6em; bottom: 3em; height: 12.5em; + display: flex; flex-wrap: wrap; align-content: flex-start; gap: 0.5em; + padding: 0.5em; box-sizing: border-box; overflow-y: auto; + background: linear-gradient(180deg, rgba(0, 18, 28, 0.78), rgba(0, 14, 22, 0.60)); + border: 1px solid rgba(191, 213, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); + z-index: 4; +} +.g3-paint-grid::-webkit-scrollbar { width: 0.35em; } +.g3-paint-grid::-webkit-scrollbar-thumb { background: rgba(191, 213, 255, 0.25); border-radius: 0.2em; } +.g3-paint-cell { + width: 2.5em; height: 2.5em; flex: 0 0 auto; padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 0.25em; + cursor: pointer; +} +.g3-paint-cell:hover { border-color: #ffffff; } +.g3-paint-cell.selected { border-color: #76ff33; box-shadow: 0 0 0 0.12em #76ff33; } +/* 车库右侧参数面板 / 大按钮:对齐线上 292×193 与 295×165 的位置 */ +.g3-right { height: 14.9em; overflow: hidden; } +.g3-actions { top: 18.9em; } +/* 迷彩:captionPaint + blockPaints 网格 */ +.g3-paint-panel { + position: absolute; left: 6em; right: 6em; bottom: 3em; height: 12.5em; + display: flex; flex-direction: column; overflow: hidden; z-index: 4; + background: linear-gradient(180deg, rgba(0, 18, 28, 0.78), rgba(0, 14, 22, 0.60)); + border: 1px solid rgba(191, 213, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); +} +.g3-paint-caption { + flex: 0 0 2.5em; display: flex; align-items: center; gap: 0.6em; + padding: 0 0.6em; border-bottom: 1px solid rgba(255, 255, 255, 0.10); +} +.g3-paint-caption-name { font: 500 0.95em/1 BaseFontMedium, "Segoe UI", sans-serif; color: #fff; } +.g3-paint-caption-rarity { color: rgba(255, 255, 255, 0.55); font-size: 0.85em; } +.g3-paint-fav { + margin-left: auto; width: 2em; height: 2em; padding: 0; + border: 0; background: transparent; cursor: pointer; +} +.g3-paint-fav img { width: 1.2em; height: 1.2em; object-fit: contain; } +.g3-paint-grid { + position: static; left: auto; right: auto; bottom: auto; height: auto; + flex: 1; min-height: 0; display: flex; flex-wrap: wrap; align-content: flex-start; + gap: 0.5em; padding: 0.5em; overflow-y: auto; + background: transparent; border: 0; border-radius: 0; backdrop-filter: none; z-index: auto; +} +/* 车库总览:右侧已装配件 */ +.g3.overview-mode .g3-right, +.g3.overview-mode .g3-actions, +.g3.overview-mode .g3-carousel { display: none; } +.g3:not(.overview-mode) .g3-mounted { display: none; } + +.g3-mounted { + position: absolute; right: 6em; top: 4em; width: 22.5em; z-index: 3; + display: flex; flex-direction: column; gap: 0.5em; +} +.g3-mounted-card { + display: flex; align-items: center; gap: 0.6em; + height: 7em; padding: 0.4em 0.6em; box-sizing: border-box; + background: linear-gradient(180deg, rgba(0, 18, 28, 0.72), rgba(0, 14, 22, 0.52)); + border: 1px solid rgba(191, 213, 255, 0.10); border-radius: 0.5em; + backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); +} +.g3-mounted-art { + width: 7em; height: 5.5em; flex: 0 0 auto; + display: flex; align-items: center; justify-content: center; + background: rgba(191, 213, 255, 0.06); border-radius: 0.35em; +} +.g3-mounted-art img { max-width: 100%; max-height: 100%; object-fit: contain; } +.g3-mounted-text { display: flex; flex-direction: column; gap: 0.2em; min-width: 0; } +.g3-mounted-name { font: 500 1em/1.2 BaseFontMedium, "Segoe UI", sans-serif; color: #fff; } +.g3-mounted-type { color: rgba(255, 255, 255, 0.55); font-size: 0.85em; } \ No newline at end of file diff --git a/TankiOffline/webpack.config.d/res-proxy.js b/TankiOffline/webpack.config.d/res-proxy.js index 4acfc6a..eff09a7 100644 --- a/TankiOffline/webpack.config.d/res-proxy.js +++ b/TankiOffline/webpack.config.d/res-proxy.js @@ -7,4 +7,15 @@ config.devServer.proxy = (config.devServer.proxy || []).concat([ changeOrigin: true, pathRewrite: { '^/res': '' }, }, + { + context: ['/legacy'], + target: 'http://127.0.0.1:8081', + changeOrigin: true, + pathRewrite: { '^/legacy': '' }, + }, + { + context: ['/config.xml'], + target: 'http://127.0.0.1:8081', + changeOrigin: true, + }, ]);