From 6c1e8efb49ecd661e708f5f725bdf9de42c3421f Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sun, 25 Oct 2026 19:02:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E6=8E=A5=E5=85=A5=E5=AE=98?= =?UTF-8?q?=E6=96=B9=E8=B5=84=E6=BA=90=E9=95=9C=E5=83=8F=E4=B8=8E=E5=8E=9F?= =?UTF-8?q?=E7=89=88=E7=99=BB=E5=BD=95=E9=A1=B5=EF=BC=8C=E6=90=AD=E5=BB=BA?= =?UTF-8?q?=E5=A4=A7=E5=8E=85=E9=AA=A8=E6=9E=B6=E4=B8=8E=20UI=20=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E4=B9=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 14 + 3DTank/docs/KtRebuild_Plan.md | 11 + 3DTank/docs/UI_Rebuild_Plan.md | 221 +++++++++ .../jsMain/kotlin/App/Browser/BrowserUi.kt | 36 ++ .../jsMain/kotlin/App/Browser/EntranceView.kt | 118 +++++ .../jsMain/kotlin/App/Browser/LobbyClient.kt | 198 ++++++++ .../jsMain/kotlin/App/Browser/LobbyView.kt | 445 ++++++++++++++++++ .../jsMain/kotlin/App/Browser/Translations.kt | 51 ++ .../app/src/jsMain/kotlin/App/Main.kt | 10 +- .../app/src/jsMain/resources/entrance.css | 290 ++++++++++++ .../app/src/jsMain/resources/index.html | 15 + .../app/src/jsMain/resources/lobby.css | 291 ++++++++++++ .../app/webpack.config.d/node-fallback.js | 10 + .../webpack.config.d/node-fallback.js | 10 + 14 files changed, 1719 insertions(+), 1 deletion(-) create mode 100644 3DTank/docs/UI_Rebuild_Plan.md create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Browser/BrowserUi.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Browser/EntranceView.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyClient.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt create mode 100644 TankiOffline/app/src/jsMain/kotlin/App/Browser/Translations.kt create mode 100644 TankiOffline/app/src/jsMain/resources/entrance.css create mode 100644 TankiOffline/app/src/jsMain/resources/index.html create mode 100644 TankiOffline/app/src/jsMain/resources/lobby.css create mode 100644 TankiOffline/app/webpack.config.d/node-fallback.js create mode 100644 TankiOffline/webpack.config.d/node-fallback.js diff --git a/.gitignore b/.gitignore index 5b0b92e..66b3d42 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/3DTank/docs/KtRebuild_Plan.md b/3DTank/docs/KtRebuild_Plan.md index a30f753..286c741 100644 --- a/3DTank/docs/KtRebuild_Plan.md +++ b/3DTank/docs/KtRebuild_Plan.md @@ -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@)` 并从函数体抓到顶层动作名(69 / 1 / 2 / 11 个); `launchParams` 是纯透传(`(pass-through)`,无 reducer),已把字段收口为 `Map`。 diff --git a/3DTank/docs/UI_Rebuild_Plan.md b/3DTank/docs/UI_Rebuild_Plan.md new file mode 100644 index 0000000..048ff98 --- /dev/null +++ b/3DTank/docs/UI_Rebuild_Plan.md @@ -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 现在做、首批大厅+车库、允许混跑。 diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/BrowserUi.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/BrowserUi.kt new file mode 100644 index 0000000..388ac9c --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/BrowserUi.kt @@ -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) + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/EntranceView.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/EntranceView.kt new file mode 100644 index 0000000..8fcf9c6 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/EntranceView.kt @@ -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 = """ +
+
+
+ + +
+
+
+
通过以下方式登录
+
+ +
+
+
+
+ + +
+ + 12+ +
+ TYPE: web|VER: Kotlin/JS| + SRC: TankiOffline|STATE: S3 +
+
+ + + """.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() + } + }) + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyClient.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyClient.kt new file mode 100644 index 0000000..b0a4c6d --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyClient.kt @@ -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 = 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() + + 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; })" + ) +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt new file mode 100644 index 0000000..3fbecbc --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/LobbyView.kt @@ -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> = 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", + """ +
+
${U.Rank}
+
+ ${Esc(U.Uid.ifEmpty { "未登录" })} + GS ${U.GearScore} · Rank ${U.Rank} +
+
+
+ ${U.Crystals} + ${U.Score} + + ${if (U.HasPremium) "VIP" else "普通"} + +
+ """ + ) + 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 = """ +
+
+
+
+ +
+ +
+ + +
+
+ +
+ + + +
未连接
+ + +
+ """.trimIndent() + + private fun NavButton(Id: String, Icon: String, Label: String, Key: String): String = """ + + """.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 """ +
+
+
${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}
+

${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}

+

快速匹配一场战斗,或从战斗列表中选择模式与地图。

+
+ + +
+ ${if (Connected) "" else """ +

当前未连接服务器:点右上角「连接」填入中继地址后即可接收真实大厅数据。

+ """} +
+
+
+ +
+ ${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", "车库"), "装备、皮肤与强化")} +
+ """.trimIndent() + } + + private fun HomeCard(Nav: String, Icon: String, Title: String, Text: String): String = """ + + """.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 """ +

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

+ +
+
+ ${Modes.joinToString("") { (Key, Name, Desc) -> """ + + """ }} +
+

战斗创建 / 匹配的完整链路属于 S4;这里先接真实状态里的战斗列表与模式文案。

+ """.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 """ +
+

${Esc(Translations.T("lobby.menu.GARAGE", "车库"))} + ${Filtered.size} / ${All.size} +

+ +
+
+ ${Categories.joinToString("") { (Id, Key, Fallback) -> + val Count = All.count { ItemCategory(it) == Id } + """ + + """.trimIndent() + }} +
+ ${if (All.isEmpty()) """ +
目录还是空的:连接服务器后会解出车库物品(当前离线目录 0 个)。
+ """ else """ +
${Shown.joinToString("") { ItemCard(it) }}
+ ${if (Filtered.size > Shown.size) "

仅显示前 ${Shown.size} 件,继续补充分页。

" else ""} + """} + """.trimIndent() + } + + private fun Shop(): String { + val All = Client.Router.Catalog.Items.values.filter { it.buyable } + val Shown = All.take(96) + return """ +

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

+ ${if (All.isEmpty()) """ +
离线目录里暂无可购买物品,连接服务器后会自动填充。
+ """ else """ +
${Shown.joinToString("") { ItemCard(it) }}
+ """} + """.trimIndent() + } + + private fun Containers(): String { + val All = Client.Router.Catalog.Items.values.filter { ItemCategory(it) == 13 } + return """ +

${Esc(Translations.T("lobby.menu.CONTAINERS", "幸运礼盒"))} + ${All.size}

+ ${if (All.isEmpty()) "
没有礼盒数据。
" + else "
" + All.take(60).joinToString("") { ItemCard(it) } + "
"} + """.trimIndent() + } + + private fun Quests(): String = """ +

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

+
+ ${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", "星级挑战"), "星际挑战")} +
+ """.trimIndent() + + private fun Friends(): String = """ +

${Esc(Translations.T("lobby.friends.TITLE", "好友"))}

+
好友列表将在社交链路补齐后接入。
+ """.trimIndent() + + private fun News(): String { + val Items = Client.Router.Store.State.News.News + return """ +

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

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

${Esc(N.description)}

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

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

+
+
连接大厅 / 空间中继地址
+
语言简体中文(运行时读取官方 zh-CN)
+
诊断帧 ${Client.Frames},记录 ${Client.Records},未覆盖 ${Client.Router.Unmapped}
+
+ """.trimIndent() + + private fun Placeholder(Title: String, Text: String): String = """ +

${Esc(Title)}

+
${Esc(Text)}
+ """.trimIndent() + + private fun Side(): String { + val Lines = Client.Logs.takeLast(12).joinToString("") { "
${Esc(it)}
" } + return """ +
+

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

+
${if (Lines.isEmpty()) "连接后显示服务器日志" else Lines}
+
+
+
+

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

+
新闻、公告与活动由服务端推送后显示在这里。
+
+ """.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 """ +
+
${Esc(Name.take(1))}
+
+ ${Esc(Name)} + ${Esc(Category)}${if (Count > 1) " · x$Count" else ""} + ${if (Premium) "VIP" else ""} +
+
+ """.trimIndent() + } + + private fun SetHtml(Id: String, Html: String) { + val El = Doc.getElementById(Id) ?: return + El.innerHTML = Html + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Browser/Translations.kt b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Translations.kt new file mode 100644 index 0000000..60ae411 --- /dev/null +++ b/TankiOffline/app/src/jsMain/kotlin/App/Browser/Translations.kt @@ -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 + } +} diff --git a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt index 53925c7..8c49a0b 100644 --- a/TankiOffline/app/src/jsMain/kotlin/App/Main.kt +++ b/TankiOffline/app/src/jsMain/kotlin/App/Main.kt @@ -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 diff --git a/TankiOffline/app/src/jsMain/resources/entrance.css b/TankiOffline/app/src/jsMain/resources/entrance.css new file mode 100644 index 0000000..0affa04 --- /dev/null +++ b/TankiOffline/app/src/jsMain/resources/entrance.css @@ -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; } +} diff --git a/TankiOffline/app/src/jsMain/resources/index.html b/TankiOffline/app/src/jsMain/resources/index.html new file mode 100644 index 0000000..b625823 --- /dev/null +++ b/TankiOffline/app/src/jsMain/resources/index.html @@ -0,0 +1,15 @@ + + + + + + TankiOffline · 大厅 + + + + +
+ + + + diff --git a/TankiOffline/app/src/jsMain/resources/lobby.css b/TankiOffline/app/src/jsMain/resources/lobby.css new file mode 100644 index 0000000..f04125b --- /dev/null +++ b/TankiOffline/app/src/jsMain/resources/lobby.css @@ -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; } +} diff --git a/TankiOffline/app/webpack.config.d/node-fallback.js b/TankiOffline/app/webpack.config.d/node-fallback.js new file mode 100644 index 0000000..96c97b5 --- /dev/null +++ b/TankiOffline/app/webpack.config.d/node-fallback.js @@ -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, +}); diff --git a/TankiOffline/webpack.config.d/node-fallback.js b/TankiOffline/webpack.config.d/node-fallback.js new file mode 100644 index 0000000..96c97b5 --- /dev/null +++ b/TankiOffline/webpack.config.d/node-fallback.js @@ -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, +});