feat(ui): 接入官方资源镜像与原版登录页,搭建大厅骨架与 UI 计划书
This commit is contained in:
1 parent
1d1479592f
commit
6c1e8efb49
14 files changed
+1719
-1
No files matched your search
+14
@@ -35,3 +35,17 @@ tools/symbol_map.json
|
||||
/1
|
||||
/curlcmd
|
||||
/i.html
|
||||
|
||||
# ---- UI 资源镜像(开发用本地镜像;生产走 CDN/反代,不入库) ----
|
||||
TankiOffline/app/src/jsMain/resources/play/
|
||||
|
||||
# ---- UI / 调试临时产物 ----
|
||||
bud/
|
||||
3DTank/tmp_old_*.js
|
||||
3DTank/names/*_trace.json
|
||||
3DTank/names/slice_*.json
|
||||
3DTank/names/decoded_frames.txt
|
||||
3DTank/names/regdump_model.json
|
||||
3DTank/names/action_trace*.json
|
||||
__pycache__/
|
||||
/1.html
|
||||
@@ -537,6 +537,17 @@
|
||||
生成器现在读取该表,跳过「实际类型 ≠ 语义模型名」的方法(4 条),
|
||||
不再生成必然 ClassCastException 的映射;清单记在 `downlink_actions.json` 的
|
||||
`SkippedTypeConflicts`。
|
||||
- 浏览器启动器(S3 验收载体):
|
||||
- `App/Browser/LobbyClient.kt`:大厅 WS(Hash Request → Hash Response / Open Space)→
|
||||
空间 WS(EnterSpace → LoadDependencies 回 `G.q1wd`)→ `SpaceSession.Feed` → `SessionRouter`。
|
||||
与 AutoTest 的差别是不退出进程,状态通过 `OnChange` 推给 UI,错误只记日志。
|
||||
- `App/Browser/BrowserUi.kt` + `app/src/jsMain/resources/index.html`:极简 DOM 视图,
|
||||
展示连接状态、用户属性、当前屏幕、物品目录规模与最近日志。
|
||||
- `Main.kt` 在非 Node 环境进入浏览器入口;Node 下的自检逻辑原样保留。
|
||||
- 运行:`gradlew :app:jsBrowserDevelopmentRun`(默认 `http://localhost:8080/`),
|
||||
可用 `?lobby=ws://…&space=ws://…` 预填地址。
|
||||
- `webpack.config.d/node-fallback.js` 把 `fs` / `path` 解析成空模块:
|
||||
jsMain 与 Node 自检共用源码,浏览器分支不会调用那些 `module.require`,但 webpack 会静态扫描到。
|
||||
- 委派表里的已知形态:`shop` / `home` / `localBattleUserState` / `partner` 是内联 IIFE,
|
||||
已按字段具名为 `(inline@<field>)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个);
|
||||
`launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map<String, String>`。
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# TankiOffline UI 重建计划书(全站 UI)
|
||||
|
||||
> 目标:把原客户端(`module_82347` 大厅/车库/商店/军团/结算 UI + `module_21300` 战斗 HUD)
|
||||
> 还原到 Kotlin/JS 新工程,最终覆盖**全站所有界面**,并与真实状态 / 协议数据打通。
|
||||
> 本文件用于对齐范围、技术栈、里程碑与验收口径。
|
||||
|
||||
## 0. 已确认决策(2026-10-25)
|
||||
|
||||
| 议题 | 决策 |
|
||||
|---|---|
|
||||
| 技术栈 | **Kotlin/JS + kotlin-react + Emotion**(与现工程同栈) |
|
||||
| 还原精度 | **组件级还原**;背景/摄像机是动态的,不做像素 diff,由人工验收,尽可能贴近 |
|
||||
| 数据源 | **本地 mock 服务器**(协议级 mock,稳定产出用户/物品/大厅数据) |
|
||||
| 原版账号 | 有(`WYT2WYT`),仅用于线上获取必要信息(DOM/CSS/资源/文案),**不做逐屏截图对拍** |
|
||||
| 3D | **现在接 WebGL**:先做 3D 资源加载器与车库预览,再逐步接 `92980` 渲染管线 |
|
||||
| 首批屏幕 | **大厅骨架 + 车库** |
|
||||
| 资源部署 | 开发用本地镜像;生产走 CDN / 反向代理 |
|
||||
| 混跑策略 | 允许长期保留部分原 JS UI,按屏幕逐步替换为 Kotlin |
|
||||
|
||||
---
|
||||
|
||||
## 1. 调研结论(先纠正三个认知)
|
||||
|
||||
### 1.1 原前端不是手写 React,而是 **Kotlin/JS + React + Emotion**
|
||||
|
||||
证据:
|
||||
|
||||
| 证据 | 说明 |
|
||||
|---|---|
|
||||
| `module_glossary`:`78931` = React 18.3.1、`45555` = React-DOM 18.3.1 | 运行时确实是 React |
|
||||
| `94090` = “React 封装的内部 UI 构建工具 RBuilder” | Kotlin 侧的 React DSL |
|
||||
| `82347` 的代码形态:`Sq(d7n, "MainScreenComponentStyle", $q, h$)`、`this.aux([], K2n)`、`Eq("containerPanel", 1, …)` | 这是 Kotlin/JS 编译后的 styled-component 构造,不是手写 JS |
|
||||
| 类名如 `MainScreenComponentStyle-containerPanel`、`RoundBigButtonComponentStyle-outerCircle` | Emotion 生成的组件样式名 |
|
||||
| `82347` 依赖 `10032 / 18334 / 61860 / 87072 / 92980 / 61378 / 85196 …` | UI 直接吃状态、服务、渲染、战斗系统 |
|
||||
|
||||
**结论**:另起一个 React/TS 工程不是“更原版”,反而会丢掉 Kotlin 数据模型。
|
||||
推荐在现有 Kotlin/JS 工程里加 **kotlin-react + Emotion**,逐屏移植;纯 React/TS 只作为备选。
|
||||
|
||||
### 1.2 原 UI 的体量与耦合
|
||||
|
||||
- `82347`:5.50 MB,约 21.3 万行,覆盖大厅 / 车库 / 商店 / 军团 / 好友 / 新闻 / 设置 / 结算 / 弹窗。
|
||||
- `21300`:战斗内 HUD(血条、准星、道具、计分板、聊天…)。
|
||||
- 大量组件直接依赖:
|
||||
- 状态:`RootState` 的 89 个切片(已迁移 89/89);
|
||||
- 服务:`18334` 的 509 处动作派发、物品目录(已迁移 `ItemCatalog`);
|
||||
- 渲染:`92980` WebGL(车库 3D 预览、战斗场景);
|
||||
- 战斗:`87072`(HUD 数据)。
|
||||
- 因此 UI 必须分层:**布局/样式可先独立还原,数据和 3D 后接**。
|
||||
|
||||
### 1.3 资源已经齐了
|
||||
|
||||
- 镜像:`3DTank/web/play/static`(1239 文件 / 71.4 MB,含 images / fonts / translations / sound / videos / textdata)。
|
||||
- 已复制到:`TankiOffline/app/src/jsMain/resources/play/static`,开发服务器直接以 `/play/static/...` 提供。
|
||||
- 官方翻译:`/play/static/translations/zh-CN.61d6ab60.json`(运行时加载,已接)。
|
||||
- 可直接引用的资源 id → 路径:`3DTank/src/resources.json` 的 `resourceMap`。
|
||||
- 可选:`3DTank/Start-LocalAndRelay.ps1` 启动镜像 + 中继(抓包/真实服务器)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术方案
|
||||
|
||||
### 2.1 推荐方案 A:Kotlin/JS + kotlin-react + Emotion(与现工程同栈)
|
||||
|
||||
```
|
||||
TankiOffline/
|
||||
ui/ # 新:React 组件(Kotlin)
|
||||
src/jsMain/kotlin/Ui/
|
||||
Theme.kt # 颜色/字体/间距 token(从原 Emotion 样式抽出)
|
||||
Components/ # Button / Panel / Tabs / Dialog / ItemCard ...
|
||||
Screens/ # Lobby / Garage / Shop / Battles / Quests ...
|
||||
Layout/ # MainScreen / Header / Menu / Chat / Footer
|
||||
app/ # 入口:Entrance → 连接 → Lobby
|
||||
state/ # 已有 RootState
|
||||
net/ # 已有协议
|
||||
```
|
||||
|
||||
依赖:
|
||||
|
||||
```kotlin
|
||||
implementation(npm("react", "18.3.1"))
|
||||
implementation(npm("react-dom", "18.3.1"))
|
||||
implementation(npm("@emotion/react", "11.x"))
|
||||
// Kotlin 侧可用 kotlin-react / kotlin-emotion 包装,或直接 external 声明
|
||||
```
|
||||
|
||||
数据流:
|
||||
|
||||
```
|
||||
SessionRouter (Store) ──订阅──> UiStore (Adapter) ──props──> React 组件
|
||||
ItemCatalog ───────────────────┘
|
||||
Session 状态(连接/帧/日志)─────┘
|
||||
```
|
||||
|
||||
优点:复用现有状态类;一套 Gradle 构建;后续可直接接 3D/战斗。
|
||||
缺点:Kotlin React 组件写法与原始编译产物不完全一致,需要逐屏翻译。
|
||||
|
||||
### 2.2 备选方案 B:独立 React/TS 工程
|
||||
|
||||
```
|
||||
webui/ (Vite + React + TS + Emotion)
|
||||
↕ WebSocket / postMessage
|
||||
TankiOffline (Kotlin/JS 客户端)
|
||||
```
|
||||
|
||||
优点:前端生态成熟、UI 开发快、便于像素级还原。
|
||||
缺点:状态模型要跨语言复制/序列化;构建两套;长期维护成本高。
|
||||
|
||||
### 2.3 备选方案 C:混跑原 JS 模块
|
||||
|
||||
直接把 `module_82347` 作为 external 挂到 `window.__modules__`,用现有 Kotlin 状态通过 bridge 驱动。
|
||||
优点:**保真度最高**、最快看到完整原版 UI。
|
||||
缺点:不是“重建”,依赖原 bundle;长期难以维护。
|
||||
可作为 **过渡验收**(视觉基准)而不是最终形态。
|
||||
|
||||
### 2.4 保真度分层
|
||||
|
||||
| 级别 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| L0 | 结构骨架(当前) | 能导航、无报错 |
|
||||
| L1 | 官方资源 / 字体 / 布局比例 | 截图肉眼接近 |
|
||||
| L2 | 像素级静态还原 | 与原版截图逐屏 diff |
|
||||
| L3 | 真实数据 + 交互 | 状态变化驱动 UI |
|
||||
| L4 | 3D 车库 / 战斗场景 | WebGL 管线接入 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 里程碑
|
||||
|
||||
### M0 基础设施(进行中)
|
||||
- [x] 资源镜像接入(`/play/static`)。
|
||||
- [x] 登录页 1:1(EntranceView + entrance.css)。
|
||||
- [ ] `ui` 模块启用 kotlin-react + Emotion,建立 `Theme.kt`。
|
||||
- [ ] 协议级 mock 服务器(用户属性 / 物品目录 / 大厅列表)。
|
||||
- [ ] `ScreenRegistry`:屏幕 ID ↔ 组件(对齐原 `ScreenId`)。
|
||||
- [ ] `UiStore`:订阅 `SessionRouter.Store`,节流重绘。
|
||||
- [ ] 3D 资源加载器(`.3ds` / 贴图 / 场景描述)+ 车库预览最小场景。
|
||||
- [ ] 截图 / DOM 基准工具(人工验收辅助,不做像素门禁)。
|
||||
|
||||
### M1 大厅骨架(2 个迭代)
|
||||
- 顶栏:等级/昵称/GS/水晶/金币/VIP/设置/声音。
|
||||
- 左侧菜单:9 个官方图标 + 文案 + 选中态。
|
||||
- 底部:大号“开始战斗”按钮、聊天面板、新闻条。
|
||||
- 公告/活动/每日任务浮层。
|
||||
- 验收:与官方大厅截图逐项对齐(无 3D 场景时用静态背景占位)。
|
||||
|
||||
### M2 车库(3 个迭代)
|
||||
- 分类页签(12 类,来自 `ItemCategory` 枚举)。
|
||||
- 物品网格:名称/稀有度/数量/VIP/价格;数据源 `ItemCatalog`。
|
||||
- 物品详情:属性、升级、装备、皮肤。
|
||||
- 3D 预览:接 M0 的 3D 加载器,先渲染底盘/炮塔/贴图与旋转,再补光照/摄像机。
|
||||
|
||||
### M2.5 3D / 渲染(并行)
|
||||
- 3DS / 贴图 / 资源清单加载。
|
||||
- 车库场景:平台、背景、坦克组装、光照、摄像机环绕。
|
||||
- 与 `92980` 渲染管线的接口对齐;最终替换为原引擎实现。
|
||||
|
||||
### M3 商店 / 幸运礼盒(2 个迭代)
|
||||
- 商店分类、卡片、折扣、支付方式、礼盒开启动画占位。
|
||||
- 数据:`Shop` 切片 + `ItemCatalog`。
|
||||
|
||||
### M4 战斗选择 / 任务 / 军团 / 好友 / 新闻(3 个迭代)
|
||||
- 战斗模式卡片、战斗列表、创建战场表单。
|
||||
- 任务:主线/每日/每周/星级挑战/合约。
|
||||
- 军团/好友:列表、搜索、请求、成员管理。
|
||||
- 新闻:列表 + 详情。
|
||||
|
||||
### M5 设置 / 弹窗体系(2 个迭代)
|
||||
- 设置 8 个页签(游戏/画面/声音/操控/账户/战斗/安全/支持)。
|
||||
- 通用 Dialog / Toast / 确认框 / 购买弹窗 / 升级弹窗 / 排名弹窗。
|
||||
|
||||
### M6 结算与 HUD(依赖 S5)
|
||||
- 战斗结算、奖励、排名变化。
|
||||
- 战斗 HUD(`21300`):血条、道具、准星、计分板、聊天。
|
||||
|
||||
---
|
||||
|
||||
## 4. 每屏的数据契约(先定,避免返工)
|
||||
|
||||
| 屏幕 | 主要数据 | 现有来源 | 缺口 |
|
||||
|---|---|---|---|
|
||||
| Entrance | 语言/资源/连接状态 | `Translations` / `LobbyClient` | 真实登录 |
|
||||
| Lobby Home | User / News / Notifications / Quests 摘要 | RootState 已迁移 | 公告、活动配置 |
|
||||
| Garage | ItemCatalog / Garage 切片 / GaragePreview | 已有 | 3D 预览、装备操作 |
|
||||
| Shop | Shop 切片 / ItemCatalog | 已有 | 支付流程 |
|
||||
| Battles | BattlesSelect / Maps / Matchmaking | 已有 | 创建/匹配 |
|
||||
| Quests | Quests / Challenges / ContractState | 已有 | 领奖链路 |
|
||||
| Clans | Clan / ClanStorage / ClanChat | 已有 | 社交操作 |
|
||||
| Friends | Friends 切片 | 已有 | 搜索/请求 |
|
||||
| News | News 切片 | 已有 | 详情页 |
|
||||
| Settings | 8 个设置切片 | 已有 | 持久化 port |
|
||||
| HUD | BattleStatistics / BattleUsers / Supplies | 部分 | S5 战斗系统 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 验收方式
|
||||
|
||||
1. **视觉基准**:原版客户端逐屏截图(需要可登录环境);新 UI 同分辨率截图,人工/像素 diff。
|
||||
2. **DOM 基准**:同一数据下对比关键元素的尺寸、间距、颜色 token。
|
||||
3. **交互基准**:点击路径一致(菜单、返回、弹窗层级)。
|
||||
4. **数据基准**:同一抓包回放后,界面显示与状态断言一致(可自动化)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与待决事项
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
|---|---|---|
|
||||
| 没有可登录账号,拿不到原版大厅截图/DOM | 无法做像素级对比 | 用测试账号 / 本地 mock 服务器 / 仅按源码还原 |
|
||||
| 3D 车库与战斗场景 | 大厅“灵魂”缺失 | 先用静态背景 + 官方素材,S4 后替换 |
|
||||
| `82347` 21 万行,逐屏翻译量大 | 周期长 | 按 M1–M5 切片,先布局后逻辑 |
|
||||
| 原样式是 Emotion 动态类名 | 难以直接复用 CSS | 抽 token + 按截图复写;或运行时抓取 CSS |
|
||||
| 资源 71 MB 进包 | 首屏体积 | 开发用镜像;生产走 CDN/代理 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前进度(本文件创建时)
|
||||
|
||||
- 登录页:1:1 完成(官方背景、logo、圆形登录按钮、健康忠告、年龄提示、ClientInfo)。
|
||||
- 大厅:L0/L1 骨架(官方图标、真实翻译、物品目录、连接状态)。
|
||||
- 资源:本地镜像已接入。
|
||||
- 已确认:技术栈 Kotlin/React、组件级还原、本地 mock、3D 现在做、首批大厅+车库、允许混跑。
|
||||
@@ -0,0 +1,36 @@
|
||||
// BrowserUi.kt —— 浏览器入口:原版登录页 → 大厅
|
||||
//
|
||||
// EntranceView(1:1 复刻官方入口)→ 连接成功 → LobbyView(大厅骨架)
|
||||
// 连接 / 解码 / 路由在 LobbyClient,文案在 Translations。
|
||||
package App.Browser
|
||||
|
||||
fun BrowserMain() {
|
||||
lateinit var Client: LobbyClient
|
||||
lateinit var Lobby: LobbyView
|
||||
lateinit var Entrance: EntranceView
|
||||
|
||||
Client = LobbyClient {
|
||||
if (Client.Status == "已连接") {
|
||||
Lobby.Mount()
|
||||
Lobby.Update()
|
||||
} else {
|
||||
Entrance.Update()
|
||||
}
|
||||
}
|
||||
|
||||
Lobby = LobbyView(Client)
|
||||
Entrance = EntranceView(Client) {
|
||||
Lobby.Mount()
|
||||
Lobby.Update()
|
||||
}
|
||||
Entrance.Mount()
|
||||
Translations.Load { Entrance.Update() }
|
||||
|
||||
// 调试便利:?autoconnect=1&lobby=ws://…&space=ws://…
|
||||
val Params = js("new URLSearchParams(window.location.search)")
|
||||
if (Params.get("autoconnect") == "1") {
|
||||
val LobbyUrl = (Params.get("lobby") as? String) ?: "ws://127.0.0.1:9091/"
|
||||
val SpaceUrl = (Params.get("space") as? String) ?: LobbyUrl
|
||||
Client.Connect(LobbyUrl, SpaceUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// EntranceView.kt —— 原版登录页(1:1 复刻官方入口)
|
||||
//
|
||||
// 结构 / 尺寸 / 字体 / 资源全部取自线上 3dtank.com/play/ 的 Entrance 屏:
|
||||
// .wrapper → -changingBackground → -entranceGradient
|
||||
// ├ HeaderComponentStyle-linkBar(左图标、中 logo、右声音)
|
||||
// ├ EntranceComponentStyle-ContainerForm(标题 + 圆形「游戏账号」按钮)
|
||||
// └ ApplicationLoaderComponentStyle-promptInformation(健康游戏忠告)
|
||||
// 左下年龄提示、右下 ClientInfo。
|
||||
// 「游戏账号」→ 打开连接面板(真实登录链路未接,先用中继地址进入大厅)。
|
||||
package App.Browser
|
||||
|
||||
private val Edoc: dynamic = js("document")
|
||||
|
||||
private fun EAsset(Path: String): String = "/play/" + Path
|
||||
|
||||
class EntranceView(private val Client: LobbyClient, private val OnEnterLobby: () -> Unit) {
|
||||
|
||||
private var Mounted = false
|
||||
private var PanelOpen = false
|
||||
private var Error: String = ""
|
||||
|
||||
fun Mount() {
|
||||
if (Mounted) return
|
||||
Mounted = true
|
||||
Edoc.getElementById("app").innerHTML = Markup()
|
||||
Bind()
|
||||
}
|
||||
|
||||
fun Update() {
|
||||
if (!Mounted) return
|
||||
val St = Edoc.getElementById("entrance-status")
|
||||
if (St != null) {
|
||||
St.textContent = Error.ifEmpty { Client.Status }
|
||||
St.classList.toggle("error", Error.isNotEmpty())
|
||||
}
|
||||
val Panel = Edoc.getElementById("entrance-connect")
|
||||
if (Panel != null) Panel.classList.toggle("hidden", !PanelOpen)
|
||||
if (Client.Status == "已连接") OnEnterLobby()
|
||||
}
|
||||
|
||||
private fun Markup(): String = """
|
||||
<div class="wrapper">
|
||||
<div class="bg-layer" style="background-image:url('${EAsset("static/images/background.fc2779c1.webp")}')"></div>
|
||||
<div class="entrance-gradient">
|
||||
<header class="link-bar">
|
||||
<img class="icon-tank" src="${EAsset("static/images/iconTankOnline.e3b07fd0.svg")}" alt="">
|
||||
<img class="logo-tank" src="${EAsset("static/images/logoTankiOnlineChina.0b6ba850.svg")}" alt="3D坦克">
|
||||
<button class="sound-btn" title="声音"><i class="sound-on"></i></button>
|
||||
</header>
|
||||
|
||||
<div class="content-space">
|
||||
<div class="entrance-form">
|
||||
<form onsubmit="return false">
|
||||
<div><span class="entrance-title">通过以下方式登录</span></div>
|
||||
<div class="entrance-center">
|
||||
<button id="login-account" class="round-big" type="button">
|
||||
<div class="round-outer"><div class="round-middle"><div class="round-inner">
|
||||
<img src="${EAsset("static/images/tanki_online_white.b4613c5f.svg")}" alt="">
|
||||
</div></div></div>
|
||||
<span>游戏账号</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legal">
|
||||
游戏适合12岁以上用户使用,为了您的健康,请合理控制游戏时间。健康游戏忠告:抵制不良游戏,拒绝盗版游戏。
|
||||
注意自我保护,谨防受骗上当。适度游戏益脑,沉迷游戏伤身。合理安排时间,享受健康生活。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img class="age-warn" src="${EAsset("static/images/age_warn.6479e9e3.webp")}" alt="12+">
|
||||
<div class="client-info">
|
||||
<span>TYPE: web</span><i>|</i><span>VER: Kotlin/JS</span><i>|</i>
|
||||
<span>SRC: TankiOffline</span><i>|</i><span>STATE: S3</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="entrance-connect" class="connect-modal hidden">
|
||||
<div class="connect-card">
|
||||
<h2>连接中继</h2>
|
||||
<p class="muted">真实登录链路尚未接入,这里直接连接本地 / 远程中继来进入大厅。</p>
|
||||
<label>大厅地址<input id="entrance-lobby-url" spellcheck="false" value="ws://127.0.0.1:9091/"></label>
|
||||
<label>空间地址<input id="entrance-space-url" spellcheck="false" value="ws://127.0.0.1:9091/"></label>
|
||||
<div class="connect-actions">
|
||||
<button id="entrance-connect-go" class="primary">连接</button>
|
||||
<button id="entrance-connect-close">取消</button>
|
||||
</div>
|
||||
<div id="entrance-status" class="connect-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Bind() {
|
||||
Edoc.getElementById("login-account").addEventListener("click", {
|
||||
PanelOpen = true
|
||||
Error = ""
|
||||
Update()
|
||||
})
|
||||
Edoc.getElementById("entrance-connect-close").addEventListener("click", {
|
||||
PanelOpen = false
|
||||
Update()
|
||||
})
|
||||
Edoc.getElementById("entrance-connect-go").addEventListener("click", {
|
||||
val Lobby = (Edoc.getElementById("entrance-lobby-url").value as String).trim()
|
||||
val Space = (Edoc.getElementById("entrance-space-url").value as String).trim()
|
||||
if (Lobby.isEmpty()) {
|
||||
Error = "大厅地址不能为空"
|
||||
Update()
|
||||
} else {
|
||||
Error = ""
|
||||
Client.Connect(Lobby, if (Space.isEmpty()) Lobby else Space)
|
||||
Update()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// LobbyClient.kt —— 浏览器版「大厅 → 空间」连接(S3 启动器)
|
||||
//
|
||||
// 复用 AutoTest 里已经验证过的入口握手:
|
||||
// 大厅 WS:open → Hash Request;收到 Hash Response(digest)+ Open Space(spaceId)
|
||||
// 空间 WS:open → EnterSpace(digest, spaceId);收到 LoadDependencies(zt) 后回 G.q1wd(X)
|
||||
// 之后所有空间字节交给 SpaceSession(解帧 / 解密 / 解记录),
|
||||
// 记录再交给 SessionRouter(动作映射 + 物品目录)。
|
||||
//
|
||||
// 和 AutoTest 的差别:这里不退出进程,状态通过 OnChange 推给 UI;失败只记日志。
|
||||
package App.Browser
|
||||
|
||||
import App.Session.SessionRouter
|
||||
import App.Session.SpaceSession
|
||||
import Net.Protocol.Entry
|
||||
import Net.Protocol.EntryEvent
|
||||
import Net.Protocol.S2cDecoder
|
||||
import Net.Protocol.ZtPayload
|
||||
|
||||
class LobbyClient(private val OnChange: () -> Unit) {
|
||||
|
||||
val Router = SessionRouter()
|
||||
val Logs: MutableList<String> = ArrayList()
|
||||
|
||||
var Status: String = "未连接"
|
||||
private set
|
||||
|
||||
var LobbyState: String = "-"
|
||||
private set
|
||||
var SpaceState: String = "-"
|
||||
private set
|
||||
|
||||
var Frames: Int = 0
|
||||
private set
|
||||
var Records: Int = 0
|
||||
private set
|
||||
var KeepAlives: Int = 0
|
||||
private set
|
||||
|
||||
private val Space = SpaceSession()
|
||||
private val LobbyDecoder = S2cDecoder()
|
||||
private val PendingReplies = ArrayList<Int>()
|
||||
|
||||
private var LobbyWs: dynamic = null
|
||||
private var SpaceWs: dynamic = null
|
||||
private var LobbyUrl: String = ""
|
||||
private var SpaceUrl: String = ""
|
||||
private var Digest: ByteArray? = null
|
||||
private var SpaceId: Long = 0L
|
||||
|
||||
fun Log(Text: String) {
|
||||
Logs.add(Text)
|
||||
if (Logs.size > 200) Logs.removeAt(0)
|
||||
OnChange()
|
||||
}
|
||||
|
||||
fun Connect(Lobby: String, Space: String) {
|
||||
if (LobbyWs != null || SpaceWs != null) Disconnect()
|
||||
LobbyUrl = Lobby
|
||||
SpaceUrl = if (Space.isBlank()) Lobby else Space
|
||||
Status = "连接大厅…"
|
||||
Log("→ 大厅 $LobbyUrl")
|
||||
val Ws = NewSocket(LobbyUrl)
|
||||
LobbyWs = Ws
|
||||
Ws.onopen = {
|
||||
Log("→ Hash Request")
|
||||
Ws.send(Entry.BuildHashRequest(Entry.HashKeys, Entry.HashValues))
|
||||
}
|
||||
Ws.onmessage = { Event: dynamic -> HandleLobby(Event.data) }
|
||||
Ws.onerror = { Log("✗ 大厅 WS 错误") }
|
||||
Ws.onclose = { Event: dynamic ->
|
||||
LobbyState = "关闭(${Event.code})"
|
||||
if (SpaceWs == null) Status = "大厅已断开"
|
||||
OnChange()
|
||||
}
|
||||
OnChange()
|
||||
}
|
||||
|
||||
fun Disconnect() {
|
||||
try {
|
||||
LobbyWs?.close()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
try {
|
||||
SpaceWs?.close()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
LobbyWs = null
|
||||
SpaceWs = null
|
||||
Status = "已断开"
|
||||
OnChange()
|
||||
}
|
||||
|
||||
private fun HandleLobby(Data: dynamic) {
|
||||
val Bytes = js("new Int8Array(Data)") as ByteArray
|
||||
var Reconnect = false
|
||||
val Bodies = try {
|
||||
LobbyDecoder.Feed(Bytes) { Line -> Log("⚠ $Line") }
|
||||
} catch (E: Throwable) {
|
||||
Log("✗ 大厅解码异常:${E.message ?: E}")
|
||||
emptyList()
|
||||
}
|
||||
for (Body in Bodies) {
|
||||
for (E in Entry.ParseServerBody(Body)) {
|
||||
when (E) {
|
||||
is EntryEvent.HashResponse -> {
|
||||
Digest = E.Digest
|
||||
LobbyState = "Hash ✓(加密=${E.Encrypted})"
|
||||
Log("← Hash Response(加密=${E.Encrypted})")
|
||||
}
|
||||
is EntryEvent.OpenSpace -> {
|
||||
SpaceId = E.SpaceId
|
||||
LobbyState = "Open Space $SpaceId"
|
||||
Log("← Open Space spaceId=$SpaceId")
|
||||
Reconnect = true
|
||||
}
|
||||
is EntryEvent.Unknown -> Log("← 未识别入口命令 id=${E.Id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Reconnect && Digest != null) OpenSpace()
|
||||
OnChange()
|
||||
}
|
||||
|
||||
private fun OpenSpace() {
|
||||
val DigestNow = Digest ?: return
|
||||
Status = "连接空间…"
|
||||
SpaceState = "连接中"
|
||||
Log("→ 空间 $SpaceUrl")
|
||||
val Ws = NewSocket(SpaceUrl)
|
||||
SpaceWs = Ws
|
||||
Ws.onopen = {
|
||||
Log("→ EnterSpace($SpaceId)")
|
||||
Ws.send(Entry.BuildEnterSpace(DigestNow, SpaceId))
|
||||
SpaceState = "EnterSpace 已发"
|
||||
}
|
||||
Ws.onmessage = { Event: dynamic -> HandleSpace(Event.data) }
|
||||
Ws.onerror = { Log("✗ 空间 WS 错误") }
|
||||
Ws.onclose = { Event: dynamic ->
|
||||
SpaceState = "关闭(${Event.code})"
|
||||
Status = "空间已断开"
|
||||
OnChange()
|
||||
}
|
||||
OnChange()
|
||||
}
|
||||
|
||||
private fun HandleSpace(Data: dynamic) {
|
||||
val Bytes = js("new Int8Array(Data)") as ByteArray
|
||||
Frames++
|
||||
val List = try {
|
||||
Space.Feed(Bytes) { Line -> Log("⚠ $Line") }
|
||||
} catch (E: Throwable) {
|
||||
Log("✗ 空间解码异常:${E.message ?: E}")
|
||||
emptyList()
|
||||
}
|
||||
if (List.isEmpty()) {
|
||||
KeepAlives++
|
||||
Status = "已连接"
|
||||
SpaceState = "运行中(密钥=${Space.Key ?: "明文"})"
|
||||
OnChange()
|
||||
return
|
||||
}
|
||||
Records += List.size
|
||||
for (R in List) {
|
||||
val V = R.Value
|
||||
if (V is ZtPayload) {
|
||||
PendingReplies.add(V.X)
|
||||
ScheduleReply(V.X)
|
||||
}
|
||||
}
|
||||
Router.RouteAll(List)
|
||||
Status = "已连接"
|
||||
SpaceState = "运行中(密钥=${Space.Key ?: "明文"})"
|
||||
OnChange()
|
||||
}
|
||||
|
||||
private fun ScheduleReply(X: Int) {
|
||||
SetTimeout({
|
||||
val Ws = SpaceWs
|
||||
if (Ws != null) {
|
||||
try {
|
||||
Ws.send(Entry.BuildObjectDataRequest(SpaceId, X))
|
||||
Log("→ G.q1wd($X)")
|
||||
} catch (E: Throwable) {
|
||||
Log("✗ G.q1wd 发送失败:${E.message ?: E}")
|
||||
}
|
||||
}
|
||||
PendingReplies.remove(X)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
private val SetTimeout: (() -> Unit, Int) -> Unit =
|
||||
js("(function (F, Ms) { setTimeout(F, Ms); })")
|
||||
|
||||
private val NewSocket: (String) -> dynamic =
|
||||
js(
|
||||
"(function (U) { var W = new WebSocket(U); W.binaryType = 'arraybuffer'; return W; })"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
// LobbyView.kt —— 真实大厅 UI(接入 RootState / 物品目录 / 官方文案与图标)
|
||||
//
|
||||
// 目标是把「诊断页」换成可持续扩展的大厅骨架:
|
||||
// 顶栏(等级 / GS / 水晶 / 金币 / VIP)+ 左侧官方菜单 + 主内容区 + 右侧聊天/公告 + 底部连接状态。
|
||||
// 文案运行时从游戏自带的 zh-CN 翻译拉取(Translations.kt),图标用 resources.json 里的官方资源路径。
|
||||
// 车库 / 商店 / 幸运礼盒直接读 ItemCatalog(抓包解出的 1399 物品 + 516 装置)。
|
||||
package App.Browser
|
||||
|
||||
import App.State.GarageItemObject
|
||||
import Net.Generated.DescriptionModelCC
|
||||
|
||||
private val Doc: dynamic = js("document")
|
||||
|
||||
/** 官方静态资源前缀(资源路径来自 resources.json)。 */
|
||||
private const val AssetRoot = "/play/"
|
||||
|
||||
private fun Asset(Path: String): String = AssetRoot + Path
|
||||
|
||||
private fun Esc(Text: String): String = Text
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
|
||||
/** 车库分类:数值来自 module_85667 的 ItemCategory 枚举。 */
|
||||
private val Categories: List<Triple<Int, String, String>> = listOf(
|
||||
Triple(0, "WEAPON", "炮塔"),
|
||||
Triple(1, "ARMOR", "底盘"),
|
||||
Triple(2, "PAINT", "迷彩"),
|
||||
Triple(3, "INVENTORY", "道具"),
|
||||
Triple(5, "KIT", "套件"),
|
||||
Triple(10, "RESISTANCE_MODULE", "防御模块"),
|
||||
Triple(11, "DEVICE", "装置"),
|
||||
Triple(13, "CONTAINER", "幸运礼盒"),
|
||||
Triple(14, "DRONE", "辅助无人机"),
|
||||
Triple(15, "SKIN", "皮肤"),
|
||||
Triple(17, "BAZOOKA", "火箭炮"),
|
||||
Triple(18, "GRENADE", "手榴弹"),
|
||||
)
|
||||
|
||||
private val RarityColors = listOf("#9aa3b2", "#5fbf6a", "#3f8fe0", "#a05fe0", "#e0a13f", "#e05f5f")
|
||||
|
||||
private fun ItemName(Item: GarageItemObject): String {
|
||||
val D = Item.description as? DescriptionModelCC
|
||||
return D?.Name ?: "物品 ${Item.id}"
|
||||
}
|
||||
|
||||
private fun ItemRarity(Item: GarageItemObject): Int = Item.rarity as? Int ?: 0
|
||||
|
||||
private fun ItemCategory(Item: GarageItemObject): Int = Item.itemCategory
|
||||
|
||||
private fun CategoryLabel(Category: Int): String {
|
||||
val Entry = Categories.firstOrNull { it.first == Category } ?: return "其它"
|
||||
return Translations.T("lobby.garage.${Entry.second}", Entry.third)
|
||||
}
|
||||
|
||||
class LobbyView(private val Client: LobbyClient) {
|
||||
|
||||
private var Nav: String = "home"
|
||||
private var Category: Int = 0
|
||||
private var Query: String = ""
|
||||
private var Mounted = false
|
||||
|
||||
fun Mount() {
|
||||
if (Mounted) return
|
||||
Mounted = true
|
||||
val Root = Doc.getElementById("app")
|
||||
Root.innerHTML = Shell()
|
||||
Bind()
|
||||
Update()
|
||||
}
|
||||
|
||||
fun Update() {
|
||||
if (!Mounted) return
|
||||
val State = Client.Router.Store.State
|
||||
val U = State.User
|
||||
SetHtml(
|
||||
"lobby-stats",
|
||||
"""
|
||||
<div class="user-block">
|
||||
<div class="rank-badge">${U.Rank}</div>
|
||||
<div class="user-meta">
|
||||
<b>${Esc(U.Uid.ifEmpty { "未登录" })}</b>
|
||||
<span>GS ${U.GearScore} · Rank ${U.Rank}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="currencies">
|
||||
<span class="stat" title="水晶"><img src="${Asset("static/images/crystalSmall.242e6c15.svg")}">${U.Crystals}</span>
|
||||
<span class="stat" title="坦克币"><img src="${Asset("static/images/gold.e0b82d44.svg")}">${U.Score}</span>
|
||||
<span class="stat ${if (U.HasPremium) "premium" else "muted"}" title="VIP">
|
||||
<img src="${Asset("static/images/premiumIcon.e8725ab0.svg")}">${if (U.HasPremium) "VIP" else "普通"}
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
SetHtml("lobby-content", Content())
|
||||
SetHtml("lobby-chat", Chat())
|
||||
SetHtml(
|
||||
"lobby-status",
|
||||
"${Client.Status}|帧 ${Client.Frames}|记录 ${Client.Records}|已派发 ${Client.Router.Applied}" +
|
||||
"|未覆盖 ${Client.Router.Unmapped}|目录 ${Client.Router.Catalog.Items.size} 物品 / " +
|
||||
"${Client.Router.Catalog.Devices.size} 装置|屏幕 ${State.Screen.Current}"
|
||||
)
|
||||
BindDynamic()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 骨架
|
||||
|
||||
private fun Shell(): String = """
|
||||
<div class="lobby">
|
||||
<div class="lobby-bg"></div>
|
||||
<header class="topbar">
|
||||
<div id="lobby-stats" class="stats"></div>
|
||||
<button id="connect-toggle" class="connect-toggle">连接</button>
|
||||
</header>
|
||||
|
||||
<div class="lobby-body">
|
||||
<nav class="menu">
|
||||
${NavButton("battles", "iconLobbyMenuWar.ada9b0af.svg", "开始战斗", "BATTLE")}
|
||||
${NavButton("garage", "iconLobbyMenuGarage.0b623165.svg", "车库", "GARAGE")}
|
||||
${NavButton("quests", "iconLobbyMenuQuests.54801b8b.svg", "任务", "QUESTS")}
|
||||
${NavButton("shop", "iconLobbyMenuStore.e755fe14.svg", "商店", "SHOP")}
|
||||
${NavButton("containers", "iconLobbyMenuContainers.f960290f.svg", "幸运礼盒", "CONTAINERS")}
|
||||
${NavButton("clans", "iconLobbyMenuClan.f01ae646.svg", "军团", "CLANS")}
|
||||
${NavButton("friends", "Add-friend.137d3601.svg", "好友", "FRIENDS")}
|
||||
${NavButton("news", "iconLobbyMenuNews.6efe9ae3.svg", "新闻", "NEWS")}
|
||||
${NavButton("settings", "iconLobbyMenuSettings.cc00d08a.svg", "设置", "OPTIONS")}
|
||||
</nav>
|
||||
|
||||
<main id="lobby-content" class="content"></main>
|
||||
</div>
|
||||
|
||||
<div id="lobby-chat" class="lobby-chat"></div>
|
||||
|
||||
<button class="play-fab" data-go="battles">
|
||||
<img src="${Asset("static/images/join_battle.b0903eb3.svg")}" alt="">
|
||||
<span>${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</span>
|
||||
</button>
|
||||
|
||||
<footer class="statusbar"><span id="lobby-status">未连接</span></footer>
|
||||
|
||||
<div id="connect-panel" class="connect-panel hidden">
|
||||
<h3>连接中继</h3>
|
||||
<label>大厅地址<input id="lobby-url" spellcheck="false"></label>
|
||||
<label>空间地址<input id="space-url" spellcheck="false"></label>
|
||||
<div class="connect-actions">
|
||||
<button id="connect-go" class="primary">连接</button>
|
||||
<button id="connect-close">关闭</button>
|
||||
</div>
|
||||
<p class="hint">默认 ws://127.0.0.1:9091/(本地 wss_server 中继)。也可用 ?lobby=…&space=… 预填。</p>
|
||||
</div>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun NavButton(Id: String, Icon: String, Label: String, Key: String): String = """
|
||||
<button class="nav" data-nav="$Id">
|
||||
<img src="${Asset("static/images/$Icon")}" alt="">
|
||||
<span>${Esc(Translations.T("lobby.menu.$Key", Label))}</span>
|
||||
</button>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Bind() {
|
||||
Doc.querySelectorAll(".nav").forEach { El: dynamic ->
|
||||
El.addEventListener("click", {
|
||||
Nav = El.getAttribute("data-nav") as String
|
||||
Update()
|
||||
})
|
||||
}
|
||||
Doc.getElementById("connect-toggle").addEventListener("click", {
|
||||
val Panel = Doc.getElementById("connect-panel")
|
||||
Panel.classList.toggle("hidden")
|
||||
})
|
||||
Doc.getElementById("connect-close").addEventListener("click", {
|
||||
Doc.getElementById("connect-panel").classList.add("hidden")
|
||||
})
|
||||
Doc.getElementById("connect-go").addEventListener("click", {
|
||||
val Lobby = (Doc.getElementById("lobby-url").value as String).trim()
|
||||
val Space = (Doc.getElementById("space-url").value as String).trim()
|
||||
if (Lobby.isEmpty()) {
|
||||
Client.Log("✗ 大厅地址不能为空")
|
||||
} else {
|
||||
Client.Connect(Lobby, if (Space.isEmpty()) Lobby else Space)
|
||||
Doc.getElementById("connect-panel").classList.add("hidden")
|
||||
}
|
||||
})
|
||||
val LobbyInput = Doc.getElementById("lobby-url")
|
||||
val SpaceInput = Doc.getElementById("space-url")
|
||||
val Params = js("new URLSearchParams(window.location.search)")
|
||||
val LobbyDefault = (Params.get("lobby") as? String) ?: "ws://127.0.0.1:9091/"
|
||||
val SpaceDefault = (Params.get("space") as? String) ?: LobbyDefault
|
||||
LobbyInput.value = LobbyDefault
|
||||
SpaceInput.value = SpaceDefault
|
||||
if (Params.get("autoconnect") == "1") {
|
||||
Client.Connect(LobbyDefault, SpaceDefault)
|
||||
}
|
||||
}
|
||||
|
||||
private fun BindDynamic() {
|
||||
Doc.querySelectorAll(".nav").forEach { El: dynamic ->
|
||||
val Id = El.getAttribute("data-nav") as String
|
||||
El.classList.toggle("active", Id == Nav)
|
||||
}
|
||||
Doc.querySelectorAll("[data-garage-cat]").forEach { El: dynamic ->
|
||||
El.addEventListener("click", {
|
||||
Category = (El.getAttribute("data-garage-cat") as String).toInt()
|
||||
Query = ""
|
||||
Update()
|
||||
})
|
||||
}
|
||||
Doc.querySelectorAll("[data-go]").forEach { El: dynamic ->
|
||||
El.addEventListener("click", {
|
||||
Nav = El.getAttribute("data-go") as String
|
||||
Update()
|
||||
})
|
||||
}
|
||||
val Search = Doc.getElementById("garage-search")
|
||||
if (Search != null) {
|
||||
Search.addEventListener("input", {
|
||||
Query = (Search.value as String)
|
||||
Refresh()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private var Pending = false
|
||||
|
||||
private fun Refresh() {
|
||||
if (Pending) return
|
||||
Pending = true
|
||||
val F: () -> Unit = {
|
||||
Pending = false
|
||||
Update()
|
||||
}
|
||||
js("(function (Fn) { requestAnimationFrame(Fn); })")(F)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 内容
|
||||
|
||||
private fun Content(): String = when (Nav) {
|
||||
"garage" -> Garage()
|
||||
"shop" -> Shop()
|
||||
"containers" -> Containers()
|
||||
"battles" -> Battles()
|
||||
"quests" -> Quests()
|
||||
"clans" -> Placeholder("军团", "军团系统将在 S4 之后接入。")
|
||||
"friends" -> Friends()
|
||||
"news" -> News()
|
||||
"settings" -> Settings()
|
||||
else -> Home()
|
||||
}
|
||||
|
||||
private fun Home(): String {
|
||||
val Connected = Client.Status == "已连接"
|
||||
return """
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="eyebrow">${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</div>
|
||||
<h1>${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</h1>
|
||||
<p>快速匹配一场战斗,或从战斗列表中选择模式与地图。</p>
|
||||
<div class="hero-actions">
|
||||
<button class="primary big" data-go="battles">${Esc(Translations.T("lobby.battle.pick.SEARCH_BATTLE", "搜索一场战斗"))}</button>
|
||||
<button class="ghost big" data-go="garage">${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}</button>
|
||||
</div>
|
||||
${if (Connected) "" else """
|
||||
<p class="warn">当前未连接服务器:点右上角「连接」填入中继地址后即可接收真实大厅数据。</p>
|
||||
"""}
|
||||
</div>
|
||||
<div class="hero-art"></div>
|
||||
</section>
|
||||
|
||||
<div class="cards">
|
||||
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.menu.QUESTS", "任务"), "每日 / 每周任务与星级挑战")}
|
||||
${HomeCard("shop", "iconLobbyMenuStore.e755fe14.svg", Translations.T("lobby.menu.SHOP", "商店"), "水晶 / 坦克币 / 礼盒")}
|
||||
${HomeCard("garage", "iconLobbyMenuGarage.0b623165.svg", Translations.T("lobby.menu.GARAGE", "车库"), "装备、皮肤与强化")}
|
||||
</div>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun HomeCard(Nav: String, Icon: String, Title: String, Text: String): String = """
|
||||
<button class="card" data-go="$Nav">
|
||||
<img src="${Asset("static/images/$Icon")}" alt="">
|
||||
<div><b>${Esc(Title)}</b><span>${Esc(Text)}</span></div>
|
||||
</button>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Battles(): String {
|
||||
val Modes = listOf(
|
||||
Triple("TEAM_MODE", "快速战斗", "快速加入战斗,系统将随机分配阵营与地图。"),
|
||||
Triple("DEATHMATCH", "死亡混战", "竞技场中的每个玩家都是您的敌人。"),
|
||||
Triple("CAPTURE_THE_FLAG", "互夺军旗", "夺取最多旗子的队伍获胜。"),
|
||||
Triple("CONTROL_POINTS", "控制点", "占领敌方点位并阻止敌人占领。"),
|
||||
Triple("JUGGERNAUT", "剑圣", "摧毁尽可能多的坦克。"),
|
||||
Triple("SIEGE", "围城大战", "控制一个点并使敌人远离它。"),
|
||||
)
|
||||
return """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.battle.list.TITLE", "战场列表"))}</h2>
|
||||
<button class="primary" data-go="garage">${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}</button>
|
||||
</section>
|
||||
<div class="mode-grid">
|
||||
${Modes.joinToString("") { (Key, Name, Desc) -> """
|
||||
<button class="mode-card" data-go="battles">
|
||||
<b>${Esc(Translations.T("lobby.battle.pick.${Key}_NAME", Name))}</b>
|
||||
<span>${Esc(Translations.T("lobby.battle.pick.${Key}_DESCRIPTION", Desc))}</span>
|
||||
</button>
|
||||
""" }}
|
||||
</div>
|
||||
<p class="hint">战斗创建 / 匹配的完整链路属于 S4;这里先接真实状态里的战斗列表与模式文案。</p>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun Garage(): String {
|
||||
val All = Client.Router.Catalog.Items.values
|
||||
val Filtered = All.filter {
|
||||
ItemCategory(it) == Category && (Query.isEmpty() || ItemName(it).contains(Query, ignoreCase = true))
|
||||
}
|
||||
val Shown = Filtered.take(96)
|
||||
return """
|
||||
<section class="section-head">
|
||||
<h2>${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}
|
||||
<small>${Filtered.size} / ${All.size}</small>
|
||||
</h2>
|
||||
<input id="garage-search" class="search" placeholder="搜索物品" value="${Esc(Query)}">
|
||||
</section>
|
||||
<div class="tabs">
|
||||
${Categories.joinToString("") { (Id, Key, Fallback) ->
|
||||
val Count = All.count { ItemCategory(it) == Id }
|
||||
"""
|
||||
<button class="tab ${if (Category == Id) "active" else ""}" data-garage-cat="$Id">
|
||||
${Esc(Translations.T("lobby.garage.$Key", Fallback))}<i>$Count</i>
|
||||
</button>
|
||||
""".trimIndent()
|
||||
}}
|
||||
</div>
|
||||
${if (All.isEmpty()) """
|
||||
<div class="empty">目录还是空的:连接服务器后会解出车库物品(当前离线目录 0 个)。</div>
|
||||
""" else """
|
||||
<div class="item-grid">${Shown.joinToString("") { ItemCard(it) }}</div>
|
||||
${if (Filtered.size > Shown.size) "<p class=\"hint\">仅显示前 ${Shown.size} 件,继续补充分页。</p>" else ""}
|
||||
"""}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun Shop(): String {
|
||||
val All = Client.Router.Catalog.Items.values.filter { it.buyable }
|
||||
val Shown = All.take(96)
|
||||
return """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.SHOP", "商店"))}
|
||||
<small>${All.size} 件可购买</small></h2></section>
|
||||
${if (All.isEmpty()) """
|
||||
<div class="empty">离线目录里暂无可购买物品,连接服务器后会自动填充。</div>
|
||||
""" else """
|
||||
<div class="item-grid">${Shown.joinToString("") { ItemCard(it) }}</div>
|
||||
"""}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun Containers(): String {
|
||||
val All = Client.Router.Catalog.Items.values.filter { ItemCategory(it) == 13 }
|
||||
return """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.CONTAINERS", "幸运礼盒"))}
|
||||
<small>${All.size}</small></h2></section>
|
||||
${if (All.isEmpty()) "<div class=\"empty\">没有礼盒数据。</div>"
|
||||
else "<div class=\"item-grid\">" + All.take(60).joinToString("") { ItemCard(it) } + "</div>"}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun Quests(): String = """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.quests.navigation.HEADER", "任务"))}</h2></section>
|
||||
<div class="cards">
|
||||
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.MAIN", "主要"), "主线任务")}
|
||||
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.DAILY", "每日"), "每日任务")}
|
||||
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.WEEKLY", "每周"), "每周任务")}
|
||||
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.CHALLENGES", "星级挑战"), "星际挑战")}
|
||||
</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Friends(): String = """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.friends.TITLE", "好友"))}</h2></section>
|
||||
<div class="empty">好友列表将在社交链路补齐后接入。</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun News(): String {
|
||||
val Items = Client.Router.Store.State.News.News
|
||||
return """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.NEWS", "新闻"))}</h2></section>
|
||||
${if (Items.isEmpty()) "<div class=\"empty\">暂无新闻。</div>"
|
||||
else "<div class=\"news-list\">" + Items.take(20).joinToString("") { N ->
|
||||
"<article class=\"news\"><b>${Esc(N.header)}</b><p>${Esc(N.description)}</p></article>"
|
||||
} + "</div>"}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun Settings(): String = """
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.OPTIONS", "设置"))}</h2></section>
|
||||
<div class="cards">
|
||||
<div class="card static"><div><b>连接</b><span>大厅 / 空间中继地址</span></div></div>
|
||||
<div class="card static"><div><b>语言</b><span>简体中文(运行时读取官方 zh-CN)</span></div></div>
|
||||
<div class="card static"><div><b>诊断</b><span>帧 ${Client.Frames},记录 ${Client.Records},未覆盖 ${Client.Router.Unmapped}</span></div></div>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Placeholder(Title: String, Text: String): String = """
|
||||
<section class="section-head"><h2>${Esc(Title)}</h2></section>
|
||||
<div class="empty">${Esc(Text)}</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Side(): String {
|
||||
val Lines = Client.Logs.takeLast(12).joinToString("") { "<div class=\"log-line\">${Esc(it)}</div>" }
|
||||
return """
|
||||
<div class="panel">
|
||||
<h3>${Esc(Translations.T("lobby.menu.CHAT", "聊天"))}</h3>
|
||||
<div class="chat-box">${if (Lines.isEmpty()) "<span class=\"muted\">连接后显示服务器日志</span>" else Lines}</div>
|
||||
<div class="chat-input"><input placeholder="聊天暂未开放" disabled><button disabled>发送</button></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>${Esc(Translations.T("lobby.menu.NEWS", "新闻"))}</h3>
|
||||
<div class="muted small">新闻、公告与活动由服务端推送后显示在这里。</div>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun ItemCard(Item: GarageItemObject): String {
|
||||
val Rarity = ItemRarity(Item)
|
||||
val Color = RarityColors.getOrElse(Rarity) { RarityColors[0] }
|
||||
val Name = ItemName(Item)
|
||||
val Category = CategoryLabel(ItemCategory(Item))
|
||||
val Premium = Item.isPremium
|
||||
val Count = Item.count
|
||||
return """
|
||||
<article class="item" style="--rarity:$Color">
|
||||
<div class="item-art"><span>${Esc(Name.take(1))}</span></div>
|
||||
<div class="item-body">
|
||||
<b title="${Esc(Name)}">${Esc(Name)}</b>
|
||||
<span class="muted">${Esc(Category)}${if (Count > 1) " · x$Count" else ""}</span>
|
||||
${if (Premium) "<span class=\"badge\">VIP</span>" else ""}
|
||||
</div>
|
||||
</article>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun SetHtml(Id: String, Html: String) {
|
||||
val El = Doc.getElementById(Id) ?: return
|
||||
El.innerHTML = Html
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Translations.kt —— 真实 UI 文案(运行时拉取游戏自带的 zh-CN 翻译)
|
||||
//
|
||||
// 资源路径来自 3DTank/src/resources.json 的 resourceMap(id 19144)。
|
||||
// 拉取失败时回退到调用点给的默认文案,UI 不会因网络问题变空白。
|
||||
package App.Browser
|
||||
|
||||
object Translations {
|
||||
|
||||
private var Root: dynamic = null
|
||||
private var Loading = false
|
||||
|
||||
val Loaded: Boolean get() = Root != null
|
||||
|
||||
private val FetchJson: (String, (dynamic) -> Unit) -> Unit = js(
|
||||
"""
|
||||
(function (Url, Cb) {
|
||||
if (typeof fetch !== 'function') { Cb(null); return; }
|
||||
fetch(Url, { mode: 'cors' })
|
||||
.then(function (R) { return R.ok ? R.json() : null; })
|
||||
.then(function (J) { Cb(J); })
|
||||
.catch(function () { Cb(null); });
|
||||
})
|
||||
"""
|
||||
)
|
||||
|
||||
/** 拉取 zh-CN 翻译;完成后调用 OnReady(用于重绘)。 */
|
||||
fun Load(OnReady: () -> Unit) {
|
||||
if (Loaded || Loading) {
|
||||
OnReady()
|
||||
return
|
||||
}
|
||||
Loading = true
|
||||
FetchJson("/play/static/translations/zh-CN.61d6ab60.json") { Json ->
|
||||
Root = Json
|
||||
Loading = false
|
||||
OnReady()
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 `a.b.c` 取值;缺失或非字符串时返回 Default。 */
|
||||
fun T(Key: String, Default: String): String {
|
||||
var Cur: dynamic = Root
|
||||
if (Cur == null) return Default
|
||||
for (Part in Key.split('.')) {
|
||||
if (Cur == null) return Default
|
||||
Cur = Cur[Part]
|
||||
if (Cur == null) return Default
|
||||
}
|
||||
return Cur as? String ?: Default
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,15 @@ object Replay {
|
||||
}
|
||||
|
||||
fun main() {
|
||||
if (!IsNode()) return
|
||||
if (!IsNode()) {
|
||||
App.Browser.BrowserMain()
|
||||
return
|
||||
}
|
||||
NodeMain()
|
||||
}
|
||||
|
||||
/** Node 下的自检 / 抓包回放入口(浏览器不使用这些环境变量)。 */
|
||||
private fun NodeMain() {
|
||||
if (StateCheck.Wanted()) {
|
||||
StateCheck.Run()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/* entrance.css —— 原版登录页(尺寸取线上 3dtank.com/play/ 的计算样式) */
|
||||
@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");
|
||||
}
|
||||
|
||||
:root { --sar: env(safe-area-inset-right); --sal: env(safe-area-inset-left); }
|
||||
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #001926;
|
||||
color: #fff;
|
||||
font-family: BaseFontRegular, "Segoe UI", system-ui, sans-serif;
|
||||
user-select: none;
|
||||
}
|
||||
button, input { font: inherit; }
|
||||
|
||||
.wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
left: var(--sal);
|
||||
right: var(--sar);
|
||||
background: radial-gradient(50% 100% at 50% 100%, #395673 0%, #001926 100%);
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.entrance-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(#001926 0%, rgba(0, 25, 38, 0) 100%);
|
||||
font-size: max(min(1.48148vh, 1vw), 3px);
|
||||
}
|
||||
|
||||
/* ---------------- 顶栏 ---------------- */
|
||||
.link-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 6em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.icon-tank {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 6em;
|
||||
height: 6em;
|
||||
}
|
||||
.logo-tank {
|
||||
position: absolute;
|
||||
top: 1.125em;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: 12em;
|
||||
height: 3.75em;
|
||||
}
|
||||
.sound-btn {
|
||||
margin-right: 3.75em;
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
border-radius: 0.25em;
|
||||
border: 0.063em solid rgba(255, 255, 255, 0.25);
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.sound-btn:hover { border-color: rgba(255, 255, 255, 0.55); }
|
||||
.sound-on {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
background: url("/play/static/images/Sound_on.32428e4c.svg") center / contain no-repeat;
|
||||
filter: invert(26%) sepia(26%) saturate(443%) hue-rotate(188deg) brightness(104%) contrast(104%);
|
||||
}
|
||||
.sound-btn:hover .sound-on { filter: none; }
|
||||
|
||||
/* ---------------- 中央 ---------------- */
|
||||
.content-space {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.entrance-form {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
width: 22.5em;
|
||||
min-width: 22.5em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.entrance-form form { width: 18.75em; }
|
||||
.entrance-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 1.5em;
|
||||
font-family: BaseFontMedium, "Segoe UI", sans-serif;
|
||||
font-size: 1.125em;
|
||||
text-transform: uppercase;
|
||||
cursor: default;
|
||||
}
|
||||
.entrance-center { display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
|
||||
.round-big {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 15em;
|
||||
height: 17em;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.round-big span {
|
||||
margin-top: 1em;
|
||||
font-family: BaseFontMedium, "Segoe UI", sans-serif;
|
||||
font-size: 1.125em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.round-big:hover span { color: #fff; }
|
||||
.round-outer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 0.2em;
|
||||
width: 14em;
|
||||
height: 14em;
|
||||
border: 0.176em solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.round-middle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 13em;
|
||||
height: 13em;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0);
|
||||
}
|
||||
.round-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12em;
|
||||
height: 12em;
|
||||
border-radius: 50%;
|
||||
background: rgba(188, 188, 188, 0.5);
|
||||
backdrop-filter: blur(12.5px);
|
||||
}
|
||||
.round-inner img { width: 3em; height: 3em; }
|
||||
.round-big:hover .round-inner { background: rgba(205, 205, 205, 0.62); }
|
||||
|
||||
/* ---------------- 底部 ---------------- */
|
||||
.legal {
|
||||
position: absolute;
|
||||
bottom: 1.5em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(72em, 86vw);
|
||||
text-align: center;
|
||||
font-size: 0.75em;
|
||||
line-height: 1.3;
|
||||
color: #fff;
|
||||
white-space: normal;
|
||||
}
|
||||
.age-warn {
|
||||
position: absolute;
|
||||
left: 1.75em;
|
||||
bottom: 2em;
|
||||
width: 4.1875em;
|
||||
height: 5.375em;
|
||||
z-index: 60;
|
||||
}
|
||||
.client-info {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 1.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 1.8rem;
|
||||
z-index: 101;
|
||||
font-size: 0.9375em;
|
||||
letter-spacing: 0.0375em;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
pointer-events: none;
|
||||
}
|
||||
.client-info i { margin: 0 0.4rem; font-style: normal; color: rgba(255, 255, 255, 0.6); }
|
||||
|
||||
/* ---------------- 连接弹窗 ---------------- */
|
||||
.connect-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 10, 18, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.connect-modal.hidden { display: none; }
|
||||
.connect-card {
|
||||
width: min(440px, 92vw);
|
||||
padding: 22px 24px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: linear-gradient(180deg, rgba(24, 42, 60, 0.98), rgba(8, 18, 28, 0.98));
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.connect-card h2 { margin: 0 0 6px; font-family: BaseFontMedium, sans-serif; font-size: 20px; }
|
||||
.connect-card .muted { color: rgba(255, 255, 255, 0.6); font-size: 12px; margin: 0 0 16px; }
|
||||
.connect-card label { display: block; margin-bottom: 12px; font-size: 12px; color: rgba(255, 255, 255, 0.65); }
|
||||
.connect-card input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(0, 8, 14, 0.8);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.connect-card input:focus { border-color: rgba(255, 179, 0, 0.65); }
|
||||
.connect-actions { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.connect-actions button {
|
||||
padding: 9px 18px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.connect-actions .primary {
|
||||
border-color: #8a5d00;
|
||||
background: linear-gradient(180deg, #ffc93d, #f29500);
|
||||
color: #241800;
|
||||
font-weight: 700;
|
||||
}
|
||||
.connect-status {
|
||||
margin-top: 12px;
|
||||
min-height: 18px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
.connect-status.error { color: #ff8a8a; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.entrance-gradient { font-size: max(min(1.48148vh, 1.6vw), 3px); }
|
||||
.legal { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>TankiOffline · 大厅</title>
|
||||
<link rel="stylesheet" href="entrance.css" />
|
||||
<link rel="stylesheet" href="lobby.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<!-- Gradle 的 webpack 产物:开发时由 dev server 提供,打包后在 index.html 同目录 -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,291 @@
|
||||
/* lobby.css —— 大厅骨架(对齐 Tanki 的深色金属 + 橙色强调) */
|
||||
@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");
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg0: #05080d;
|
||||
--bg1: #0b1725;
|
||||
--bg2: #122436;
|
||||
--panel: rgba(16, 28, 42, 0.88);
|
||||
--line: #22394f;
|
||||
--text: #e8eef6;
|
||||
--muted: #8ea3b8;
|
||||
--accent: #ffb300;
|
||||
--accent2: #ff7a18;
|
||||
--blue: #3f9fe0;
|
||||
--danger: #e05f5f;
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lobby {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text);
|
||||
font-family: BaseFontRegular, "Segoe UI", system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
background: var(--bg0);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lobby-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 12, 20, 0.55), rgba(0, 10, 18, 0.82)),
|
||||
url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
.lobby > *:not(.lobby-bg) { position: relative; z-index: 1; }
|
||||
|
||||
button, input { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; }
|
||||
img { display: block; }
|
||||
|
||||
/* ---------------- 顶栏 ---------------- */
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
height: 72px; padding: 0 22px;
|
||||
background: linear-gradient(180deg, rgba(5, 14, 22, 0.82), rgba(5, 10, 16, 0.15));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; min-width: 190px; }
|
||||
.brand .logo { width: 38px; height: 38px; }
|
||||
.brand span { letter-spacing: 0.14em; font-weight: 700; font-size: 14px; color: #fff; font-family: BaseFontBold, sans-serif; }
|
||||
.brand b { color: var(--accent); margin-left: 4px; }
|
||||
|
||||
.stats { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex: 1; }
|
||||
.user-block { display: flex; align-items: center; gap: 10px; }
|
||||
.rank-badge {
|
||||
width: 44px; height: 44px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font: 700 18px/1 BaseFontBold, sans-serif; color: #241800;
|
||||
background: radial-gradient(circle at 35% 30%, #ffe08a, #e59a00 70%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: 0 0 16px rgba(255, 179, 0, 0.35);
|
||||
}
|
||||
.user-meta b { display: block; font: 600 15px/1.2 BaseFontMedium, sans-serif; color: #fff; }
|
||||
.user-meta span { font-size: 11px; color: rgba(255, 255, 255, 0.55); letter-spacing: 0.04em; }
|
||||
|
||||
.currencies { display: flex; align-items: center; gap: 10px; }
|
||||
.stat {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 12px; border-radius: 6px;
|
||||
background: rgba(2, 10, 16, 0.62);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
color: #eaf3fb; font: 600 14px/1 BaseFontMedium, sans-serif;
|
||||
}
|
||||
.stat img { width: 18px; height: 18px; object-fit: contain; }
|
||||
.stat.premium { border-color: rgba(255, 179, 0, 0.5); color: var(--accent); }
|
||||
.stat.muted { color: var(--muted); }
|
||||
.connect-toggle {
|
||||
padding: 8px 18px; border-radius: 6px; font-weight: 700;
|
||||
border: 1px solid #8a5d00;
|
||||
background: linear-gradient(180deg, #ffc93d, #f29500);
|
||||
color: #241800;
|
||||
}
|
||||
.connect-toggle:hover { filter: brightness(1.07); }
|
||||
|
||||
/* ---------------- 主体 ---------------- */
|
||||
.lobby-body { flex: 1; display: flex; min-height: 0; }
|
||||
|
||||
.menu {
|
||||
width: 108px; padding: 16px 8px; display: flex; flex-direction: column; gap: 4px;
|
||||
background: linear-gradient(90deg, rgba(3, 10, 16, 0.78), rgba(3, 10, 16, 0.20) 80%, transparent);
|
||||
}
|
||||
.nav {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%;
|
||||
padding: 7px 4px; border-radius: 8px; border: 1px solid transparent;
|
||||
background: transparent; color: #c7d7e6; text-align: center;
|
||||
}
|
||||
.nav img { width: 46px; height: 46px; object-fit: contain; filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45)); }
|
||||
.nav span { font-size: 11px; letter-spacing: 0.02em; }
|
||||
.nav:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.12); }
|
||||
.nav.active {
|
||||
background: linear-gradient(180deg, rgba(255, 179, 0, 0.22), rgba(255, 179, 0, 0.04));
|
||||
border-color: rgba(255, 179, 0, 0.45); color: #fff;
|
||||
}
|
||||
.nav.active img { filter: drop-shadow(0 0 10px rgba(255, 179, 0, 0.55)); }
|
||||
|
||||
.content { flex: 1; min-width: 0; padding: 20px 24px; overflow: auto; }
|
||||
.side {
|
||||
width: 310px; padding: 14px; display: flex; flex-direction: column; gap: 12px;
|
||||
border-left: 1px solid var(--line); background: rgba(8, 14, 22, 0.55); overflow: auto;
|
||||
}
|
||||
|
||||
/* ---------------- 通用块 ---------------- */
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.section-head h2 { margin: 0; font-size: 22px; letter-spacing: 0.02em; }
|
||||
.section-head h2 small { margin-left: 10px; color: var(--muted); font-size: 13px; font-weight: 500; }
|
||||
.search {
|
||||
width: 240px; padding: 8px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: rgba(4, 8, 14, 0.8); color: var(--text); outline: none;
|
||||
}
|
||||
.search:focus { border-color: var(--blue); }
|
||||
.hint { color: var(--muted); font-size: 12px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.warn { color: var(--accent); font-size: 13px; }
|
||||
.empty {
|
||||
padding: 38px 20px; text-align: center; color: var(--muted);
|
||||
border: 1px dashed var(--line); border-radius: var(--radius); background: rgba(8, 14, 22, 0.4);
|
||||
}
|
||||
|
||||
.primary, .ghost {
|
||||
padding: 9px 16px; border-radius: 8px; font-weight: 700;
|
||||
}
|
||||
.primary { background: linear-gradient(180deg, #ffc93d, #f29500); border: 1px solid #8a5d00; color: #241800; }
|
||||
.ghost { background: rgba(63, 159, 224, 0.10); border: 1px solid rgba(63, 159, 224, 0.4); color: #cfe6ff; }
|
||||
.big { padding: 13px 26px; font-size: 15px; }
|
||||
.primary:hover, .ghost:hover { filter: brightness(1.08); }
|
||||
|
||||
/* ---------------- 首页 ---------------- */
|
||||
.hero {
|
||||
position: relative; min-height: 240px; display: flex; align-items: center;
|
||||
padding: 28px 30px; border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid #2a4258;
|
||||
background:
|
||||
radial-gradient(700px 300px at 85% 40%, rgba(255, 179, 0, 0.18), transparent 60%),
|
||||
linear-gradient(120deg, rgba(16, 38, 60, 0.95), rgba(8, 14, 22, 0.9));
|
||||
}
|
||||
.hero-copy { max-width: 560px; position: relative; z-index: 1; }
|
||||
.eyebrow { color: var(--accent); letter-spacing: 0.25em; font-size: 11px; font-weight: 700; }
|
||||
.hero h1 { margin: 6px 0 8px; font-size: 42px; line-height: 1.05; letter-spacing: 0.02em; }
|
||||
.hero p { margin: 0 0 16px; color: #b9c9da; }
|
||||
.hero-actions { display: flex; gap: 10px; }
|
||||
.hero-art {
|
||||
position: absolute; right: -30px; bottom: -40px; width: 360px; height: 260px;
|
||||
background: radial-gradient(closest-side, rgba(63, 159, 224, 0.28), transparent 70%);
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||
.card {
|
||||
display: flex; align-items: center; gap: 14px; padding: 14px;
|
||||
border-radius: 12px; border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(19, 34, 50, 0.9), rgba(9, 16, 25, 0.9));
|
||||
color: var(--text); text-align: left;
|
||||
}
|
||||
.card:hover { border-color: rgba(255, 179, 0, 0.45); transform: translateY(-1px); }
|
||||
.card img { width: 42px; height: 42px; object-fit: contain; }
|
||||
.card b { display: block; font-size: 15px; }
|
||||
.card span { color: var(--muted); font-size: 12px; }
|
||||
.card.static { cursor: default; }
|
||||
|
||||
/* ---------------- 车库 / 商店 ---------------- */
|
||||
.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
|
||||
.tab {
|
||||
padding: 7px 12px; border-radius: 999px; font-size: 13px;
|
||||
border: 1px solid var(--line); background: rgba(8, 14, 22, 0.6); color: #b9c9da;
|
||||
}
|
||||
.tab i { margin-left: 6px; color: var(--muted); font-style: normal; font-size: 11px; }
|
||||
.tab.active { border-color: var(--accent); color: #241800; background: linear-gradient(180deg, #ffc93d, #f29500); }
|
||||
.tab.active i { color: #4a3200; }
|
||||
|
||||
.item-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px;
|
||||
}
|
||||
.item {
|
||||
position: relative; display: flex; flex-direction: column; overflow: hidden;
|
||||
border-radius: 10px; border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(18, 31, 45, 0.95), rgba(8, 13, 20, 0.95));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--rarity) 35%, transparent);
|
||||
}
|
||||
.item:hover { transform: translateY(-1px); border-color: var(--rarity); }
|
||||
.item-art {
|
||||
height: 70px; display: flex; align-items: center; justify-content: center;
|
||||
background:
|
||||
radial-gradient(closest-side, color-mix(in srgb, var(--rarity) 45%, transparent), transparent 70%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent);
|
||||
}
|
||||
.item-art span { font-size: 28px; font-weight: 800; color: var(--rarity); opacity: 0.9; }
|
||||
.item-body { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.item-body b {
|
||||
font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.badge {
|
||||
position: absolute; top: 6px; right: 6px; padding: 1px 6px; border-radius: 999px;
|
||||
font-size: 10px; font-weight: 800; background: var(--accent); color: #241800;
|
||||
}
|
||||
|
||||
/* ---------------- 战斗 ---------------- */
|
||||
.mode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
|
||||
.mode-card {
|
||||
text-align: left; padding: 16px; border-radius: 12px;
|
||||
border: 1px solid var(--line); background: linear-gradient(180deg, rgba(18, 31, 45, 0.92), rgba(8, 13, 20, 0.92));
|
||||
}
|
||||
.mode-card:hover { border-color: rgba(63, 159, 224, 0.6); }
|
||||
.mode-card b { display: block; font-size: 16px; margin-bottom: 6px; color: #fff; }
|
||||
.mode-card span { color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* ---------------- 侧栏 ---------------- */
|
||||
.panel {
|
||||
padding: 12px; border-radius: 12px; border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.panel h3 { margin: 0 0 10px; font-size: 14px; color: #dce8f4; letter-spacing: 0.04em; }
|
||||
.chat-box {
|
||||
height: 240px; overflow: auto; padding: 8px; border-radius: 8px;
|
||||
background: rgba(3, 7, 12, 0.75); font: 12px/1.55 Consolas, "Cascadia Mono", monospace;
|
||||
}
|
||||
.log-line { color: #b8c9da; padding: 1px 0; border-bottom: 1px solid rgba(34, 57, 79, 0.35); }
|
||||
.chat-input { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.chat-input input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line); background: #0a1119; }
|
||||
.chat-input button { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--line); background: #142434; color: var(--muted); }
|
||||
|
||||
.news-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.news { padding: 12px; border-radius: 10px; border: 1px solid var(--line); background: rgba(8, 14, 22, 0.55); }
|
||||
.news b { display: block; margin-bottom: 4px; }
|
||||
.news p { margin: 0; color: var(--muted); font-size: 12px; white-space: pre-wrap; }
|
||||
|
||||
/* ---------------- 底部 / 连接面板 ---------------- */
|
||||
.statusbar {
|
||||
height: 28px; display: flex; align-items: center; padding: 0 14px;
|
||||
border-top: 1px solid var(--line); background: rgba(5, 9, 14, 0.9);
|
||||
color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.connect-panel {
|
||||
position: fixed; top: 76px; right: 18px; width: 360px; z-index: 30;
|
||||
padding: 16px; border-radius: 12px; border: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #16283a, #0a121c);
|
||||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.connect-panel.hidden { display: none; }
|
||||
.connect-panel h3 { margin: 0 0 12px; }
|
||||
.connect-panel label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 12px; }
|
||||
.connect-panel input {
|
||||
display: block; width: 100%; margin-top: 4px; padding: 8px 10px; border-radius: 7px;
|
||||
border: 1px solid var(--line); background: #070d14; color: var(--text);
|
||||
}
|
||||
.connect-actions { display: flex; gap: 8px; }
|
||||
.connect-actions button {
|
||||
padding: 8px 14px; border-radius: 7px; border: 1px solid var(--line);
|
||||
background: #142434; color: #cfe0f0;
|
||||
}
|
||||
.connect-actions .primary { background: linear-gradient(180deg, #ffc93d, #f29500); border-color: #8a5d00; color: #241800; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.side { display: none; }
|
||||
.menu { width: 64px; }
|
||||
.nav span { display: none; }
|
||||
.nav { justify-content: center; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// webpack.config.d/node-fallback.js —— 浏览器构建时屏蔽 Node 内置模块
|
||||
//
|
||||
// jsMain 与 Node 自检共用一份源码,里面用 `module.require('fs' / 'path')` 读抓包;
|
||||
// 浏览器分支永远不会调用它们,但 webpack 会静态扫描到,默认会报
|
||||
// "Can't resolve 'fs'"。把这两个模块解析成空模块即可(运行期不触发)。
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.fallback = Object.assign({}, config.resolve.fallback, {
|
||||
fs: false,
|
||||
path: false,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
// webpack.config.d/node-fallback.js —— 浏览器构建时屏蔽 Node 内置模块
|
||||
//
|
||||
// jsMain 与 Node 自检共用一份源码,里面用 `module.require('fs' / 'path')` 读抓包;
|
||||
// 浏览器分支永远不会调用它们,但 webpack 会静态扫描到,默认会报
|
||||
// "Can't resolve 'fs'"。把这两个模块解析成空模块即可(运行期不触发)。
|
||||
config.resolve = config.resolve || {};
|
||||
config.resolve.fallback = Object.assign({}, config.resolve.fallback, {
|
||||
fs: false,
|
||||
path: false,
|
||||
});
|
||||
Reference in new issue
Block a user