feat(ui): 接入官方资源镜像与原版登录页,搭建大厅骨架与 UI 计划书

This commit is contained in:
TermiNexus committed 2026-10-25 19:02:13 +08:00
1 parent 1d1479592f
commit 6c1e8efb49
14 files changed
+1719 -1

No files matched your search

+14
View File
@@ -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
+11
View File
@@ -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>`。
+221
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
/** 车库分类:数值来自 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,
});