feat(ui): 细化非战斗子页面与车库/商店交互

This commit is contained in:
TermiNexus committed 2026-10-26 17:17:53 +08:00
1 parent df28be81f6
commit 233a8b5c05
23 files changed
+7938 -377

No files matched your search

+6
View File
@@ -1,6 +1,12 @@
# ---- 依赖 ----
node_modules/
# ---- 无头浏览器(本机工具,不入库) ----
chrome-win64/
# ---- 探针/构建产物 ----
/build/
# ---- 本机凭据与本地配置(含数据库密码),绝不入库 ----
3DTank/local/
+122
View File
@@ -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:<ratio>%; 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 已按线上实测值实现。
+57
View File
@@ -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 模型。
+186
View File
@@ -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 助手和常量映射出来,就可以不依赖运行时忠实翻译。
+113
View File
@@ -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)`);
+252
View File
@@ -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 路径:<repo>/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);
});
+37
View File
@@ -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,
};
})()
+89
View File
@@ -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),
};
})()
+16
View File
@@ -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 };
})()
+40
View File
@@ -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()),
};
})()
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
/** 等级图标: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",
"""
<div class="premium-block ${if (U.HasPremium) "on" else "off"}">
<i class="premium-icon"></i>
</div>
<div class="group-block">
<i class="group-icon g1"></i><i class="group-icon g2"></i>
</div>
<div class="rank-icon"><img src="${RankIcon(U.Rank)}" alt=""></div>
<div class="user-progress">
<span class="user-name">${LEsc(U.Uid.ifEmpty { "未登录" })}</span>
<div class="xp-bar"><i style="width:${(Ratio * 100).toInt()}%"></i></div>
<div class="xp-text"><i class="xp-icon"></i>${U.Score}<span> / ${if (Bounds.TopBound > 0) Top else "—"}</span></div>
</div>
"""
)
SetHtml(
"ms-top-right",
"""
<div class="ms-currencies">
<div class="ms-currency">
<img class="coin" src="${LAsset("static/images/ruby.ce62efbc.svg")}" alt="">
<span>${U.Rubies}</span>
<img class="plus" src="${LAsset("static/images/addRuby.ff47c8be.svg")}" alt="">
</div>
<div class="ms-currency">
<img class="coin" src="${LAsset("static/images/crystalSmall.242e6c15.svg")}" alt="">
<span>${U.Crystals}</span>
<img class="plus" src="${LAsset("static/images/addCrystal.af9a397c.svg")}" alt="">
</div>
</div>
<div class="ms-fullscreen"><i></i><b>F11</b></div>
<button class="ms-logout" data-go="logout"><i></i></button>
"""
)
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", """<div id="screen-root"></div>""")
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) {
<div class="ms-bg"></div>
<header id="ms-top" class="ms-top">
<div id="ms-top-left" class="ms-top-left"></div>
<div class="ms-top-logo"><img src="${LAsset("static/images/logoTankiOnlineChina.0b6ba850.svg")}" alt=""></div>
<div id="ms-top-right" class="ms-top-right"></div>
<div id="head-root" style="width:100%;height:100%"></div>
</header>
<div id="ms-home" class="ms-home">
<div class="ms-left-gradient">
<button class="ms-play" data-go="battles">
<span>${LEsc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</span>
</button>
<ul class="ms-menu">
${MenuItem("containers", "Containers", "幸运礼盒")}
${MenuItem("quests", "Quests", "任务")}
${MenuItem("garage", "Garage", "车库")}
${MenuItem("shop", "Shop", "商店")}
${MenuItem("settings", "Option", "设置")}
</ul>
<footer class="ms-footer">
<ul>
${FooterItem("news", "News")}
${FooterItem("chat", "Chat")}
${FooterItem("friends", "Friends")}
${FooterItem("clans", "Clan")}
</ul>
</footer>
</div>
<div class="ms-right-gradient"></div>
<div class="ms-battle-pass">
<div class="ms-bp-head">
<span><img src="${LAsset("static/images/star.01f7e4f8.svg")}" alt="">${Client.Router.Store.State.Challenges.Stars.values.sum()}</span>
<span class="muted">星级挑战</span>
</div>
<div class="ms-bp-row"><i></i><div><b>等级 ${Client.Router.Store.State.Challenges.BattlePasses.size}</b><span class="muted">星级挑战</span></div></div>
<div class="ms-bp-row locked"><i></i><div><b>精英通行证</b><span class="muted">未解锁</span></div></div>
</div>
<div class="ms-announce">
<div class="ms-announce-art"></div>
<div class="ms-announce-title">${LEsc(Translations.T("lobby.menu.NEWS", "新闻"))}</div>
</div>
<div id="home-root"></div>
</div>
<button id="ms-back" class="ms-back hidden" data-go="home">← 返回</button>
<main id="lobby-content" class="ms-content hidden"></main>
<div id="overlay-root" style="position:absolute;inset:0;z-index:200;pointer-events:none"></div>
<div id="battle-hud-root" style="position:absolute;inset:0;z-index:50;pointer-events:none"></div>
<footer id="client-info" class="ms-client-info"></footer>
</div>
""".trimIndent()
private fun MenuItem(Id: String, Key: String, Fallback: String): String = """
<li class="ms-menu-item" data-go="$Id">
<div class="ms-menu-icon icon-$Id"></div>
<span>${LEsc(Translations.T("lobby.menu.${Key.uppercase()}", Fallback))}</span>
</li>
""".trimIndent()
private fun FooterItem(Id: String, Key: String): String = """
<li class="ms-footer-item" data-go="$Id">
<div class="ms-footer-icon icon-$Id"></div>
</li>
""".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 """<div id="screen-root"></div>"""
private fun Garage(): String {
if (!UiCatalog.Loaded) {
UiCatalog.Ensure(Client) { Refresh() }
return """<div class="ms-screen"><div class="ms-empty">正在加载物品目录…</div></div>"""
return """<div class="ms-screen"><div class="ms-empty">?????????</div></div>"""
}
return """<div class="ms-screen ms-garage-screen"><div id="garage-root"></div></div>"""
}
private fun Shop(): String {
val All = Client.Router.Catalog.Items.values.filter { it.buyable }
return """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.SHOP", "商店"))}<small>${All.size} 件可购买</small></h2>
<div class="ms-empty">${if (All.isEmpty()) "离线目录里暂无可购买物品。" else "商店卡片将在下一轮迁移。"}</div>
</div>
""".trimIndent()
}
private fun Containers(): String {
val N = Client.Router.Catalog.Items.values.count { it.itemCategory == 13 }
return """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.CONTAINERS", "幸运礼盒"))}<small>$N</small></h2>
<div class="ms-empty">礼盒开启动画与掉落表待接入。</div>
</div>
""".trimIndent()
}
private fun Battles(): String = """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.battle.list.TITLE", "战场列表"))}</h2>
<div class="ms-empty">战斗选择 / 匹配属于 S4;当前先接入真实大厅外壳。</div>
</div>
""".trimIndent()
private fun Quests(): String = """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.quests.navigation.HEADER", "任务"))}</h2>
<div class="ms-empty">任务列表将在下一轮迁移。</div>
</div>
""".trimIndent()
private fun Chat(): String {
val Lines = Client.Logs.takeLast(20).joinToString("") { "<div class=\"ms-log-line\">${LEsc(it)}</div>" }
return """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.CHAT", "聊天"))}</h2>
<div class="ms-chat-box">${if (Lines.isEmpty()) "<span class=\"muted\">连接后显示服务器日志</span>" else Lines}</div>
</div>
""".trimIndent()
}
private fun News(): String {
val Items = Client.Router.Store.State.News.News
return """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.NEWS", "新闻"))}</h2>
${if (Items.isEmpty()) "<div class=\"ms-empty\">暂无新闻。</div>"
else "<div class=\"ms-news-list\">" + Items.take(20).joinToString("") { N ->
"<article class=\"ms-news\"><b>${LEsc(N.header)}</b><p>${LEsc(N.description)}</p></article>"
} + "</div>"}
</div>
""".trimIndent()
}
private fun Settings(): String = """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.OPTIONS", "设置"))}</h2>
<div class="ms-empty">设置页将在下一轮迁移(游戏 / 画面 / 声音 / 操控 / 账户 …)。</div>
</div>
""".trimIndent()
private fun Placeholder(Title: String, Text: String): String = """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Title)}</h2>
<div class="ms-empty">${LEsc(Text)}</div>
</div>
""".trimIndent()
private fun SetHtml(Id: String, Html: String) {
val El = LDoc.getElementById(Id) ?: return
El.innerHTML = Html
@@ -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<Props> {
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
}
}
@@ -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<String> = 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<Props> {
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
}
}
@@ -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<Pair<String, String>> {
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<CatalogItem>,
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<Props> {
var Category by useState(-1)
var Query by useState("")
var Selected by useState<CatalogItem?>(null)
var Drawer by useState(false)
var SelectedId by useState<Long?>(null)
var MountedId by useState<Long?>(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() {
@@ -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<Props> {
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())
}
}
@@ -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<Any?> = 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<Props> {
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
}
}
File diff suppressed because it is too large. Load diff
@@ -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
}
@@ -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 };
@@ -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();
@@ -5,11 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TankiOffline · 大厅</title>
<link rel="stylesheet" href="entrance.css?v=6" />
<link rel="stylesheet" href="lobby2.css" />
<link rel="stylesheet" href="lobby3.css?v=25" />
<script type="importmap">
{ "imports": { "three": "/vendor/three/three.module.js" } }
</script>
<script type="module" src="/Garage3D2.js"></script>
<script type="module" src="/Lobby3D.js?v=25"></script>
<script type="module" src="/Garage3D3.js?v=25"></script>
</head>
<body>
<div id="app"></div>
@@ -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; }
@@ -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,
},
]);