feat(ui): 细化非战斗子页面与车库/商店交互
This commit is contained in:
1 parent
df28be81f6
commit
233a8b5c05
23 files changed
+7938
-377
No files matched your search
@@ -1,6 +1,12 @@
|
||||
# ---- 依赖 ----
|
||||
node_modules/
|
||||
|
||||
# ---- 无头浏览器(本机工具,不入库) ----
|
||||
chrome-win64/
|
||||
|
||||
# ---- 探针/构建产物 ----
|
||||
/build/
|
||||
|
||||
# ---- 本机凭据与本地配置(含数据库密码),绝不入库 ----
|
||||
3DTank/local/
|
||||
|
||||
|
||||
@@ -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 已按线上实测值实现。
|
||||
@@ -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 模型。
|
||||
@@ -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 助手和常量映射出来,就可以不依赖运行时忠实翻译。
|
||||
@@ -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)`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
})()
|
||||
@@ -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),
|
||||
};
|
||||
})()
|
||||
@@ -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 };
|
||||
})()
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
|
||||
/** 等级图标:10–31 级有官方资源;低等级回退到 10 级图标。 */
|
||||
private fun RankIcon(Rank: Int): String {
|
||||
val R = if (Rank in 10..31) Rank else 10
|
||||
val Hash = mapOf(
|
||||
10 to "17447044", 11 to "f4ddcab2", 12 to "c8158aa7", 13 to "2baa3518",
|
||||
14 to "1fcc1281", 15 to "eaafa648", 16 to "9d0599ee", 17 to "dd67d583",
|
||||
18 to "e86c45fa", 19 to "bc7f519b", 20 to "76063620", 21 to "cef08a4f",
|
||||
22 to "0bc84156", 23 to "3c31b410", 24 to "e5342906", 25 to "ef416fe8",
|
||||
26 to "128b1504", 27 to "c9a72f49", 28 to "b96f89b9", 29 to "ee5f90ba",
|
||||
30 to "5c2d92a0", 31 to "91cbb062",
|
||||
)[R] ?: "eaafa648"
|
||||
return LAsset("static/images/$R.$Hash.webp")
|
||||
}
|
||||
|
||||
class LobbyView(private val Client: LobbyClient) {
|
||||
|
||||
private var Nav: String = "home"
|
||||
private var Mounted = false
|
||||
private var GarageMounted = false
|
||||
private var ScreenMounted = false
|
||||
private var MountedScreen = ""
|
||||
|
||||
fun Mount() {
|
||||
if (Mounted) return
|
||||
Mounted = true
|
||||
UiCatalog.Ensure(Client) { }
|
||||
LDoc.getElementById("app").innerHTML = Shell()
|
||||
Bind()
|
||||
HeadMount.Mount()
|
||||
HomeMount.Mount()
|
||||
OverlayMount.Mount()
|
||||
BattleHudMount.Mount()
|
||||
Update()
|
||||
}
|
||||
|
||||
@@ -68,71 +65,115 @@ class LobbyView(private val Client: LobbyClient) {
|
||||
val Bounds = U.RankBounds as? UserRankBounds ?: UserRankBounds()
|
||||
val Low = Bounds.LowBound
|
||||
val Top = if (Bounds.TopBound > 0) Bounds.TopBound else Low + 1
|
||||
val Ratio = ((U.Score - Low).toDouble() / (Top - Low).coerceAtLeast(1)).coerceIn(0.0, 1.0)
|
||||
|
||||
SetHtml(
|
||||
"ms-top-left",
|
||||
"""
|
||||
<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,
|
||||
},
|
||||
]);
|
||||
Reference in new issue
Block a user