feat: M14 搬运官方车库 UI(官方应用壳 + DOM 运行时等价实现 + 车库主屏)

按用户要求改为「搬运式复刻」:版式与样式全部取自官方反编译代码 module_82347.js,逐条标注 file:line,不自行排版。壳:client/src/jsMain/resources/index.html 复刻 3DTank/web/index.html(6 个 @font-face = Rubik-*.woff / MPLUS1p-*.ttf、#preloader、#root.wrapper、#app-root、#modal-root、.bgClipText),有意差异(https 重定向脚本/analytics/isRussia 检测/manifest/favicon/官方 main.6469b966.js)已在报告标注,改为加载 TankIcClient.js。运行时 Ui/Official/UiDom.kt:等价实现 FD(parent,tag,styleFn)/iq(styleObj)->类名(注入 style[data-emotion])/WD/ip2/lp1/fp2 与单位值函数(E$=%, G$=px, O$=em, zj=rgba, rnr=em/16, cnr=rem/16, Ms 的 0 去单位),并注入官方 Emotion Global 表(module_82347.js:105411-105424 的 btr:html/body/.wrapper/portrait 媒体查询)——.wrapper 的 position:absolute;height:100% 不在 index.html 里,是车库 height:100% 成立的前提。车库主屏 Ui/Official/OfficialGarage.kt:按官方实例化路径(module_82347.js:161904-162084 的 gPn -> :166813-166828 的 hGn -> _Ln/PWn/PGn/_Wn)搬节点层级与样式函数,含 GarageCommonStyle/GarageMainScreenStyle/MountedItemsStyle 的片段;设备块按官方 PGn.tan 的 case 13 语义合并为一类一块(6 块而非 33 块)。两处纠正我任务书里的错误假设(均有证据):w$ 不是单位换算而是 Kotlin 的 Any?.toString()(w$ = Co.$_$.je -> module_37036.js:1165 Br()),单位由调用点拼字符串;fq/jq/Y$ 不是样式注册表而是 Kotlin/JS 的类/对象/伴生对象元数据注册(module_37036.js:1249/1257/1266)。浏览器实测(经由 AssetRelay 8130):官方壳与车库主屏渲染、#preloader 已移除、13 条 emotion 类规则注入、字体 BaseFontRegular/Medium 与 Fallback 全 loaded、33/33 图片加载成功、真实中文名(火焰炮/炸弹/橄榄绿/神谕者/十字军/防护×28)、布局尺寸非零(host 1311x986、positionContent 1311x826@0,64、blockParameters 1119x591@96,64、mounted 368x2186)。逐步修掉两个真问题:#app-root 缺官方 PresetContainer(position:absolute;height:100%)导致下游 height:100% 退化成 auto 而塌成 0 高;以及设备块数按官方 case 13 语义合并。回归:CompareContainerDecode 5 帧/26950/0、selftest 1108/9/90、semantic --demo 全部断言通过、compileKotlinJs/jsBrowserProductionWebpack/jsBrowserDistribution 成功。未完成(待搬,附官方出处):_Ln 3D 预览、PWn TankParametersStyle 全部片段、_Wn SuppliesComponentStyle、vGn 块内 improvementArrow/commonBlockForTurretsHulls/resistanceContainer/iconNotificator/SkinsIcon;未实现:sp1 状态选择器、Global 表的 li/a(部分)/input::placeholder;物品单件图标因数据里缺 TankInfoCC(5543162332469864498) 而退化为 defaultDevice.svg(数据缺失,非算法问题)。
This commit is contained in:
TermiNexus committed 2026-09-18 17:01:35 +08:00
1 parent 6c36c5e98d
commit 2224ddc3f6
12 files changed
+2060 -172

No files matched your search

+53 -136
View File
@@ -1,151 +1,41 @@
// TankIcClient 客户端入口 // TankIcClient 客户端入口
// //
// node 分支(jsNodeDevelopmentRun):M1–M5 的协议层自检,保持原样不动。 // node 分支(jsNodeDevelopmentRun / jsDevelopmentExecutableRun):M1–M13 的协议层自检与 dump,**保持原样不动**。
// browser 分支:M6 的最小「车库」只读界面 —— 把 M5 已经能拿到的语义状态(ClientState)渲染出来。 // browser 分支:M14 —— 把官方**反编译源码里的 DOM UI 运行时与车库主屏**照搬到本客户端:
// · 应用壳 = 官方 3DTank/web/index.html 的等价物(见 resources/index.html:@font-face / #preloader /
// #root.wrapper / #app-root / #modal-root / html,body 样式);
// · 运行时 = Ui/Official/UiDom.kt(FD / WD / iq / w$ / ip2 / lp2 / fp2 / 样式类注册表的等价实现);
// · 车库主屏 = Ui/Official/OfficialGarage.kt(GarageMainScreenStyle.blockParameters 及其使用处那一棵树)。
// //
// 界面刷新方式(M6 选的是「状态容器 + 手动重渲染」这条,没用 useState/useEffect): // 刷新方式:沿用既有的「全局状态 + 手动重渲染根节点」——会话对象每次收帧/发上行回调一次,
// GarageSession 每次收到帧 / 发出上行都会回调 OnChanged,这里把根节点再 render 一次, // 这里把 #app-root 清空后按官方层级重挂一遍(DOM 直写,不走 React;理由见 UiDom.kt 文件头)。
// React 按元素类型做协调、复用同一批 DOM 节点(所以输入框里已输入的内容不会被清掉)。
// 为什么不走 hooks:本页面只有一份全局会话状态,用根节点重渲染语义最简单,也不引入 hook 依赖数组的心智负担。
package TankIcClient package TankIcClient
import react.FC
import react.Props
import react.create
import react.dom.client.createRoot
import react.dom.html.ReactHTML.button
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h1
import react.dom.html.ReactHTML.h2
import react.dom.html.ReactHTML.input
import react.dom.html.ReactHTML.li
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.ul
import web.cssom.ClassName
import web.dom.ElementId
import web.dom.document
import web.html.HTMLElement
import kotlinx.browser.window
import Protocol.ContainerDump import Protocol.ContainerDump
import Protocol.GarageDriver import Protocol.GarageDriver
import Protocol.GarageSession
import Protocol.IsNodeRuntime import Protocol.IsNodeRuntime
import Protocol.ItemInfoDriver import Protocol.ItemInfoDriver
import Protocol.SelfTest import Protocol.SelfTest
import Protocol.SpaceDumpDriver import Protocol.SpaceDumpDriver
import Ui.Session
import Ui.Translations
import Ui.UiState
import Ui.Official.AppRoot
import Ui.Official.GarageMainScreen
import Ui.Official.InjectOfficialGlobal
import Ui.Official.RemovePreloader
import kotlinx.browser.window
// 全局会话状态容器:node 分支不会用到它(构造时不碰任何浏览器 API) // 渲染官方界面:清空 #app-root → 按官方节点层级重挂 → 移除 #preloader(官方行为,module_13431.js:625-643)
private val Session = GarageSession { RenderApp() } private fun RenderOfficial() {
val Root = AppRoot() ?: return
// 页面根组件:只读渲染 Session 的快照 InjectOfficialGlobal()
private val App = FC<Props> { Root.innerHTML = ""
div { GarageMainScreen.Mount(Root)
h1 { +"TankIcClient · M10 车库(物品装备 + 挂载位设备装备)" } RemovePreloader()
// ---------------- 1 连接区
div {
className = ClassName("Card")
h2 { +"连接" }
div {
className = ClassName("Row")
input {
id = ElementId("ServerUrl")
className = ClassName("UrlInput")
defaultValue = Session.Url
onChange = { E -> Session.Url = E.target.value }
}
button {
onClick = { Session.Connect() }
+"连接并进车库(100073070)"
}
}
p { +"当前状态:${Session.Status}" }
if (Session.LastError.isNotEmpty()) p { +"错误原因:${Session.LastError}" }
}
// ---------------- 2 仓库物品面板(itemsOnDepot)
div {
className = ClassName("Card")
h2 { +"仓库物品(itemsOnDepot):${Session.DepotItems.size} 件" }
val Mounted = Session.MountedItems.toHashSet()
ul {
for (Id in Session.DepotItems) {
val IsMounted = Mounted.contains(Id)
li {
// 类别一并渲染:显示**枚举成员名**(附原始 enum#N);M8 的同类互斥就是按原始类别判定的
+("$Id 类别 ${Session.CategoryLabelOf(Id)} " + (if (IsMounted) "已挂载" else "未挂载") + " ")
// 「装备」按钮:点击发 M_.fcn(ItemId);已挂载的置灰(防重复点,落库本身也幂等)
button {
disabled = IsMounted
onClick = { Session.Equip(Id) }
+"装备"
}
}
}
}
if (Session.DepotItems.isEmpty()) p { +"(尚未收到 itemsOnDepot;挂载依据 mountedItems 集合,共 ${Session.MountedItems.size} 件)" }
}
// ---------------- 3 挂载位面板(devicesLoaded)
div {
className = ClassName("Card")
h2 { +"挂载位(devicesLoaded):槽位 ${Session.Slots.size} 个 / 设备 ${Session.DeviceCount} 个" }
ul {
for (D in Session.Slots) {
li {
+("槽位 ${D.SlotObjectId} 设备 ${D.Devices.size} 个 当前装备:" + (D.MountedDevice ?: "未装备"))
// 每个可用设备一个「装备 <设备id>」按钮:点击发 dh.hcu(设备id),靶 = 该挂载位。
// 已装备的那个置灰(本地乐观更新会让该槽位行立刻变成当前装备,落库本身也幂等)。
ul {
for (Dev in D.Devices) {
li {
button {
disabled = D.MountedDevice == Dev
onClick = { Session.EquipDevice(D.SlotObjectId, Dev) }
+"装备 $Dev"
}
}
}
}
}
}
}
if (Session.Slots.isEmpty()) p { +"(尚未收到 devicesLoaded)" }
}
// ---------------- 4 市场面板(itemsOnMarket,只渲染前 20 个 id)
div {
className = ClassName("Card")
h2 { +"市场(itemsOnMarket):共 ${Session.MarketItems.size} 件(只渲染前 20 个 id)" }
ul {
className = ClassName("Plain")
for (Id in Session.MarketItems.take(20)) li { +Id }
}
}
// ---------------- 5 日志区(最近 MaxLogLines 条收发记录)
div {
className = ClassName("Card")
h2 { +"日志:最近 ${Session.Logs.size} 条" }
ul {
className = ClassName("Plain Log")
for (Line in Session.Logs) li { +Line }
}
}
}
} }
// React 根节点只建一次;之后每次刷新都是对同一个根 render 一个新元素 // 入口函数:Node 下跑协议层自检;浏览器下渲染官方界面并把探针挂到 window 上
private var Root: dynamic = null
private fun RenderApp() {
val Container = document.getElementById(ElementId("root")) as? HTMLElement
?: error("未找到 id 为 root 的挂载节点")
if (Root == null) Root = createRoot(Container)
Root.render(App.create())
}
// 入口函数:Node 下跑协议层自检;浏览器下渲染页面并把入口挂到 window 上
fun main() { fun main() {
if (IsNodeRuntime()) { if (IsNodeRuntime()) {
// 比对脚本用:只把指定抓包帧解成 JSON 输出(TANKIC_CONTAINER_DUMP=1),不连服务器 // 比对脚本用:只把指定抓包帧解成 JSON 输出(TANKIC_CONTAINER_DUMP=1),不连服务器
@@ -171,6 +61,33 @@ fun main() {
SelfTest.Run() SelfTest.Run()
return return
} }
RenderApp()
window.asDynamic().TankIcClientMain = { RenderApp() } // 浏览器:接上重绘回调 → 渲染官方壳 → 拉官方文案 → 自动连一次 9091 并进车库
UiState.Redraw = { RenderOfficial() }
Translations.OnRedraw = { RenderOfficial() }
RenderOfficial()
Translations.Ensure()
Session.Connect()
window.asDynamic().TankIcClientMain = { RenderOfficial() }
// 浏览器实测取证用:把关键状态挂到 window 上(只读)
window.asDynamic().TankIcClientProbe = {
val O = js("({})")
O.Screen = UiState.Screen
O.GarageStatus = Session.Status
O.DepotItems = Session.DepotItems.size
O.MountedItems = Session.MountedItems.size
O.MarketItems = Session.MarketItems.size
O.Slots = Session.Slots.size
O.Devices = Session.DeviceCount
O.TranslationCount = Translations.Count()
O
}
// 官方运行时取证用:已注入的 Emotion 类(data-emotion)与样式类注册表条目
window.asDynamic().TankIcOfficialProbe = {
val O = js("({})")
val El = js("document.querySelector('style[data-emotion]')")
O.InjectedCss = if (El == null) 0 else (El.asDynamic().textContent as String).length
O.RootHtml = js("(document.getElementById('app-root')||{innerHTML:''}).innerHTML")
O
}
} }
@@ -28,6 +28,9 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
private set private set
var LastError: String = "" var LastError: String = ""
private set private set
// 是否已进空间(大厅菜单据此避免重复重连;连接/重连时清零)
var Connected: Boolean = false
private set
private var Conn: Connection? = null private var Conn: Connection? = null
private var Auto: AutoResponder? = null private var Auto: AutoResponder? = null
@@ -62,6 +65,20 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
// 类别的展示文本(成员名 + 原始序数):UI/日志用;查不到写「enum#N(未解)」,无 pt 数据写「?」 // 类别的展示文本(成员名 + 原始序数):UI/日志用;查不到写「enum#N(未解)」,无 pt 数据写「?」
fun CategoryLabelOf(ItemId: String): String = StateObj.ItemCategoryLabel(ItemId) fun CategoryLabelOf(ItemId: String): String = StateObj.ItemCategoryLabel(ItemId)
// ---- 界面用的可展示信息(名称 / 类别 / 稀有度 / 型号 / 图标),全部来自服务端下发的 pt 模型表
// (与 M13a 的 ItemInfoDriver 同一份 ClientState 实现,两端不重写规则)
fun InfoOf(ItemId: String): ItemInfo = StateObj.ItemInfo(ItemId)
fun ItemInfos(): List<ItemInfo> = StateObj.ItemInfos()
// 调试抽屉用:整份语义状态 JSON(与 M5 的 <<<TANKIC_STATE_JSON>>> 同一份 ToJson 输出)
fun StateJson(): String = StateObj.ToJson()
val PtObjectCount: Int get() = StateObj.PtObjectCount
val DevicesRawCount: Int get() = StateObj.DevicesRawCount
val ZtRecordCount: Int get() = StateObj.ZtRecords.size
val UnloadRecordCount: Int get() = StateObj.UnloadRecords.size
// ---------------------------------------------------------------- 连接 // ---------------------------------------------------------------- 连接
fun Connect() { fun Connect() {
@@ -72,6 +89,7 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
FrameNo = 0 FrameNo = 0
EnterSent = false EnterSent = false
LastError = "" LastError = ""
Connected = false
LsDerived = emptyList() LsDerived = emptyList()
BaDerived = emptyList() BaDerived = emptyList()
LsEnqueued = false LsEnqueued = false
@@ -167,6 +185,7 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
AddLog("→ c2s Auth/EnterSpace($SpaceId) ${Req.size}B") AddLog("→ c2s Auth/EnterSpace($SpaceId) ${Req.size}B")
Conn?.Send(Req) Conn?.Send(Req)
Status = "已进空间 $SpaceId" Status = "已进空间 $SpaceId"
Connected = true
} }
} }
} }
@@ -0,0 +1,176 @@
// SpaceSession.kt —— 浏览器侧「进空间 + 收帧 + 解码」会话(M12-B 的 node 侧实现 SpaceDumpDriver 的浏览器移植)
//
// 用途:大厅里除车库之外还能进的两个空间(商店 100072911 / 聊天 100045720)——它们没有会话回放模板,
// 服务端走「进空间即下发初始化帧」那条路(names/space-init-<id>.json)。本类只做三件事:
// 1) 连 ws → Hash Request → Hash Response → Auth/EnterSpace(spaceId)
// 2) 收帧并**逐帧逐记录解码**(复用同一份 SpaceFrame/Container/ProtocolTables,与 node 侧口径一致)
// 3) 收满 DumpMs 后关连接,把帧/记录/容器摘要留给界面显示
//
// 与 GarageSession 的分工:车库有会话回放 + 靶推导(要发 ls.xbl/ba.lcr/M_.fcn),本类只做「进得去、解得开」。
// 自动应答(G.q1wd)沿用 AutoResponder:初始化帧里若带 zt 回调号,仍按同一协议回一次。
package Protocol
// 一帧空间下行的可展示摘要
internal class SpaceFrameLine(
val FrameNo: Int,
val TotalBytes: Int,
val RecordCount: Int,
val Labels: List<String>,
val Summaries: List<String>,
val Bad: Boolean,
val Note: String,
)
internal class SpaceSession(private val OnChanged: () -> Unit) {
var Url: String = "ws://localhost:9091/"
var SpaceId: Long = 0L
private set
var Status: String = "未进入"
private set
var LastError: String = ""
private set
var Done: Boolean = false
private set
val Frames = ArrayList<SpaceFrameLine>()
val Logs = ArrayList<String>()
var RecordTotal = 0
private set
var BadFrames = 0
private set
private var Conn: Connection? = null
private var Auto: AutoResponder? = null
private var FrameNo = 0
private var EnterSent = false
private var CaptureMs = 0
// 进空间:一次独立连接(与服务端 index.js 的「空间连接」模型一致)
fun Enter(Space: Long, CaptureMilliseconds: Int = 9000) {
Conn?.Close(1000, "重新进入")
Frames.clear()
Logs.clear()
RecordTotal = 0
BadFrames = 0
FrameNo = 0
EnterSent = false
Done = false
LastError = ""
SpaceId = Space
CaptureMs = CaptureMilliseconds
Status = "连接中(空间 $SpaceId)"
AddLog("→ 建立连接 $Url(目标空间 $SpaceId)")
val C = Connection(Url)
Conn = C
Auto = AutoResponder(SpaceId) { Bytes -> C.Send(Bytes) }
C.OnOpen = {
val Req = Entry.BuildHashRequest(SelfTest.HashKeys, SelfTest.HashValues)
AddLog("→ c2s Hash Request ${Req.size} 字节")
C.Send(Req)
}
C.OnMessage = { OnMessage(it) }
C.OnClose = { Code ->
AddLog("连接关闭 code=$Code")
if (!Done) Status = "连接关闭 code=$Code"
OnChanged()
}
C.OnError = { Msg ->
LastError = Msg
Status = "错误:$Msg"
AddLog("✗ $Msg")
OnChanged()
}
C.Connect()
OnChanged()
}
private fun OnMessage(Raw: ByteArray) {
if (Frame.IsPingFrame(Raw)) return
var Off = 0
while (Off < Raw.size) {
val Info = Frame.PeekFrame(Raw, Off) ?: break
val Body = try {
Frame.DecodeFrame(WireFrame(Raw.copyOfRange(Off + Info.HeaderSize, Off + Info.TotalSize), Info.Compressed))
} catch (E: ProtocolException) {
AddLog("✗ 帧解压失败:${E.message}")
OnChanged()
return
}
HandleFrame(Info.TotalSize, Body)
Off += Info.TotalSize
}
OnChanged()
}
private fun HandleFrame(TotalBytes: Int, Body: ByteArray) {
val NmSize = Nullmap.Size(Body, 0)
val MethodAt8 = if (Body.size >= NmSize + 16) Body.ReadI64(NmSize + 8).toString() else ""
val IsSpace = ProtocolTables.Downlink(MethodAt8) != null || ProtocolTables.CodecByMethod(MethodAt8) != null
if (!IsSpace) {
for (C in Entry.DecodeServerBody(Body)) {
AddLog("← s2c 入口 ${C.Name} ${TotalBytes}B")
if (C.Id == Entry.S2C_HASH_RESPONSE && C.Digest != null && !EnterSent) {
EnterSent = true
val Req = Entry.BuildEnterSpace(C.Digest, SpaceId)
AddLog("→ c2s Auth/EnterSpace($SpaceId) ${Req.size}B")
Conn?.Send(Req)
Status = "已进空间 $SpaceId,收帧中(${CaptureMs / 1000}s)"
StartCaptureTimer()
}
}
return
}
FrameNo++
val Parsed = SpaceFrame.Parse(Body)
Auto?.OnSpaceFrame(Parsed, FrameNo)
val Summaries = ArrayList<String>()
for (R in Parsed.Records) {
if (R.Kind == "container") Summaries.add("布局 ${R.Layout} 摘要:${Container.Summary(R.Layout, R.Value)}")
}
val Line = SpaceFrameLine(
FrameNo, TotalBytes, Parsed.Records.size,
Parsed.Records.map { LabelOf(it) }, Summaries, Parsed.Bad, Parsed.Note,
)
Frames.add(Line)
RecordTotal += Parsed.Records.size
if (Parsed.Bad) BadFrames++
AddLog(
"← s2c 帧#$FrameNo 整帧 $TotalBytes 字节 | 记录数 ${Parsed.Records.size} " +
Line.Labels.distinct().joinToString(" + ") + (if (Parsed.Bad) " ⚠ ${Parsed.Note}" else "")
)
}
private fun StartCaptureTimer() {
val Ms = CaptureMs
SetTimeout(Ms) { Finish() }
}
private fun Finish() {
if (Done) return
Done = true
Status = "已完成:空间 $SpaceId 收到 $FrameNo 帧 / $RecordTotal 条记录 / Bad $BadFrames"
AddLog("✓ 空间 $SpaceId 收帧结束:$FrameNo 帧,$RecordTotal 条记录,Bad $BadFrames")
Conn?.Close(1000, "收帧完成")
OnChanged()
}
private fun LabelOf(R: SpaceRecord): String {
val Spec = ProtocolTables.Downlink(R.MethodId)
?: return if (R.Kind == "container") "容器${R.Layout}" else "未知(${R.MethodId})"
val Tail = if (Spec.Kind == "container") "/${Spec.Layout}" else ""
return Spec.Name.ifEmpty { "(未命名)" } + Tail
}
private fun AddLog(Line: String) {
Logs.add(Line)
while (Logs.size > MaxLogLines) Logs.removeAt(0)
println("TankIcClient | [Space $SpaceId] $Line")
}
private companion object {
const val MaxLogLines = 200
}
}
@@ -0,0 +1,384 @@
// OfficialGarage.kt —— 「车库主屏」这一屏的搬运(M14)
//
// 实例化路径(任务要求「以官方实例化路径为准」):
// 1) GarageComponent(gPn,注册 module_82347.js:107427 `jq(gPn, "", gPn, M0t, [M0t, C$])`)
// 的 render 里,当 screen instanceof $$ 时挂载主屏:
// module_82347.js:162054-162057
// `else if (e instanceof $$) { iPn(0, t.m1i9_1.j1oa_1); var c = kq(hGn); n.ian(c, lPn); }`
// 2) 主屏组件 = hGn(class,注册 module_82347.js:107493 `jq(hGn, "", hGn, gq)`),
// 其 render = `(lq(hGn).tan = ...)`,module_82347.js:166813-166828:
// i = root 节点,样式 `QRn().n1o9()` → GarageCommonStyle.positionContent
// e = 子块, 样式 `nGn().h1ol()` → GarageMainScreenStyle.blockParameters
// e 下依次挂:kq(_Ln) 坦克预览 / kq(PWn) 坦克参数 / kq(PGn) 挂载物品
// i 下再挂:kq(_Wn) 补给(SuppliesComponent)
// (lq(hGn).tan = function (t) {
// var n = rGn, i = $q.xum(n); i.tuo().lp2(QRn().n1o9()); // 166816-166817
// var r = eGn, e = $q.xum(r); e.tuo().lp2(nGn().h1ol()); // 166818-166819
// var u = kq(_Ln); e.ian(u, uGn); // 166820-166821
// var s = kq(PWn); e.ian(s, oGn); // 166822-166823
// var o = kq(PGn); (e.ian(o, _Gn), i.can(e.s9j())); // 166824-166825
// var _ = kq(_Wn); (i.ian(_, aGn), t.can(i.s9j())); // 166826-166827
// })
// 3) 本片搬运范围:root(positionContent) + blockParameters + 挂载物品容器(PGn 那棵子树的最大一块)。
// 未搬完的子树见报告「待搬清单」。
package Ui.Official
import Ui.Session
import Ui.Translations
import Ui.UiAssets
import org.w3c.dom.HTMLElement
// ================================================================
// 一、官方样式片段(每条都给出处;键名沿用官方 Kotlin CSS DSL 的 camelCase,序列化时转 kebab-case)
// ================================================================
// ---- p8i()(module_82347.js:104864 → 构造 w8i @103429)里的预设片段
// 为什么单独抽出来:positionContent 只声明 `lp2(p8i().z1hj())`,真正的 CSS 在预设里。
// "displayFlex" —— getter module_82347.js:209645-209659;初值 E4i @103074-103076
private fun DisplayFlex(S: StyleSheet) {
S.Set("display", "flex") // N$(t, Xx()) Xx = module_63383.js:7130 → (lo(), yt) = "flex"(lo() @1279)
}
// "displayFlexColumn" —— getter module_82347.js:209675-209689;初值 I4i @103080-103082(依赖 displayFlex)
private fun DisplayFlexColumn(S: StyleSheet) {
DisplayFlex(S)
S.Set("flexDirection", "column") // Zz(t, Jz()) Zz=Aa=flexDirection(module_63383.js:3085);Jz=module_63383.js:7154 → (po(), Ot) = "column"(po() @1338)
}
// "alignCenter" —— getter module_82347.js:209720-209734;初值 P4i @103089-103091
private fun AlignCenter(S: StyleSheet) {
S.Set("alignItems", "center") // tS(t, Wj()) tS=a_=alignItems(module_63383.js:1642);Wj=module_63383.js:7031 → (vo(), Et) = "center"(vo() @1316)
}
// "flexStartAlignCenterColumn" —— getter module_82347.js:209975-209989;初值 Q4i @103143-103145
// 依赖 = [displayFlexColumn, alignCenter](module_82347.js:104218-104221),所以先并入那两份
private fun FlexStartAlignCenterColumn(S: StyleSheet) {
DisplayFlexColumn(S)
AlignCenter(S)
S.Set("justifyContent", "flex-start") // Xj(t, Qz()) Xj=oh=justifyContent(module_63383.js:3603);Qz=module_63383.js:7198 → (Qs(), U) = "flexStart"(Qs() @1124)
}
// "flexSpaceBetweenAlignCenterColumn" —— getter module_82347.js:210305-210319;初值 k6i @103209-103211
// 依赖 = [displayFlexColumn, alignCenter](module_82347.js:104548-104551)
private fun FlexSpaceBetweenAlignCenterColumn(S: StyleSheet) {
DisplayFlexColumn(S)
AlignCenter(S)
S.Set("justifyContent", "space-between") // k6i @103210 Xj(t, pT()) = "spaceBetween"
}
// p8i() 的 "container" 预设 —— getter (lq(w8i).o1hb);初值 i4i @102912-102922
// 使用处 = GarageComponent(gPn)的 render 根节点,module_82347.js:161907 `i.tuo().lp2(p8i().o1hb())`
// ★这一条是车库主屏能被看见的关键:它给了一个 `position:absolute; height:100%` 的定高容器,
// 否则 #app-root(高度 auto)会让下游的 `height:100%` 全部退化成 auto。
// 官方还有一条 `lp2(p8i().v1hj())`(@102915,flexSpaceBetweenAlignCenterColumn)——见下方一并并入。
private fun PresetContainer(S: StyleSheet) {
S.Set("background", "radial-gradient(#203040 0%, #03080D 100%)") // 102914 Zj(t, "...") Zj=module_63383.js:1827 je("background")
FlexSpaceBetweenAlignCenterColumn(S) // 102915 t.lp2(p8i().v1hj())
S.Set("borderWidth", "0") // 102916 ux(t, ex(O$(0))) ux=m9=border 简写,四边宽 0
S.Set("width", Pct(100)) // 102917 T$(t, E$(100))
S.Set("height", Pct(100)) // 102918 I$(t, E$(100))
S.Set("position", "absolute") // 102919 mj(t, qj())
}
// ---- GarageCommonStyle(module_82347.js:107425 `fq(ZRn, "GarageCommonStyle", hq, K$)`;构造 @55722-55909)
internal object GarageCommonStyle {
// "commonStylePositionContent" —— 属性 getter module_82347.js:55767-55781;初值 MRn @55639-55650
// 注:官方 height 在 navigator.userAgent 含 "GamesWindowsDesktopApp" 时取 "auto"(yj().pp3_1),
// 浏览器下取 "100%"(E$(100))。本页是浏览器,取 100%。
val CommonStylePositionContent: StyleFn = { S ->
S.Set("userSelect", "none") // sA(t, oA()) sA=zc=userSelect;oA=module_63383.js:7383 → (Js(), N) = "none"
S.Set("height", Pct(100)) // I$(t, E$(100)) I$=uh=height;E$=module_63383.js:7927 → new Ms(v,"%")
S.Set("width", Pct(100)) // T$(t, E$(100)) T$=Pc=width
S.Set("marginTop", RnR(24)) // Hx(t, rnr(24)) Hx=qh=marginTop;rnr(n)=O$(n/16)(module_82347.js:105755)
Fnr(S, 1280, 950) { M -> // fnr(t, 1280, 950, DRn) module_82347.js:105800
M.Set("marginTop", RnR(64)) // DRn @55651-55653
}
}
// "positionContent" —— 属性 getter module_82347.js:55770-55781;初值 LRn @55654-55656
// 官方写法:依赖 [commonStylePositionContent],自身只加 `lp2(p8i().z1hj())`
val PositionContent: StyleFn = { S ->
CommonStylePositionContent(S)
FlexStartAlignCenterColumn(S)
}
// "garageContainer" —— 属性 getter module_82347.js:161684-161698;初值 NRn @55633-55635
// 使用处 = GarageComponent(gPn)render 的子节点,module_82347.js:161928-161931 `e.tuo().lp2(QRn().m1o9())`
val GarageContainer: StyleFn = { S ->
S.Set("width", Pct(100)) // 55634 T$(t, E$(100))
S.Set("height", "calc(100% - 6em)") // 55634 I$(t, new Jj("calc(100% - 6em)"))
FlexStartAlignCenterColumn(S) // 55634 t.lp2(p8i().z1hj())
}
}
// ---- GarageMainScreenStyle(module_82347.js:107492 `fq(tGn, "GarageMainScreenStyle", hq, K$)`)
internal object GarageMainScreenStyle {
// 唯一属性 "blockParameters" —— 构造 module_82347.js:60325-60341;
// getter `(lq(tGn).h1ol)` module_82347.js:166790-166804;使用处 module_82347.js:166819;
// 初值 JFn @60289-60305(+ 断点覆盖 ZFn @60307-60309)
val BlockParameters: StyleFn = { S ->
S.Set("display", "flex") // 60291 N$(t, Xx())
S.Set("justifyContent", "space-between") // 60292 Xj(t, pT()) pT=module_63383.js:7204 → (Qs(), G) = "spaceBetween"
S.Set("alignItems", "flex-start") // 60293 tS(t, RE()) RE=module_63383.js:7040 → (vo(), At) = "flexStart"
S.Set("flexGrow", "0") // 60294 V$(t, 0) V$=j8=Pa=flexGrow
S.Set("flexShrink", "1") // 60295 Tj(t, 1) Tj=k8=Oa=flexShrink
S.Set("flexBasis", "auto") // 60296 Aj(t, Ij().bp7_1) Aj=h8=Ta=flexBasis;Ij=zo()(module_63383.js:1420)→ xo().bp7_1 = new So("auto")(module_63383.js:1412)
S.Set("userSelect", "none") // 60297 sA(t, oA())
S.Set("cursor", "default") // 60298 Ej(t, Jx()) Ej=b8=ja=cursor;Jx=module_63383.js:7115 → (so(), rt) = "default"
S.Set("height", "calc(100% - 14.6875em)") // 60299 I$(t, new Jj("calc(100% - 14.6875em)")) Jj = x5 = Ns(原样字符串值)
S.Set("width", "calc(100% - 3em)") // 60300 T$(t, new Jj("calc(100% - 3em)"))
S.Set("position", "relative") // 60301 mj(t, gj()) mj=ka=rc=position;gj=module_63383.js:7286 → (Ds(), y) = "relative"
S.Set("zIndex", "3") // 60302 xj(t, (Dtr().y1hj_1 + 3) | 0) xj=vb=Bc=zIndex;Dtr().y1hj_1 = 0(module_82347.js:105639 `new Mtr("DEFAULT", 0, 0)`)
Fnr(S, 1280, 950) { M -> // 60303 fnr(t, 1280, 950, ZFn)
M.Set("width", "calc(100% - 12em)") // ZFn @60307-60309
}
}
}
// ---- MountedItemsStyle(module_82347.js:107497 `fq(eHn, "MountedItemsStyle", hq, K$)`;
// 单例 getter uHn() @61098;构造 @60899-61097)
// 使用处 = MountedItemsComponent(PGn)的 render,module_82347.js:166959-166982
internal object MountedItemsStyle {
// "container" —— getter module_82347.js:166983-166997;初值 NGn @60774-60783;使用处 166963
val Container: StyleFn = { S ->
S.Set("display", "flex") // N$(t, Xx())
S.Set("flexDirection", "column") // Zz(t, Jz())
S.Set("width", RnR(368)) // T$(t, rnr(368)) = 23em
S.Set("position", "absolute") // mj(t, qj()) qj=module_63383.js:7274 → (Ds(), k) = "absolute"(Ds() @1015)
S.Set("right", RnR(0)) // jj(t, rnr(0)) jj=ra=uc(module_63383.js:4565 里 je("right"));rnr(0) → Ms 的 0 去单位(module_63383.js:1012-1014)
S.Set("zIndex", "2") // xj(t, (Dtr().y1hj_1 + 2) | 0) = 0 + 2
}
// "containerBlockGarage" —— getter module_82347.js:166998-167012;初值 OGn @60785-60796(+ 断点 MGn @60798-60800);使用处 166966
val ContainerBlockGarage: StyleFn = { S ->
S.Set("display", "flex") // N$(t, Xx())
S.Set("flexWrap", "wrap") // NE(t, PE()) NE=l8=Da=flexWrap;PE=module_63383.js:7166 → (mo(), Ft) = "wrap"(mo() @1354)
S.Set("justifyContent", "flex-end") // Xj(t, ZI()) ZI=module_63383.js:7195 → (Qs(), F) = "flexEnd"
S.Set("height", "inherit") // I$(t, yj().rp3_1) yj()=Rs()→As()(module_63383.js:985);rp3_1 = new Ns("inherit")(:990)
S.Set("overflow", "hidden") // LT(t, Kx()) LT=z9=Bh=overflow;Kx=module_63383.js:7256 → (Us(), z) = "hidden"(Us() @1035)
S.Set("paddingTop", RnR(2)) // tx(t, rnr(2)) tx=ha=Qh=paddingTop(module_63383.js:4454)
S.Set("paddingRight", RnR(2)) // xS(t, rnr(2)) xS=ga=Jh=paddingRight(module_63383.js:4417)
Gnr(S, 1022, 650) { M -> // gnr(t, 1022, 650, MGn) module_82347.js:105819
M.Set("paddingTop", Em(0.25)) // MGn @60798-60800 tx(t, O$(0.25))
M.Set("paddingRight", Em(0.25)) // xS(t, O$(0.25))
}
}
// 每个分类块(官方 vGn 里的 `a.lp2(uHn().r1os(是否选中))`,调用 module_82347.js:60409-60416
// / 定义 `(lq(eHn).r1os = ...)` module_82347.js:167013-167038)
fun Block(Highlight: Boolean): StyleFn = { S ->
S.Set("display", "flex") // 167018 N$(t, Xx())
S.Set("flexDirection", "column") // 167019 Zz(t, Jz())
S.Set("marginTop", Em(0)) // 167020 Hx(t, O$(0)) = "0"
S.Set("cursor", "pointer") // 167021 Ej(t, Cj()) Cj=module_63383.js:7034 邻位 → (so(), et) = "pointer"
S.Set("userSelect", "none") // 167022 sA(t, oA())
S.Set("width", RnR(673)) // 167023 T$(t, rnr(673)) = 42.0625em
S.Set("marginBottom", RnR(10)) // 167024 cz(t, rnr(10)) cz=n9=dh=marginBottom
S.Set("position", "relative") // 167025 mj(t, gj())
S.Set("borderRadius", Rem(8)) // 167026 H$(t, O9i().a1hj_1) H$=h7=Y_=borderRadius;a1hj_1 = cnr(8)(module_82347.js:102626)= 0.5rem
if (Highlight) {
S.Set("backgroundColor", "transparent") // 167028 F$(t, D9i().h1hc_1) F$=p6=q_=backgroundColor;h1hc_1 = new Rz("transparent")(:102641)
S.Set("boxShadow", "0em 0em 0em 1px rgb(255, 255, 255, 0.25)") // 167029 t.ip2("box-shadow", ...)
// 167030 t.sp1(DGn):官方给该块挂了一个「状态选择器」(DGn = box-shadow 0 0 0 2px rgb(255,204,0),@60801-60803)。
// sp1 的状态选择器语义未解出 → 见报告「官方未找到/未实现」清单。
} else {
S.Set("boxShadow", "0em 0em 0em 1px rgb(255, 255, 255, 0.25)") // 167031 t.ip2("box-shadow", ...)
// 167032 t.sp1(LGn):同上(LGn = box-shadow 0 0 0 2px rgb(191,213,255),@60804-60806)
S.Set("color", Rgb(191, 213, 255)) // 167033 Lj(t, D9i().q1hg_1) Lj=z7=ka(module_63383.js:2863 里 je("color"));q1hg_1 = D$(191,213,255)(:102755)
}
}
// "tankPartNameContainer" —— getter module_82347.js:167040-167054;初值 BGn @60807-60811
// (+ 嵌套规则 UGn @60812-60821);使用处 module_82347.js:60446
val TankPartNameContainer: StyleFn = { S ->
S.Set("display", "flex") // 60808 N$(t, Xx())
S.Set("flexDirection", "row") // 60808 Zz(t, UM()) UM=module_63383.js:7160 邻位 n1 → (po(), Dt) = "row"
S.Set("justifyContent", "flex-start") // 60808 Xj(t, Qz())
S.Set("alignItems", "flex-start") // 60808 tS(t, RE())
S.Set("height", RnR(33)) // 60808 I$(t, rnr(33)) = 2.0625em
// 60810 t.np1(_D(), UGn):官方给 `h1` 标签挂了一份嵌套样式(_D() = "h1" 标签选择器,module_63383.js:7723)。
// UGn 里可解出的只有颜色;其余声明(rx/ix、ux、ox、Uj)官方函数链未解出 → 见未实现清单。
S.NestedRule("h1") { H ->
H.Set("color", Rgb(191, 213, 255)) // UGn @60814 Lj(t, D9i().q1hg_1)
}
}
// "itemPreview" —— getter (lq(eHn).y1on);初值 VGn @60847-60849;使用处 module_82347.js:60455
val ItemPreview: StyleFn = { S ->
S.Set("width", RnR(202)) // T$(t, rnr(202)) = 12.625em
S.Set("height", "auto") // I$(t, yj().pp3_1) pp3_1 = new Ns("auto")(module_63383.js:988)
S.Set("position", "relative") // mj(t, gj())
S.Set("alignSelf", "center") // Vj(t, Wj()) Vj=module_82347.js:1453 → Fo.$_$.i6 = c_ = alignSelf(module_63383.js:1679);Wj() = "center"
}
// "deviceIcon" —— getter (lq(eHn).u1os);初值 QGn @60873-60875;使用处 module_82347.js:60486
val DeviceIcon: StyleFn = { S ->
S.Set("width", RnR(32)) // T$(t, rnr(32)) = 2em
S.Set("height", RnR(32)) // I$(t, rnr(32)) = 2em
}
// "commonBlockForTurretsHulls" —— getter (lq(eHn).v1os);初值 ZGn @60870-60872
val CommonBlockForTurretsHulls: StyleFn = { S ->
S.Set("width", RnR(360)) // T$(t, rnr(360)) = 22.5em
S.Set("height", RnR(200)) // I$(t, rnr(200)) = 12.5em
S.Set("position", "relative") // mj(t, gj())
S.Set("justifyContent", "flex-start") // Xj(t, pT())
}
}
// ================================================================
// 二、把主屏渲染出来(节点层级 = 官方 hGn.tan 的调用顺序)
// ================================================================
internal object GarageMainScreen {
// 官方节点层级(module_82347.js:161904-162084 的 GarageComponent + 166813-166828 的 GarageMainScreen):
// div(container) ← gPn 根,p8i().o1hb() @161907 / i4i @102912-102922
// div(garageContainer) ← gPn 子,QRn().m1o9() @161931 / NRn @55633
// div(positionContent) ← hGn 根,QRn().n1o9() @166817
// div(blockParameters) ← hGn 子,nGn().h1ol() @166819
// div.tankPreviewContainer ← _Ln (3D 坦克预览;本片只搬容器,见待搬清单)
// div(TankParametersStyle) ← PWn(未搬,见待搬清单)
// div(MountedItemsStyle.container) > div(containerBlockGarage) > 分类块×N ← PGn(本片最大的一块)
// div(SuppliesComponentStyle) ← _Wn(未搬,见待搬清单)
fun Mount(Parent: HTMLElement) {
// GarageComponent 的根容器(官方 gPn.tan 第一句):定高容器,主屏的百分比高度靠它成立
val Host = Fd(Parent, "div", Style(::PresetContainer))
Host.setAttribute("data-official-style", "p8i().o1hb()(GarageComponent 容器)")
Host.setAttribute("data-source", "module_82347.js:161907 / 102912-102922")
// GarageComponent 的内容容器(官方 gPn.tan @161928-161931)
val Garage = Fd(Host, "div", Style(GarageCommonStyle.GarageContainer))
Garage.setAttribute("data-official-style", "GarageCommonStyle.garageContainer")
Garage.setAttribute("data-source", "module_82347.js:161684-161698 / 55633-55635")
val Root = Fd(Garage, "div", Style(GarageCommonStyle.PositionContent))
Root.setAttribute("data-official-style", "GarageCommonStyle.positionContent")
Root.setAttribute("data-source", "module_82347.js:55770-55781 / 55639-55656 / 209975-209989")
val Block = Fd(Root, "div", Style(GarageMainScreenStyle.BlockParameters))
Block.setAttribute("data-official-style", "GarageMainScreenStyle.blockParameters")
Block.setAttribute("data-source", "module_82347.js:60289-60309")
// ---- _Ln:坦克预览容器。官方只给了类名与两条未解出的样式(module_82347.js:164285 `bj(r, kj())`),
// 3D 预览本体(WebGL canvas)不在本片范围 → 容器留空 + 显式标注。
val Preview = Fd(Block, "div", { _ -> })
Preview.className = "tankPreviewContainer" // 类名逐字取自 module_82347.js:164287 `zx(e, "tankPreviewContainer")`
Preview.setAttribute("data-official-style", "_Ln(tankPreviewContainer)")
Preview.setAttribute(
"data-unimplemented",
"官方未找到可搬运的 DOM 结构(3D 预览,module_82347.js:164281-164293);样式函数 bj/kj 未解出",
)
// ---- PWn:坦克参数(TankParametersComponent)。官方样式 TankParametersStyle(module_82347.js:107506),
// 其样式片段(d1ov/e1ov/k1ov/l1ov/x1nu/m1ov/n1ov/o1ov)本片未逐条搬完 → 留空容器 + 标注。
val Params = Fd(Block, "div", { _ -> })
Params.setAttribute("data-official-style", "TankParametersStyle(PWn)")
Params.setAttribute(
"data-unimplemented",
"官方样式片段未逐条搬完(module_82347.js:167679-167770);见待搬清单",
)
// ---- PGn:挂载物品(MountedItemsComponent)—— 本片搬运的最大一块
MountMountedItems(Block)
// ---- _Wn:补给(SuppliesComponent)。样式 SuppliesComponentStyle(module_82347.js:107500),
// 片段 d1ot/w1ot/x1ot/s1ot/t1ot 未搬完 → 留空容器 + 标注。
val Supplies = Fd(Root, "div", { _ -> })
Supplies.setAttribute("data-official-style", "SuppliesComponentStyle(_Wn)")
Supplies.setAttribute(
"data-unimplemented",
"官方样式片段未逐条搬完(module_82347.js:167561-167603);见待搬清单",
)
}
// PGn 的 render(module_82347.js:166959-166982):
// r = 根,样式 uHn().o1hb() = MountedItemsStyle.container (166963)
// u = 子,样式 uHn().y1os() = MountedItemsStyle.containerBlockGarage (166966)
// 子下按分类调 vGn(...) 建块(166970-166979)——块样式 uHn().r1os(选中)(60409)
// 数据源:PGn 的 state(cGn)字段 itemsMounted(module_82347.js:60377 `this.j1or_1 = t`)
// + 挂载位设备(MountedItemsStyle.deviceIcon 用于设备图标,60486)
private fun MountMountedItems(Parent: HTMLElement) {
val Container = Fd(Parent, "div", Style(MountedItemsStyle.Container))
Container.setAttribute("data-official-style", "MountedItemsStyle.container")
Container.setAttribute("data-source", "module_82347.js:60774-60783 / 166963")
val BlockGarage = Fd(Container, "div", Style(MountedItemsStyle.ContainerBlockGarage))
BlockGarage.setAttribute("data-official-style", "MountedItemsStyle.containerBlockGarage")
BlockGarage.setAttribute("data-source", "module_82347.js:60785-60800 / 166966")
// 已装备物品:每件一块(官方 vGn 的块,module_82347.js:60404-60416)
for (Id in Session.MountedItems) {
val Info = Session.InfoOf(Id)
val Name = Info.Name ?: Info.CategoryChinese
ItemBlock(BlockGarage, Name, Info.IconFile?.let { UiAssets.ItemIcon(it) }, Selected = false)
}
// 挂载位设备:官方把整类设备放在**一个**分类块里(PGn.tan 的 case 13 → vGn,块内每个设备一个 deviceIcon:
// module_82347.js:60486 `R.tuo().lp2(uHn().u1os())`)。这里同样只建一块,块内 28 个 deviceIcon。
if (Session.Slots.isNotEmpty()) {
val Label = Translations.Text("lobby.garage.PROTECTION") + " ×" + Session.Slots.size
DeviceBlock(BlockGarage, Label)
}
}
// 设备分类块:块样式仍用 r1os,块内直接挂设备图标(官方 vGn 里设备图标直接 can 到块根节点,module_82347.js:60468)
private fun DeviceBlock(Parent: HTMLElement, Label: String) {
val Block = Fd(Parent, "div", Style(MountedItemsStyle.Block(false)))
Block.setAttribute("data-official-style", "MountedItemsStyle.r1os(设备类)")
Block.setAttribute("data-source", "module_82347.js:167013-167038 / 60486")
val NameBox = Fd(Block, "div", Style(MountedItemsStyle.TankPartNameContainer))
NameBox.setAttribute("data-official-style", "MountedItemsStyle.tankPartNameContainer")
NameBox.setAttribute("data-source", "module_82347.js:60807-60821 / 60446")
Fd(NameBox, "h1", { H -> H.textContent = Label })
for (Slot in Session.Slots) {
val Mounted = Slot.MountedDevice != null
val Icon = if (Mounted) UiAssets.DefaultDevice else UiAssets.SlotEmpty
val Dev = Img(Block, Icon, Slot.SlotObjectId)
Style(MountedItemsStyle.DeviceIcon)(Dev)
Dev.setAttribute("data-official-style", "MountedItemsStyle.deviceIcon")
Dev.setAttribute("data-source", "module_82347.js:60873-60875 / 60486")
Dev.setAttribute("data-slot", Slot.SlotObjectId)
Dev.setAttribute("data-mounted-device", Slot.MountedDevice ?: "")
}
}
// 一个分类块(官方 vGn,module_82347.js:60404-60416 / 60444-60468 的节点序列):
// div(块样式 r1os) ← 60409-60415
// div(MountedItemsStyle.tankPartNameContainer) ← 60446 v.tuo().lp2(uHn().s1os())
// h1 文字(官方是文案键取词,我们换成服务端下发的名称)
// img(MountedItemsStyle.itemPreview) ← 60455-60468 m.lp2(uHn().y1on()); y.uu8(图标url)
// (设备图标见 DeviceBlock;官方 60486 `R.tuo().lp2(uHn().u1os())`)
// 说明:官方这一块里还有 improvementArrow / commonBlockForTurretsHulls / resistanceContainer /
// commonBlockIconNotificator / SkinsIconComponent 等子节点,本片只搬了 itemPreview 与 deviceIcon 两条。
private fun ItemBlock(Parent: HTMLElement, Label: String, Icon: String?, Selected: Boolean) {
val Block = Fd(Parent, "div", Style(MountedItemsStyle.Block(Selected)))
Block.setAttribute("data-official-style", "MountedItemsStyle.r1os")
Block.setAttribute("data-source", "module_82347.js:167013-167038")
// 名称容器 —— module_82347.js:60446-60449
val NameBox = Fd(Block, "div", Style(MountedItemsStyle.TankPartNameContainer))
NameBox.setAttribute("data-official-style", "MountedItemsStyle.tankPartNameContainer")
NameBox.setAttribute("data-source", "module_82347.js:60807-60821 / 60446")
Fd(NameBox, "h1", { H -> H.textContent = Label }) // 60449 `v.can(p.s9j())`(p 是文案节点)
// 图标 —— module_82347.js:60455-60468(官方是 <img alt src>,见 60450-60453)
val IconBox = Fd(Block, "div", { _ -> })
val Preview = Img(IconBox, Icon ?: UiAssets.DefaultDevice, Label)
Style(MountedItemsStyle.ItemPreview)(Preview)
Preview.setAttribute("data-official-style", "MountedItemsStyle.itemPreview")
Preview.setAttribute("data-source", "module_82347.js:60847-60849 / 60455")
}
}
// 把 StyleFn 套到元素上(等价官方 `t.className = iq(样式对象)`)
private fun Style(Fn: StyleFn): (HTMLElement) -> Unit = { El -> Apply(El, Fn) }
@@ -0,0 +1,341 @@
// UiDom.kt —— 官方 DOM UI 运行时的**等价实现**(M14)
//
// 为什么用「直接操作 DOM」而不是 React:
// 官方大厅/车库这一层(module_82347.js 里 FD/WD/iq 的用法)本来就是命令式的 —— 建元素、
// 设 className、挂子节点,没有虚拟 DOM、没有 diff;样式对象经 iq()(= Emotion 的 css(),
// module_82347.js:1300 `iq = ei.css`,ei = module_14766)变成类名,Emotion 把 CSS 运行时注入
// <style data-emotion>。用 React 反而要把「样式对象 → 类名」的语义改写成 props/内联样式,会走样。
// 所以这里 DOM 直写,与官方调用点 1:1 对应,便于把每条 CSS 声明对回官方源码。
//
// 等价表(官方符号 → 本文件):
// FD(parent, tag, styleFn) module_82347.js:2205 (= o_.$_$.b) → Fd(...)
// WD(node, child) module_82347.js:2208 (= o_.$_$.a) → Wd(...)
// iq(styleObj) module_82347.js:1300 (= Emotion css) → Iq(...)
// w$(value) module_37036.js:1165 (= Kotlin toString) → W(...)
// ip2(prop, value) module_63383.js:354/6985 (= 发原生 CSS 属性) → Ip2(...)
// lp1(selector, fn) module_63383.js:6966 (= @media) → Fp2(...)
// lp2(styleObj) (Kotlin CSS DSL:合并一份已解出的样式片段) → Lp2(...)
// rnr(n) / O$(n) module_82347.js:105755 / module_63383.js:7688 → RnR(n) / Em(n)
// E$(n) module_63383.js:7927(百分比值) → Pct(n)
// G$(n) module_63383.js:7935(px 值,Os/ma) → Px(n)
// cnr(n) module_82347.js:105786(rem 值) → Rem(n)
// zj(r,g,b,a) module_63383.js:834(rgba) → Rgba(...)
// D$(r,g,b) module_63383.js:7940(rgb) → Rgb(...)
// fq / jq / Y$ module_37036.js:1257 / 1249 / 1266 → StyleRegistry(见该处说明)
//
// ★关于 w$ 的查证结论(任务明确要求):w$ **不是**单位换算,**没有** rem→px 缩放、也**没有**设备像素比。
// 定义:`w$ = Co.$_$.je`,而 `Co` = module_37036(Kotlin/JS 标准库运行时),
// module_37036.js:1165-1167:
// function Br(t) { return null == t ? "null" : ye(t) ? "[...]" : "function" != typeof t.toString ? Gr(t) : t.toString(); }
// 即 Kotlin 的 Any?.toString()。佐证:module_82347.js:38108 `return w$(r) + i`(i 是 "m"/"k" 单位后缀)、
// :106127 `w$(100) + "vw"`、:106140 `w$(25) + "rem"` —— 单位都是**调用点字符串拼接**上去的。
package Ui.Official
import kotlinx.browser.document
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLStyleElement
import org.w3c.dom.Node
import org.w3c.dom.Text
// ---------------------------------------------------------------- 样式对象(等价官方传进 iq() 的那个 JS 普通对象)
// 一份样式:声明(camelCase 属性名,与官方 Kotlin CSS DSL 的键名一致)+ 嵌套规则 + @media 规则
internal class StyleSheet {
val Decls = LinkedHashMap<String, String>()
/** 嵌套选择器(官方 lp1):键是选择器原文,如 "&:hover"、"h1" */
val Nested = LinkedHashMap<String, StyleSheet>()
/** @media(官方 fp2 / 本文件的 Fp2) */
val Media = LinkedHashMap<String, StyleSheet>()
fun Set(Prop: String, Value: String) {
Decls[Prop] = Value
}
fun Merge(Other: StyleSheet) {
Decls.putAll(Other.Decls)
for ((K, V) in Other.Nested) Nested[K] = (Nested[K] ?: StyleSheet()).also { it.Merge(V) }
for ((K, V) in Other.Media) Media[K] = (Media[K] ?: StyleSheet()).also { it.Merge(V) }
}
fun NestedRule(Selector: String, Block: (StyleSheet) -> Unit): StyleSheet {
val S = Nested[Selector] ?: StyleSheet().also { Nested[Selector] = it }
Block(S)
return S
}
fun MediaRule(Query: String, Block: (StyleSheet) -> Unit): StyleSheet {
val S = Media[Query] ?: StyleSheet().also { Media[Query] = it }
Block(S)
return S
}
}
// 样式函数(等价官方那些 `function xxx(t){ t.lp2(...); cq }`)
internal typealias StyleFn = (StyleSheet) -> Unit
// ---------------------------------------------------------------- 单位 / 值(等价 rnr / O$ / E$ / G$ / cnr / zj / D$)
/** 等价官方 w$:Kotlin `toString`,只做值→字符串,不换算(见文件头)。 */
internal fun W(Value: Any?): String = if (Value == null) "null" else Value.toString()
/** 等价官方 O$(n) = module_63383.js:7688 `new Ms(t, "em")`;值为 0 时官方 Ms 去掉单位(module_63383.js:1012-1014)。 */
internal fun Em(N: Number): String = Unit0(N, "em")
/** 等价官方 rnr(n) = module_82347.js:105755 `O$(n / 16)` —— 官方设计稿数值一律 ÷16 转 em。 */
internal fun RnR(N: Number): String = Em(N.toDouble() / 16.0)
/** 等价官方 E$(n) = module_63383.js:7927 `new Ms(t, "%")`。 */
internal fun Pct(N: Number): String = Unit0(N, "%")
/** 等价官方 G$(n) = module_63383.js:7935(`Os`,module_63383.js:1009 `new Ms(t, "px")`)。 */
internal fun Px(N: Number): String = Unit0(N, "px")
/** 等价官方 cnr(n) = module_82347.js:105786 `SC(n / 16)`,SC = module_63383.js:7937 `new Ms(t, "rem")`。 */
internal fun Rem(N: Number): String = Unit0(N.toDouble() / 16.0, "rem")
/** 等价官方 Ms 的「0 不带单位」行为(module_63383.js:1012-1014)。 */
private fun Unit0(N: Number, Unit: String): String {
val D = N.toDouble()
return if (D == 0.0) "0" else Trim(D) + Unit
}
private fun Trim(D: Double): String {
if (D == D.toLong().toDouble()) return D.toLong().toString()
return D.toString()
}
/** 等价官方 D$(r,g,b) = module_63383.js:7940。 */
internal fun Rgb(R: Int, G: Int, B: Int): String = "rgb($R, $G, $B)"
/** 等价官方 zj(r,g,b,a) = module_63383.js:834。 */
internal fun Rgba(R: Int, G: Int, B: Int, A: Double): String = "rgba($R, $G, $B, ${Trim(A)})"
// ---------------------------------------------------------------- ip2 / lp2 / fp2(样式声明层)
/** 等价官方 ip2(prop, value) = module_63383.js:354 `zu` → `this.np2().tp9(t, n)`:发一条原生 CSS 属性。 */
internal fun Ip2(S: StyleSheet, Prop: String, Value: String) {
S.Set(Prop, Value)
}
/** 等价官方 lp2(styleObj):把一份已解出的样式片段并入当前样式(官方用于套用共享样式类)。 */
internal fun Lp2(S: StyleSheet, Frag: StyleSheet) {
S.Merge(Frag)
}
/** 等价官方 fp2(query, fn) = module_63383.js:6966 `this.lp1("@media " + t, n)`;官方调用点把整串写好(见 fnr/gnr)。 */
internal fun Fp2(S: StyleSheet, Query: String, Block: (StyleSheet) -> Unit) {
S.MediaRule(Query, Block)
}
/** 官方 fnr(t, n, i, r) = module_82347.js:105800-105802 的等价物。 */
internal fun Fnr(S: StyleSheet, W: Int, H: Int, Block: (StyleSheet) -> Unit) {
Fp2(S, "screen and (min-width: ${W}px), (min-height: ${H}px)", Block)
}
/** 官方 gnr(t, n, i, r) = module_82347.js:105819-105821 的等价物。 */
internal fun Gnr(S: StyleSheet, W: Int, H: Int, Block: (StyleSheet) -> Unit) {
Fp2(S, "screen and (max-width: ${W}px), (max-height: ${H}px)", Block)
}
// ---------------------------------------------------------------- 样式类注册表(等价 fq / jq / Y$)
// ★查证结论(任务要求说明等价点与差异点):
// 官方的 `fq` / `jq` / `Y$` **不是**样式注册函数,而是 Kotlin/JS 编译器发出的
// **类/对象/伴生对象元数据注册函数**:
// fq = Co.$_$.kd = module_37036.js:1257 `ne(t,n,i,r,e,u,s,o)` → `te("object", ...)`
// jq = Co.$_$.fd = module_37036.js:1249 `Qr(t,n,i,r,e,u,s,o)` → `te("class", ...)`
// Y$ = Co.$_$.gd = module_37036.js:1266 `ee(t,n,i,r)` → `ne(t, "Companion", ...)`
// 因此 module_82347.js:107425-107600 那一大段 `fq(ZRn, "GarageCommonStyle", hq, K$)` 注册的是
// Kotlin 对象的**元数据**(第 2 个实参才是 simpleName),并非 CSS。
// 等价的职责 = 「给一份样式起个公开名字,供别处按名字取用」。差异点:官方靠编译器生成符号,
// 我们靠显式注册表 + 字符串名。
internal object StyleRegistry {
private val Table = LinkedHashMap<String, StyleSheet>()
/** 等价官方 fq(defineObj 口径的「命名对象」注册)。 */
fun Fq(PublicName: String, Fragment: StyleSheet) {
Table[PublicName] = Fragment
}
/** 等价官方 jq(defineClass 口径)。 */
fun Jq(PublicName: String, Fragment: StyleSheet) = Fq(PublicName, Fragment)
/** 等价官方 Y$(defineCompanionObject 口径)。 */
fun Ys(PublicName: String, Fragment: StyleSheet) = Fq(PublicName, Fragment)
fun Get(PublicName: String): StyleSheet? = Table[PublicName]
fun Names(): List<String> = Table.keys.toList()
}
// ---------------------------------------------------------------- iq(样式对象 → 类名)与 DOM 建节点
private val Injected = LinkedHashMap<String, String>()
private var StyleEl: HTMLStyleElement? = null
/** 等价官方 iq(styleObj):样式对象 → 类名,并把 CSS 运行时注入一条 <style data-emotion>(与官方 Emotion 行为一致)。 */
internal fun Iq(S: StyleSheet): String {
val Name = "tk" + Hash32(Canonical(S))
if (!Injected.containsKey(Name)) {
// 与官方 Emotion 的输出形状一致:类规则(含嵌套选择器)一份,@media 提到顶层各一份
val Out = StringBuilder()
Out.append('.').append(Name).append('{').append(RenderBody(S)).append('}')
AppendMedia(Out, S, Name)
val Css = Out.toString()
Injected[Name] = Css
val El = StyleEl ?: run {
val E = document.createElement("style") as HTMLStyleElement
E.setAttribute("data-emotion", "tankic")
document.head?.appendChild(E)
E
}
StyleEl = El
El.appendChild(document.createTextNode(Css))
}
return Name
}
/** 等价官方 FD(parent, tag, styleFn):建元素 → 挂到父节点 → 调样式函数(样式函数里再递归 FD / WD)。 */
internal fun Fd(Parent: HTMLElement, Tag: String, StyleFn: (HTMLElement) -> Unit): HTMLElement {
val El = document.createElement(Tag) as HTMLElement
Parent.appendChild(El)
StyleFn(El)
return El
}
/** 等价官方 WD(node, child):挂子节点。 */
internal fun Wd(Node: HTMLElement, Child: Node) {
Node.appendChild(Child)
}
/** 把一条样式函数套到元素上:等价官方 `t.className = iq(n)`(jq/fq 注册的片段也走这里)。 */
internal fun Apply(El: HTMLElement, StyleFn: StyleFn) {
val S = StyleSheet()
StyleFn(S)
val Cls = Iq(S)
El.className = if (El.className.isEmpty()) Cls else El.className + " " + Cls
}
/** 直接套一份已解出的样式(等价 `x.tuo().lp2(frag)`)。 */
internal fun ApplySheet(El: HTMLElement, S: StyleSheet) {
val Cls = Iq(S)
El.className = if (El.className.isEmpty()) Cls else El.className + " " + Cls
}
// ---- CSS 序列化
/** 类规则体:声明 + 嵌套选择器(不含 @media)。 */
private fun RenderBody(S: StyleSheet): String {
val Out = StringBuilder()
for ((K, V) in S.Decls) Out.append(Kebab(K)).append(':').append(V).append(';')
for ((Sel, Sub) in S.Nested) Out.append(Sel).append('{').append(RenderBody(Sub)).append('}')
return Out.toString()
}
/** @media 提到顶层:`@media QUERY{.类名{...}}`(与官方 Emotion 的输出形状一致)。 */
private fun AppendMedia(Out: StringBuilder, S: StyleSheet, Name: String) {
for ((Q, Sub) in S.Media) {
Out.append("@media ").append(Q).append('{').append('.').append(Name).append('{')
Out.append(RenderBody(Sub))
Out.append("}}")
AppendMedia(Out, Sub, Name)
}
}
/** 参与类名哈希的规范化串(含 @media,保证不同断点得到不同类名)。 */
private fun Canonical(S: StyleSheet): String {
val Out = StringBuilder()
for ((K, V) in S.Decls) Out.append(K).append(':').append(V).append(';')
for ((Sel, Sub) in S.Nested) Out.append(Sel).append('{').append(Canonical(Sub)).append('}')
for ((Q, Sub) in S.Media) Out.append('@').append(Q).append('{').append(Canonical(Sub)).append('}')
return Out.toString()
}
/** 官方 Kotlin CSS DSL 的键名是 camelCase(如 backgroundColor),Emotion 会转成 kebab-case;这里等价转换。 */
private fun Kebab(Prop: String): String {
if (Prop.startsWith("--") || !Prop.any { it.isUpperCase() }) return Prop
val Out = StringBuilder()
for (C in Prop) {
if (C.isUpperCase()) Out.append('-').append(C.lowercaseChar()) else Out.append(C)
}
return Out.toString()
}
private fun Hash32(S: String): String {
var H = 5381
for (C in S) H = (H * 33) xor C.code
return (H.toUInt()).toString(16)
}
// ---- 全局样式(等价官方 Emotion 的 Global 表:module_82347.js:105411-105424 `btr`)
/**
* 官方那一份 Global 样式表(`btr`,module_82347.js:105411-105424)。它不在 index.html 里,
* 而是官方应用启动时由 Emotion 注入 —— 车库主屏的 `height: 100%` 之所以有意义,靠的就是这里
* 给 `.wrapper` 的 `position:absolute; height:100%`(module_82347.js:105395-105403)。
*
* 已解出并照搬的规则:
* html ← `np1(f2(), ltr)`,f2() = "html"(module_63383.js:7804)/ ltr @105363-105365
* body ← `np1(d2(), ftr)`,d2() = "body"(module_63383.js:7593)/ ftr @105366-105379
* · font-family 取 db.o1i8_1 = "BaseFontRegular, FallbackFontRegular"(module_82347.js:105041)
* · 嵌套 `h1, h2, h3, h4, span, p` ← vtr @105380-105382
* · 嵌套 `a` ← ptr @105386-105388(只取其中可解出的 color)
* .wrapper ← `lp1(".wrapper", mtr)` @105420 / mtr @105395-105403
* @media only screen and (orientation: portrait) { .wrapper { left/right: 0 !important } }
* ← `fp2("only screen and (orientation: portrait)", ytr)` @105421 / ytr @105405 / ktr @105408
*
* ★官方未找到/未实现(见报告):`li` 规则(wtr @105383 的 fB/gV 未解出)、
* `a` 规则里除 color 外的部分(CM/B0().spb_1 未解出)、`input::placeholder` 规则
* (dtr @105389 的 eT/rT、atr().q1hl_1、Sz().yoz_1 未解出)、`P9i().f1su` 追加的 html 规则
* (A9i @102614 的 I9i 链未解出)。
*/
internal fun InjectOfficialGlobal() {
if (document.querySelector("style[data-emotion='tankic-global']") != null) return
val Css = """
html{width:100%;height:100%;margin:0;border-width:0;user-select:none;}
body{display:flex;width:100%;height:100%;margin:0;border-width:0;font-family:BaseFontRegular, FallbackFontRegular;}
body h1,body h2,body h3,body h4,body span,body p{font-size:1em;color:white;}
body a{color:rgb(175, 255, 112);}
.wrapper{position:absolute;height:100%;left:var(--sar);right:var(--sal);background:radial-gradient(50% 100% at 50% 100%, #395673 0%, #001926 100%);}
@media only screen and (orientation: portrait){.wrapper{left:0px !important;right:0px !important;}}
""".trimIndent()
val E = document.createElement("style") as HTMLStyleElement
E.setAttribute("data-emotion", "tankic-global")
E.appendChild(document.createTextNode(Css))
document.head?.appendChild(E)
}
// ---- preloader(官方行为:应用挂载后移除 #preloader,见 module_13431.js:625-643)
/** 等价官方入口:vnode 渲染进 #app-root 后移除 #preloader。 */
internal fun RemovePreloader() {
val P = document.getElementById("preloader") ?: return
P.parentNode?.removeChild(P)
}
/** 取官方壳里的挂载点(3DTank/web/index.html 的 #app-root / #modal-root)。 */
internal fun AppRoot(): HTMLElement? = document.getElementById("app-root") as? HTMLElement
internal fun ModalRoot(): HTMLElement? = document.getElementById("modal-root") as? HTMLElement
/** 把一段文本挂进元素(等价官方 WD(node, 文本节点))。 */
internal fun WdText(Node: HTMLElement, Text: String) {
Node.appendChild(document.createTextNode(Text) as Text)
}
// ---- 便捷 DOM 助手(不带样式的节点 / 属性)
internal fun Div(Parent: HTMLElement, Cls: String = ""): HTMLElement {
val El = document.createElement("div") as HTMLElement
if (Cls.isNotEmpty()) El.className = Cls
Parent.appendChild(El)
return El
}
internal fun Img(Parent: HTMLElement, Src: String, Alt: String = ""): HTMLElement {
val El = document.createElement("img") as HTMLElement
El.setAttribute("src", Src)
if (Alt.isNotEmpty()) El.setAttribute("alt", Alt)
Parent.appendChild(El)
return El
}
+153
View File
@@ -0,0 +1,153 @@
// Panels.kt —— 商店 / 聊天空间屏 + 调试抽屉(M1–M13 的调试信息收进可折叠抽屉,默认收起,功能不删)
package Ui
import Protocol.SpaceFrameLine
import Protocol.SpaceSession
import react.FC
import react.Props
import react.create
import react.dom.html.ReactHTML.button
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h2
import react.dom.html.ReactHTML.li
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.pre
import react.dom.html.ReactHTML.span
import react.dom.html.ReactHTML.ul
import web.cssom.ClassName
// ---------------------------------------------------------------- 商店 / 聊天(进空间 + 解码)
internal external interface SpaceScreenProps : Props {
var Session: SpaceSession
var TitleKey: String
var SpaceId: Long
}
internal val SpaceScreen = FC<SpaceScreenProps> { P ->
val S = P.Session
div {
className = ClassName("SpaceScreen")
h2 { +(Translations.Text(P.TitleKey) + "(空间 " + P.SpaceId + ")") }
div {
className = ClassName("Actions")
button {
className = ClassName("BtnPrimary")
onClick = { S.Enter(P.SpaceId) }
+("连接并进空间 " + P.SpaceId)
}
button {
className = ClassName("BtnSecondary")
onClick = { S.Enter(P.SpaceId) }
+"重进 / 重收(9s)"
}
}
p { className = ClassName("Hint"); +("状态:" + S.Status) }
if (S.LastError.isNotEmpty()) p { className = ClassName("Hint"); +("错误:" + S.LastError) }
p {
className = ClassName("Hint")
+("已收帧 " + S.Frames.size + " 帧 / " + S.RecordTotal + " 条记录 / Bad " + S.BadFrames +
"。下面是逐帧解码结果(与 node 侧 SpaceDumpDriver 同一份 SpaceFrame/Container 实现)。")
}
div {
className = ClassName("SpaceFrames")
for (F in S.Frames) {
+SpaceFrameRow.create { Frame = F }
}
}
h2 { +"会话日志(含自动应答 G.q1wd)" }
ul {
className = ClassName("Plain Log")
for (L in S.Logs) li { +L }
}
}
}
internal external interface SpaceFrameRowProps : Props {
var Frame: SpaceFrameLine
}
internal val SpaceFrameRow = FC<SpaceFrameRowProps> { P ->
val F = P.Frame
div {
className = ClassName("SpaceFrame")
span {
className = ClassName("FrameHead")
+("帧#" + F.FrameNo + " " + F.TotalBytes + "B 记录 " + F.RecordCount + " 条 " +
(if (F.Bad) "⚠ Bad" else "OK"))
}
div {
className = ClassName("FrameLabels")
+F.Labels.distinct().joinToString(" + ")
}
for (S in F.Summaries) {
div {
className = ClassName("FrameSummary")
+S
}
}
if (F.Bad && F.Note.isNotEmpty()) {
div {
className = ClassName("FrameSummary")
+("⚠ " + F.Note)
}
}
}
}
// ---------------------------------------------------------------- 调试抽屉
internal val DebugDrawer = FC<Props> {
div {
className = ClassName(if (UiState.DebugOpen) "Drawer Open" else "Drawer")
div {
className = ClassName("DrawerHead")
onClick = { UiState.Set { UiState.DebugOpen = !UiState.DebugOpen } }
span { +"调试抽屉 · M1–M13(连接日志 / 收发帧 / 统计 / ClientState dump)" }
span { className = ClassName("DrawerToggle"); +(if (UiState.DebugOpen) "▾ 收起" else "▸ 展开") }
}
if (UiState.DebugOpen) {
div {
className = ClassName("DrawerBody")
div {
className = ClassName("Stats")
span { +("状态:" + Session.Status) }
span { +("已进空间:" + Session.Connected) }
span { +("仓库 " + Session.DepotItems.size + " 件") }
span { +("已挂载 " + Session.MountedItems.size + " 件") }
span { +("市场 " + Session.MarketItems.size + " 件") }
span { +("挂载位 " + Session.Slots.size + " 个") }
span { +("设备 " + Session.DeviceCount + " 个(记录 " + Session.DevicesRawCount + " 条)") }
span { +("pt 对象 " + Session.PtObjectCount + " 个") }
span { +("zt 记录 " + Session.ZtRecordCount + " 条") }
span { +("lt 记录 " + Session.UnloadRecordCount + " 条") }
}
if (Session.LastError.isNotEmpty()) p { className = ClassName("Hint"); +("错误:" + Session.LastError) }
div {
className = ClassName("Actions")
button {
className = ClassName("BtnSecondary")
onClick = { Session.Connect() }
+"连接并进车库(100073070)"
}
button {
className = ClassName("BtnSecondary")
onClick = { UiState.Set { UiState.ShowStateJson = !UiState.ShowStateJson } }
+(if (UiState.ShowStateJson) "隐藏 ClientState JSON" else "生成 ClientState JSON(ToJson)")
}
}
if (UiState.ShowStateJson) {
pre {
className = ClassName("Json")
+Session.StateJson()
}
}
h2 { +("收发帧 / 动作日志:最近 " + Session.Logs.size + " 条") }
ul {
className = ClassName("Plain Log")
for (Line in Session.Logs) li { +Line }
}
}
}
}
}
+486
View File
@@ -0,0 +1,486 @@
// Screens.kt —— 大厅外壳 + 车库屏(本次 M14 的主体)
//
// 界面口径(可复核):
// · 所有图标 src 都是官方原件(UiAssets 里的 /play/static/** 路径);
// · 所有中文文案都是官方 zh-CN 译文(Translations.Text("官方键"));
// · 服务端没给的数据(货币 / 装备分数 / 单件型号图标)一律显示「—」并在界面上注明原因,不编数字。
//
// 注意:kotlin-react 的 `+` 是「往当前元素追加子节点」的成员运算符,`+A + B` 会被解析成 `(+A) + B` 而报错,
// 因此凡是拼接后的字符串都要写成 `+(A + B)`。
package Ui
import Protocol.ItemInfo
import react.FC
import react.Props
import react.create
import react.dom.html.ReactHTML.aside
import react.dom.html.ReactHTML.button
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.h2
import react.dom.html.ReactHTML.h3
import react.dom.html.ReactHTML.header
import react.dom.html.ReactHTML.img
import react.dom.html.ReactHTML.li
import react.dom.html.ReactHTML.p
import react.dom.html.ReactHTML.section
import react.dom.html.ReactHTML.small
import react.dom.html.ReactHTML.span
import react.dom.html.ReactHTML.ul
import web.cssom.ClassName
// ---------------------------------------------------------------- 数据辅助(非 React)
private const val MarketPageSize = 60
private fun DepotInfos(): List<ItemInfo> = Session.ItemInfos()
// 仓库里实际出现的类别(按官方枚举 module_85196::ja 的序数排序,展示用)
private fun DepotCategories(): List<String> {
val Order = listOf(
"ARMOR", "WEAPON", "PAINT", "RESISTANCE", "DRONE", "INVENTORY",
"BAZOOKA", "INVISIBLE", "SPECIAL", "KIT", "DEVICES", "SKINS", "SKINS_SHOT",
)
val Seen = LinkedHashSet<String>()
for (I in DepotInfos()) I.CategoryMember?.let { Seen.add(it) }
return Seen.sortedBy { Order.indexOf(it).let { X -> if (X < 0) 99 else X } }
}
private fun RarityChinese(Member: String?): String {
if (Member == null) return "—"
// 官方稀有度枚举 85196::Ma 的中文在 lobby.containers.<成员名>(普通/罕见/史诗/传奇/异国情调)
val K = "lobby.containers." + Member
val V = Translations.Text(K)
return if (V == K) Member else V
}
private fun ItemIconSrc(I: ItemInfo): String =
UiAssets.ItemIcon(I.IconFile) ?: UiAssets.CategoryIcon[I.CategoryMember] ?: UiAssets.IcNoDevice
private fun IsMounted(Id: String): Boolean = Session.MountedItems.contains(Id)
// ---------------------------------------------------------------- 顶部栏
internal external interface ChipProps : Props {
var Src: String
var Label: String
}
private val Chip = FC<ChipProps> { P ->
div {
className = ClassName("Chip")
title = P.Label + ":服务端模型 UserPropertiesCC 未在抓包中出现 ⇒ 数值不可得"
img { src = P.Src; className = ClassName("ChipIcon"); alt = P.Label }
span { className = ClassName("ChipValue"); +"—" }
span { className = ClassName("ChipLabel"); +P.Label }
}
}
internal val TopBar = FC<Props> {
header {
className = ClassName("TopBar")
div {
className = ClassName("TopTitle")
img { src = UiAssets.MenuIcon["GARAGE"]!!; className = ClassName("TitleIcon"); alt = "" }
span { className = ClassName("TitleText"); +Translations.Text("lobby.garage.TITLE") }
span { className = ClassName("TitleSub"); +"本地重放客户端 · 数据全部来自 ws://localhost:9091" }
}
div {
className = ClassName("CurrencyBar")
+Chip.create { Src = UiAssets.Gold; Label = "金币" }
+Chip.create { Src = UiAssets.CrystalSmall; Label = "水晶" }
+Chip.create { Src = UiAssets.Premium; Label = "VIP" }
div {
className = ClassName("Chip GearScore")
span { className = ClassName("ChipValue"); +"—" }
span { className = ClassName("ChipLabel"); +Translations.Text("lobby.garage.GEAR_SCORE") }
}
}
}
div {
className = ClassName("Notice")
+(
"说明:货币 / 装备分数来自服务端模型 UserPropertiesCC,本账号抓包(3DTank/packets 与 names/space-init-*.json)里没有该模型," +
"故一律显示「—」,不编数字。文案取自官方 /play/static/translations/zh-CN.a2b303af.json(已载入 " +
Translations.Count() + " 条)。"
)
}
}
// ---------------------------------------------------------------- 左侧菜单
internal val LobbyMenu = FC<Props> {
aside {
className = ClassName("Menu")
for (E in UiState.Menu) {
MenuRow.create {
Text = Translations.Text("lobby.menu." + E.Key)
Icon = UiAssets.MenuIcon[E.Key] ?: UiAssets.IcNoDevice
IconHover = UiAssets.MenuIconHover[E.Key] ?: ""
Enabled = E.Enabled
Selected = UiState.Screen.equals(E.Key, ignoreCase = true)
Reason = E.Reason
Badge = E.SpaceId?.toString() ?: "置灰"
OnClick = { UiState.Go(E.Key) }
}
}
p {
className = ClassName("MenuNote")
+"只有车库(100073070)/商店(100072911)/聊天(100045720)有空间 id 硬证据,其余项置灰(原因见悬停提示)。"
}
}
}
// ---------------------------------------------------------------- 车库:分类栏
internal external interface CategoryRowProps : Props {
var Text: String
var Icon: String
var Selected: Boolean
var OnClick: () -> Unit
}
private val CategoryRow = FC<CategoryRowProps> { P ->
div {
className = ClassName(if (P.Selected) "CatRow On" else "CatRow")
onClick = { P.OnClick() }
if (P.Icon.isNotEmpty()) img { src = P.Icon; className = ClassName("CatIcon"); alt = "" }
span { +P.Text }
}
}
internal val CategoryBar = FC<Props> {
aside {
className = ClassName("Categories")
h3 { +Translations.Text("lobby.garage.TITLE") }
+CategoryRow.create {
Text = Translations.Text("lobby.garage.ALL")
Icon = ""
Selected = UiState.CategoryFilter == null
OnClick = { UiState.Set { UiState.CategoryFilter = null; UiState.SelectedItemId = null } }
}
for (M in DepotCategories()) {
+CategoryRow.create {
val K = "lobby.garage." + M
val V = Translations.Text(K)
Text = if (V == K) M else V
Icon = UiAssets.CategoryIcon[M] ?: ""
Selected = UiState.CategoryFilter == M
OnClick = { UiState.Set { UiState.CategoryFilter = M; UiState.SelectedItemId = null } }
}
}
}
}
// ---------------------------------------------------------------- 车库:坦克展示(2D 图标叠加)
internal val TankStage = FC<Props> {
val Infos = DepotInfos()
val Hull = Infos.firstOrNull { it.CategoryMember == "ARMOR" && IsMounted(it.ItemId) }
val Turret = Infos.firstOrNull { it.CategoryMember == "WEAPON" && IsMounted(it.ItemId) }
section {
className = ClassName("Stage")
img { src = UiAssets.Background; className = ClassName("StageBg"); alt = "" }
div {
className = ClassName("StageTank")
img {
src = UiAssets.ItemIcon(Hull?.IconFile) ?: UiAssets.CategoryIcon["ARMOR"]!!
className = ClassName("StageHull")
alt = "底盘"
}
img {
src = UiAssets.ItemIcon(Turret?.IconFile) ?: UiAssets.CategoryIcon["WEAPON"]!!
className = ClassName("StageTurret")
alt = "炮塔"
}
}
div {
className = ClassName("StageCaption")
span { +("底盘:" + (Hull?.Name ?: "未装备")) }
span { +(" 炮塔:" + (Turret?.Name ?: "未装备")) }
small { +"(2D 官方图标叠加示意;游戏内为 Unity 3D 渲染,本客户端不做 3D)" }
}
}
}
// ---------------------------------------------------------------- 车库:物品格
internal external interface ItemCellProps : Props {
var Id: String
var Name: String
var Icon: String
var RarityIcon: String
var RarityTitle: String
var Mounted: Boolean
var Selected: Boolean
var OnClick: () -> Unit
}
internal val ItemCell = FC<ItemCellProps> { P ->
div {
className = ClassName(if (P.Selected) "ItemCell On" else "ItemCell")
onClick = { P.OnClick() }
if (P.RarityIcon.isNotEmpty()) {
img {
src = P.RarityIcon
className = ClassName("RarityIcon")
alt = P.RarityTitle
title = P.RarityTitle
}
}
img { src = P.Icon; className = ClassName("ItemIcon"); alt = P.Name }
div {
className = ClassName("ItemName")
+P.Name
}
if (P.Mounted) {
div {
className = ClassName("ItemBadge")
img { src = UiAssets.SlotEquipped; className = ClassName("BadgeIcon"); alt = "" }
span { +Translations.Text("lobby.garage.EQUIPPED") }
}
}
div {
className = ClassName("ItemId")
+P.Id
}
}
}
// ---------------------------------------------------------------- 车库:仓库 / 市场
internal val ItemGrid = FC<Props> {
div {
className = ClassName("Tabs")
div {
className = ClassName(if (UiState.GarageTab == "Depot") "Tab On" else "Tab")
onClick = { UiState.Set { UiState.GarageTab = "Depot"; UiState.SelectedItemId = null } }
+(Translations.Text("lobby.garage.TITLE") + "(仓库 " + DepotInfos().size + " 件)")
}
div {
className = ClassName(if (UiState.GarageTab == "Market") "Tab On" else "Tab")
onClick = { UiState.Set { UiState.GarageTab = "Market"; UiState.SelectedItemId = null } }
+(Translations.Text("lobby.menu.SHOP") + "(市场 " + Session.MarketItems.size + " 件)")
}
}
if (UiState.GarageTab == "Depot") {
val Filter = UiState.CategoryFilter
val List = DepotInfos().filter { Filter == null || it.CategoryMember == Filter }
div {
className = ClassName("Grid")
for (I in List) {
+ItemCell.create {
Id = I.ItemId
Name = I.Name ?: "(无名称数据)"
Icon = ItemIconSrc(I)
RarityIcon = UiAssets.RarityIcon(I.RarityRaw) ?: ""
RarityTitle = "稀有度:" + (I.RarityMember ?: "—") + " / " + RarityChinese(I.RarityMember) +
"(原始 " + (I.RarityRaw ?: "无") + ")"
Mounted = IsMounted(I.ItemId)
Selected = UiState.SelectedItemId == I.ItemId
OnClick = { UiState.Set { UiState.SelectedItemId = I.ItemId } }
}
}
}
p {
className = ClassName("Hint")
+("仓库 itemsOnDepot 共 " + DepotInfos().size + " 件;每格=官方图标(型号取不到时退回类别图标)+ 服务端下发名称 + 稀有度角标。")
}
} else {
// 市场 992 件:**分页**(每页 MarketPageSize),避免一次渲染近千 DOM 节点
val Total = Session.MarketItems.size
val Pages = if (Total == 0) 1 else (Total + MarketPageSize - 1) / MarketPageSize
val Page = UiState.MarketPage.coerceIn(0, Pages - 1)
val Slice = Session.MarketItems.drop(Page * MarketPageSize).take(MarketPageSize)
div {
className = ClassName("Pager")
button {
disabled = Page <= 0
onClick = { UiState.Set { UiState.MarketPage = Page - 1 } }
+"← 上一页"
}
span { +("第 " + (Page + 1) + " / " + Pages + " 页(每页 " + MarketPageSize + " 件,共 " + Total + " 件)") }
button {
disabled = Page >= Pages - 1
onClick = { UiState.Set { UiState.MarketPage = Page + 1 } }
+"下一页 →"
}
}
div {
className = ClassName("Grid")
for (Mid in Slice) {
val I = Session.InfoOf(Mid)
+ItemCell.create {
Id = Mid
Name = I.Name ?: "(无名称数据)"
Icon = ItemIconSrc(I)
RarityIcon = UiAssets.RarityIcon(I.RarityRaw) ?: ""
RarityTitle = "稀有度:" + (I.RarityMember ?: "—") + " / " + RarityChinese(I.RarityMember)
Mounted = false
Selected = UiState.SelectedItemId == Mid
OnClick = { UiState.Set { UiState.SelectedItemId = Mid } }
}
}
}
p {
className = ClassName("Hint")
+(
"市场 itemsOnMarket 共 " + Total + " 件,全部在 pt 里有对象。渲染策略=分页(非虚拟滚动),每页 " +
MarketPageSize + " 个节点。"
)
}
}
}
// ---------------------------------------------------------------- 车库:右侧详情
internal val DetailPanel = FC<Props> {
aside {
className = ClassName("Detail")
val Sel = UiState.SelectedItemId
h3 { +"物品详情" }
if (Sel == null) {
p { className = ClassName("Hint"); +"点选左侧任一物品查看详情与「装备 / 取消装备」。" }
} else {
val I = Session.InfoOf(Sel)
val InDepot = Session.DepotItems.contains(Sel)
val Mounted = IsMounted(Sel)
h3 { +(I.Name ?: "(无名称数据)") }
img { src = ItemIconSrc(I); className = ClassName("DetailIcon"); alt = "" }
ul {
className = ClassName("Plain")
li { +("物品 id:" + Sel) }
li { +("类别:" + I.CategoryChinese + "(" + (I.CategoryMember ?: I.CategoryRaw ?: "—") + ")") }
li { +("稀有度:" + (I.RarityMember ?: "—") + " / " + RarityChinese(I.RarityMember) + "(原始 " + (I.RarityRaw ?: "无") + ")") }
li { +("型号值(okz_1/rkz_1):" + (I.ModelValue?.toString() ?: "—(数据里无 TankInfoCC)")) }
li { +("图标文件:" + (I.IconFile ?: "—(型号取不到 ⇒ 退回类别图标)")) }
li { +("来源:" + if (InDepot) "仓库 itemsOnDepot" else "市场 itemsOnMarket") }
}
if (InDepot) {
div {
className = ClassName("Actions")
button {
className = ClassName("BtnPrimary")
disabled = Mounted
onClick = { Session.Equip(Sel) }
+Translations.Text("lobby.garage.EQUIP_BUTTON")
}
button {
className = ClassName("BtnSecondary")
disabled = true
title = "取消装备需要一条我们没抓到过的上行(无证据)⇒ 不实现,按钮置灰"
+Translations.Text("lobby.garage.UNEQUIP_BUTTON")
}
}
if (Mounted) p { className = ClassName("Hint"); +Translations.Text("lobby.garage.EQUIPPED") }
} else {
p {
className = ClassName("Hint")
+"市场物品:本客户端未实现购买 / 装备(没有对应的上行抓包证据,故不伪造)。"
}
}
}
}
}
// ---------------------------------------------------------------- 车库:挂载位面板
internal external interface SlotRowProps : Props {
var SlotId: String
var DeviceCount: Int
var Mounted: String
var Expanded: Boolean
var OnToggle: () -> Unit
var Devices: List<String>
var OnEquip: (String) -> Unit
}
internal val SlotRow = FC<SlotRowProps> { P ->
div {
className = ClassName("Slot")
div {
className = ClassName("SlotHead")
onClick = { P.OnToggle() }
img {
src = if (P.Mounted.isNotEmpty()) UiAssets.SlotEquipped else UiAssets.SlotEmpty
className = ClassName("SlotIcon")
alt = ""
}
span { className = ClassName("SlotId"); +("槽位 " + P.SlotId) }
span { className = ClassName("SlotInfo"); +("设备 " + P.DeviceCount + " 个") }
span {
className = ClassName("SlotMounted")
+(if (P.Mounted.isNotEmpty()) "当前装备 " + P.Mounted else "未装备")
}
if (P.DeviceCount == 0) img { src = UiAssets.IcNoDevice; className = ClassName("TinyIcon"); alt = "" }
span { className = ClassName("Toggle"); +(if (P.Expanded) "▾" else "▸") }
}
if (P.Expanded) {
div {
className = ClassName("SlotDevices")
for (D in P.Devices) {
val IsOn = D == P.Mounted
button {
className = ClassName(if (IsOn) "DevBtn On" else "DevBtn")
disabled = IsOn
onClick = { P.OnEquip(D) }
img {
src = if (IsOn) UiAssets.IcUnmount else UiAssets.IcMount
className = ClassName("TinyIcon")
alt = ""
}
+(if (IsOn) Translations.Text("lobby.garage.EQUIPPED") + " " + D else "设备 " + D)
}
}
}
}
}
}
internal val MountPanel = FC<Props> {
section {
className = ClassName("Mounts")
h2 {
+("挂载位(devicesLoaded):槽位 " + Session.Slots.size + " 个 / 设备 " + Session.DeviceCount + " 个")
}
p {
className = ClassName("Hint")
+(
"图标:官方 protectionSlotEquip(已装备)/ protectionSlotEmpty(空)/ ic_mount(可装备)/ ic_unmount(已装备)/ " +
"ic_no_device(无设备)/ defaultDevice(设备条目)。点开槽位 → 点设备即发 dh.hcu(靶=槽位对象 id)。"
)
}
for (D in Session.Slots) {
SlotRow.create {
SlotId = D.SlotObjectId
DeviceCount = D.Devices.size
Mounted = D.MountedDevice ?: ""
Expanded = UiState.ExpandedSlot == D.SlotObjectId
OnToggle = {
UiState.Set {
UiState.ExpandedSlot = if (UiState.ExpandedSlot == D.SlotObjectId) null else D.SlotObjectId
}
}
Devices = D.Devices
OnEquip = { Dev -> Session.EquipDevice(D.SlotObjectId, Dev) }
}
}
if (Session.Slots.isEmpty()) p { className = ClassName("Hint"); +"(尚未收到 devicesLoaded:先在下方调试抽屉点「连接并进车库(100073070)」)" }
}
}
// ---------------------------------------------------------------- 车库屏
internal val GarageScreen = FC<Props> {
div {
className = ClassName("GarageRow")
+CategoryBar.create()
div {
className = ClassName("GarageCenter")
+TankStage.create()
+ItemGrid.create()
}
+DetailPanel.create()
}
+MountPanel.create()
}
@@ -0,0 +1,70 @@
// Translations.kt —— 官方中文文案(运行时从中继拉取官方原件,不在客户端里复制文本)
//
// 数据源:/play/static/translations/zh-CN.a2b303af.json(官方 zh-CN 译文,1751 条;文件名带官方内容 hash)。
// 该文件是**嵌套** JSON(顶层 lobby → lobby.garage.TITLE …),这里展平成「点分键 → 字符串」,
// 于是代码里用的键与官方键名逐字对应,便于溯源核对。
//
// 取不到时(中继没起 / 网络失败):Map 为空,Text() 回显键名本身(如实可辨,不编中文)。
package Ui
internal object Translations {
private const val SourceUrl = "/play/static/translations/zh-CN.a2b303af.json"
private val Map = LinkedHashMap<String, String>()
var Loaded = false
private set
var Error = ""
private set
private var Started = false
// 由 Main.kt 注入的「重绘」回调(避免本对象依赖 React)
var OnRedraw: (() -> Unit)? = null
fun Ensure() {
if (Started) return
Started = true
// 这些必须是**局部名**:js(...) 里的标识符按名字解析到外围 Kotlin 局部变量/参数
val Url = SourceUrl
val Ok: (String) -> Unit = { T -> OnOk(T) }
val Err: (String) -> Unit = { M -> OnErr(M) }
js(
"fetch(Url).then(function(R){ if(!R.ok) throw new Error('HTTP '+R.status); return R.text(); })" +
".then(function(T){ Ok(T); }).catch(function(E){ Err('' + E); })"
)
}
// 官方键名 → 中文;没查到就回显键名(便于人核对是「缺键」还是「文案错」)
fun Text(Key: String): String = Map[Key] ?: Key
// 供界面标注「文案来源」用
fun Count(): Int = Map.size
// ---- 回调
private fun OnOk(Text: String) {
try {
val Flat: dynamic = js(
"(function(T){ var J = JSON.parse(T); var Out = {}; " +
"(function W(O,P){ for (var K in O){ if(!Object.prototype.hasOwnProperty.call(O,K)) continue; " +
"var N = P ? P + '.' + K : K; var V = O[K]; " +
"if (V !== null && typeof V === 'object') W(V,N); else Out[N] = (V === null ? '' : String(V)); } })(J,''); " +
"return Out; })(Text)"
)
val Keys = js("Object.keys(Flat)") as Array<String>
Map.clear()
for (K in Keys) Map[K] = js("Flat[K]") as String
Loaded = true
Error = ""
} catch (E: dynamic) {
Error = "解析 zh-CN 官方译文失败:$E"
}
OnRedraw?.invoke()
}
private fun OnErr(Msg: String) {
Error = "拉取官方译文失败(" + SourceUrl + "):" + Msg
OnRedraw?.invoke()
}
}
+101
View File
@@ -0,0 +1,101 @@
// UiAssets.kt —— 界面用到的**官方素材**路径与「枚举成员 → 官方图标」映射(全部来自本地镜像 /play/static/**)
//
// 溯源原则:本文件里的每个文件名都是 3DTank/web/play/static/images/ 下的**官方原件**(带内容 hash 的真实文件名),
// 一律不拼名、不造名。映射关系(成员 → 文件)的出处写在每个 map 的注释里。
package Ui
import Protocol.ItemIcons
internal object UiAssets {
// 官方图标在 web/play 下的相对路径(AssetRelay 的 /play 前缀把它映射到本地镜像)
private const val Dir = "/play/static/images/"
private const val FontDir = "/play/static/fonts/"
// ---- 窗口 / 背景 / 按钮(官方原件)
const val Background = Dir + "background.fc2779c1.webp"
const val BgActionButton = Dir + "bgActionButton.b5ff3b72.svg"
const val BgSecondaryButton = Dir + "bgSecondaryButton.d770f6f8.svg"
const val BgActionButtonActive = Dir + "bgActionButtonActive.2c84e4a7.svg"
const val BgSecondaryButtonActive = Dir + "bgSecondaryButtonActive.af26dcd5.svg"
const val Button = Dir + "button.22e4056c.svg"
// ---- 字体(官方 MPLUS1p:中文字形优先;Rubik:数字/拉丁)
const val FontBold = FontDir + "MPLUS1p-Bold.be4e37ac.ttf"
const val FontMedium = FontDir + "MPLUS1p-Medium.2d50fad9.ttf"
const val FontRegular = FontDir + "MPLUS1p-Regular.d7322c05.ttf"
const val FontRubikMedium = FontDir + "Rubik-Medium.fc871fe9.woff"
const val FontRubikRegular = FontDir + "Rubik-Regular.7e001ec0.woff"
// ---- 顶部货币栏(官方原件)
const val Gold = Dir + "gold.6f457066.svg"
const val CrystalSmall = Dir + "crystalSmall.242e6c15.svg"
const val Premium = Dir + "premium.c9ee3121.svg"
// ---- 大厅菜单图标(文件名 = 官方 iconLobbyMenu*.svg;只有 War 有官方 Hover 版本)
val MenuIcon = mapOf(
"GARAGE" to Dir + "iconLobbyMenuGarage.0b623165.svg",
"SHOP" to Dir + "iconLobbyMenuStore.e755fe14.svg",
"CHAT" to Dir + "iconLobbyMenuChat.e48a39a4.svg",
"QUESTS" to Dir + "iconLobbyMenuQuests.54801b8b.svg",
"WAR" to Dir + "iconLobbyMenuWar.ada9b0af.svg",
"CONTAINERS" to Dir + "iconLobbyMenuContainers.f960290f.svg",
"CLANS" to Dir + "iconLobbyMenuClan.f01ae646.svg",
"FRIENDS" to Dir + "iconLobbyMenuFriends.28305317.svg",
"NEWS" to Dir + "iconLobbyMenuNews.6efe9ae3.svg",
"OPTIONS" to Dir + "iconLobbyMenuSettings.cc00d08a.svg",
)
// 官方只导出了 War 的 Hover 版本(iconLobbyMenuWarHover.4e013e6a.svg),其余项的高亮用官方
// 选中框 selectedEquipSlot.6403c13c.svg + CSS 亮度实现(见 index.html 的 .MenuRow.On / .MenuRow:hover)
val MenuIconHover = mapOf("WAR" to Dir + "iconLobbyMenuWarHover.4e013e6a.svg")
// ---- 车库分类图标:类别成员名 → 官方分类图标
// 成员名取自官方枚举 module_85196::ja(WEAPON/ARMOR/PAINT/INVENTORY/…/DRONE/INVISIBLE/DEVICES/SKINS/BAZOOKA)
val CategoryIcon = mapOf(
"ARMOR" to Dir + "hulls.b316ae80.svg",
"WEAPON" to Dir + "turrets.20be27f5.svg",
"PAINT" to Dir + "allPaints.741c65e1.svg",
"SKINS" to Dir + "allPaints.741c65e1.svg",
"SKINS_SHOT" to Dir + "allPaints.741c65e1.svg",
"DRONE" to Dir + "Drones.07e10f28.svg",
"INVENTORY" to Dir + "Supplies.57cf4b22.svg",
"BAZOOKA" to Dir + "Supplies.57cf4b22.svg",
"INVISIBLE" to Dir + "Supplies.57cf4b22.svg",
"SPECIAL" to Dir + "Supplies.57cf4b22.svg",
"KIT" to Dir + "Upgrades.fe5718ef.svg",
"DEVICES" to Dir + "Devices.ae5dfc92.svg",
"RESISTANCE" to Dir + "ProtectionModules.41103d70.svg",
)
// ---- 挂载位 / 设备(官方原件)
const val SlotSelected = Dir + "selectedEquipSlot.6403c13c.svg"
const val SlotEmpty = Dir + "protectionSlotEmpty.f0a3082e.svg"
const val SlotEquipped = Dir + "protectionSlotEquip.5f03fffe.svg"
const val SlotBlocked = Dir + "protectionSlotBlocked.50260988.svg"
const val IcMount = Dir + "ic_mount.4175dc0c.svg"
const val IcUnmount = Dir + "ic_unmount.01e4da15.svg"
const val IcNoDevice = Dir + "ic_no_device.74e94e6a.svg"
const val DefaultDevice = Dir + "defaultDevice.47b22a7c.svg"
// 官方稀有度图标表:module_82347.js:37290 的 switch(case) → 图标(case 即稀有度枚举 85196::Ma 的线形序数)
// 0 COMMON → iconCasualGray / 1 UNCOMMON → uncommon / 2 RARE → iconRareBlue /
// 3 EPIC → iconEpicFiolet / 4 LEGENDARY → iconLegendaryGold / 5 EXOTIC → iconCustomiseRed
private val RarityTable: Map<String, String> by lazy {
val T = ItemIcons.Data.OtherTables.firstOrNull { it.Module == "module_82347.js" && it.Line == 37290 }
T?.All ?: emptyMap()
}
// 稀有度线形原始值("enum#5")→ 官方稀有度图标;查不到返回 null(不猜)
fun RarityIcon(Raw: String?): String? {
val N = Raw?.removePrefix("enum#")?.toIntOrNull() ?: return null
val Rel = RarityTable[N.toString()] ?: return null
return "/play/" + Rel
}
// 官方图标表里存的是相对 web/play 根的路径(static/images/…),补上中继前缀
fun Play(Rel: String): String = "/play/" + Rel
// 物品图标:型号推不出来(数据里没有 TankInfoCC,见 ClientState 头注)时退回 null,
// 调用方按类别图标兜底(**不按名字猜文件名**)
fun ItemIcon(Rel: String?): String? = Rel?.let { Play(it) }
}
+77
View File
@@ -0,0 +1,77 @@
// UiState.kt —— 界面状态与运行时单例(会话对象、菜单表、当前屏)
//
// 刷新方式沿用 M6 的老办法(见 Main.kt 顶部说明):一份全局状态 + 根节点手动重渲染。
// 这里不做 React hooks:页面只有一份全局会话状态,根重渲染语义最简单。
package Ui
import Protocol.GarageSession
import Protocol.SpaceSession
// 大厅菜单的一项:Key = 官方文案键后缀(lobby.menu.<Key>);SpaceId 为空表示「没有空间 id 证据」
internal class MenuEntry(
val Key: String,
val SpaceId: Long?,
val Reason: String,
) {
val Enabled: Boolean get() = SpaceId != null
}
internal object UiState {
var Screen: String = "Garage" // Garage | Shop | Chat
var GarageTab: String = "Depot" // Depot | Market
var CategoryFilter: String? = null // null = 全部
var SelectedItemId: String? = null
var MarketPage: Int = 0
var ExpandedSlot: String? = null
var DebugOpen: Boolean = false
var ShowStateJson: Boolean = false
// 由 Main.kt 注入(重建 React 根元素)
var Redraw: () -> Unit = {}
fun Set(Block: () -> Unit) {
Block()
Redraw()
}
// 进入某个空间:车库走 GarageSession(有靶推导),商店/聊天走 SpaceSession(进得去 + 解得开)
fun Go(Key: String) {
val Entry = Menu.firstOrNull { it.Key == Key } ?: return
if (!Entry.Enabled) return
Set { Screen = Key }
when (Key) {
"Garage" -> if (!Session.Connected) Session.Connect()
"Shop" -> ShopSession.Enter(Entry.SpaceId!!)
"Chat" -> ChatSession.Enter(Entry.SpaceId!!)
}
}
// 大厅菜单 12 项(文案= lobby.menu.<Key>;只有车库/商店/聊天有空间 id 硬证据)
val Menu: List<MenuEntry> = listOf(
MenuEntry("GARAGE", 100073070L, "有会话回放模板 names/space-session-100073070.json,靶推导已闭环"),
MenuEntry("SHOP", 100072911L, "有初始化帧 names/space-init-100072911.json"),
MenuEntry("CHAT", 100045720L, "有初始化帧 names/space-init-100045720.json"),
MenuEntry("PLAY", null, "无空间 id 证据(战斗匹配服务不在本仓库抓包范围内)"),
MenuEntry("BATTLE", null, "无空间 id 证据"),
MenuEntry("QUESTS", null, "无空间 id 证据"),
MenuEntry("WAR", null, "无空间 id 证据"),
MenuEntry("CONTAINERS", null, "无空间 id 证据"),
MenuEntry("CLANS", null, "无空间 id 证据"),
MenuEntry("FRIENDS", null, "无空间 id 证据"),
MenuEntry("NEWS", null, "无空间 id 证据"),
MenuEntry("OPTIONS", null, "无空间 id 证据(设置是本地界面,不需要进空间)"),
)
}
// 运行时单例(构造时都不碰浏览器 API,只持有回调)
internal val Session = GarageSession { UiState.Redraw() }
internal val ShopSession = SpaceSession { UiState.Redraw() }
internal val ChatSession = SpaceSession { UiState.Redraw() }
// 当前屏对应的空间会话(商店/聊天各一份)
internal fun SpaceFor(Screen: String): SpaceSession? = when (Screen) {
"Shop" -> ShopSession
"Chat" -> ChatSession
else -> null
}
+64
View File
@@ -0,0 +1,64 @@
// Widgets.kt —— 界面用的小组件(FC + external interface Props;调用方需把所有字段显式填齐)
package Ui
import react.FC
import react.Props
import react.create
import react.dom.html.ReactHTML.div
import react.dom.html.ReactHTML.img
import react.dom.html.ReactHTML.span
import web.cssom.ClassName
// 官方图标(src 一律是 /play/static/** 下的官方原件)
internal external interface ImgProps : Props {
var Src: String
var Cls: String
var Alt: String
var Hover: String
}
internal val Img = FC<ImgProps> { P ->
img {
src = P.Src
className = ClassName(P.Cls)
alt = P.Alt
title = P.Hover
}
}
// 大厅菜单的一行(竖向图标菜单;当前项高亮;无空间 id 证据的置灰并在 title 里写原因)
internal external interface MenuRowProps : Props {
var Text: String
var Icon: String
var IconHover: String
var Enabled: Boolean
var Selected: Boolean
var Reason: String
var Badge: String
var OnClick: () -> Unit
}
internal val MenuRow = FC<MenuRowProps> { P ->
var Cls = "MenuRow"
if (P.Selected) Cls = Cls + " On"
if (!P.Enabled) Cls = Cls + " Off"
div {
className = ClassName(Cls)
title = if (P.Enabled) P.Text else P.Text + " —— " + P.Reason
onClick = { if (P.Enabled) P.OnClick() }
+Img.create {
Src = if (P.Selected && P.IconHover.isNotEmpty()) P.IconHover else P.Icon
Cls = "MenuIcon"
Alt = P.Text
Hover = P.Reason
}
span {
className = ClassName("MenuText")
+P.Text
}
span {
className = ClassName("MenuBadge")
+P.Badge
}
}
}
+136 -36
View File
@@ -1,36 +1,136 @@
<!DOCTYPE html> <!doctype html><html class="notranslate" translate="no"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,viewport-fit=cover,initial-scale=1,user-scalable=no"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-capable" content="yes"><title>3D坦克</title><meta name="google" content="notranslate"/><meta name="renderer" content="webkit"/><style>@font-face {
<html lang="zh-CN"> font-family: BaseFontRegular;
<head> src: url('/play/static/fonts/Rubik-Regular.7e001ec0.woff') format('woff2'),
<meta charset="UTF-8" /> url('/play/static/fonts/Rubik-Regular.7e001ec0.woff') format('woff');
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> }
<title>TankIcClient · 车库</title>
<style> @font-face {
body { margin: 0; padding: 16px; background: #14181f; color: #d7dde6; font-family: BaseFontBold;
font-family: "Cascadia Mono", Consolas, "Microsoft YaHei", monospace; } src: url('/play/static/fonts/Rubik-Bold.26ae1fce.woff') format('woff2'),
h1 { font-size: 20px; margin: 0 0 12px; } url('/play/static/fonts/Rubik-Bold.26ae1fce.woff') format('woff');
h2 { font-size: 14px; margin: 0 0 8px; color: #8fb7ff; } }
p { margin: 4px 0; }
/* 面板:每块一个卡片,避免整页挤成一坨 */ @font-face {
.Card { border: 1px solid #2b3442; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; } font-family: BaseFontMedium;
.Row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; } src: url('/play/static/fonts/Rubik-Medium.fc871fe9.woff') format('woff2'),
.UrlInput { flex: 0 0 360px; background: #0f1319; color: #d7dde6; url('/play/static/fonts/Rubik-Medium.fc871fe9.woff') format('woff');
border: 1px solid #2b3442; border-radius: 4px; padding: 4px 6px; } }
button { background: #24405f; color: #d7dde6; border: 1px solid #3b6ea5;
border-radius: 4px; padding: 5px 10px; cursor: pointer; } @font-face {
button:hover { background: #2d5078; } font-family: FallbackFontRegular;
ul { margin: 0; padding-left: 18px; } src: url('/play/static/fonts/MPLUS1p-Regular.d7322c05.ttf') format('truetype');
li { line-height: 1.5; } }
/* 市场 id 列表与日志:去掉项目符号、等宽排版,长行折行 */
ul.Plain { list-style: none; padding-left: 0; } @font-face {
ul.Plain li { white-space: pre-wrap; word-break: break-all; font-size: 12px; color: #b9c4d2; } font-family: FallbackFontBold;
ul.Log { max-height: 420px; overflow: auto; } src: url('/play/static/fonts/MPLUS1p-Bold.be4e37ac.ttf') format('truetype');
</style> }
</head>
<body> @font-face {
<!-- React 挂载点,与 Main.kt 中的 id 保持一致。 font-family: FallbackFontMedium;
连接与刷新由页面上的「连接并进车库(100073070)」按钮触发,数据全部来自 WebSocket。 --> src: url('/play/static/fonts/MPLUS1p-Medium.2d50fad9.ttf') format('truetype');
<div id="root"></div> }
<!-- webpack 产物名由 build.gradle.kts 的 outputFileName 决定 -->
<script src="TankIcClient.js"></script> html, body {
</body> -webkit-text-size-adjust: 100%;
</html> font-family: BaseFontMedium, FallbackFontMedium;
background-color: black;
width: 100%;
height: 100%;
margin: 0px;
overflow: hidden;
touch-action: none;
position: fixed;
}
.logo {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 128px;
height: 124px;
}
.preloader {
user-select: none;
background: radial-gradient(50% 100% at 50% 100%, #395673 0%, #001926 100%);
height: 100%;
width: 100%;
}
.progress {
position: absolute;
bottom: 40px;
height: 32px;
width: 320px;
transform: translateX(-50%);
left: 50%;
@media screen and (max-width: 640px), (max-height: 480px) {bottom: 20px;}
}
.loading-text {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
color: white;
font-family: BaseFontMedium, FallbackFontMedium;
font-style: normal;
line-height: 30px;
text-transform: uppercase;
@media screen and (max-width: 640px), (max-height: 480px) {bottom: 40px;}
}
#modal-root {
pointer-events: none;
position: absolute;
width: 100%;
height: 100%;
top: 0px;
bottom: 0px;
}
img {
pointer-events: none;
}
#modal-root > div {
pointer-events: auto;
}
* {
-webkit-tap-highlight-color: transparent;
}
:root {
--sar: env(safe-area-inset-right);
--sal: env(safe-area-inset-left);
}
.bgClipText {
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}</style><style id="media"></style></head><body><div id="preloader" class="preloader"><img class="logo" src="/play/static/images/logo.e64f36db.svg"> <img class="progress" src="/play/static/images/progress.9d48f262.png"><div class="loading-text" id="loading-text">&nbsp;</div></div><div id="root" class="wrapper"><div id="app-root"></div><div id="modal-root"></div></div><noscript></noscript><script>const devMode = false;
window.tankiBuildConstants = {"copyrightYear":2026,"isBuildForChina":true,"isProduction":true,"appLink":"https://app.tankionline.com"};
const loadingText = document.getElementById("loading-text");
loadingText.innerText = getLoadingText();
function getLoadingText() {
const localized = {
"en": "Loading",
"zh": "加载中",
"ru": "Загрузка",
"pt": "Carregando",
"pl": "Ładowanie",
"es": "Cargando",
"de": "Laden"
};
const foundLang = window.navigator.languages
.map((lang) => lang.substr(0, 2).toLowerCase())
.find((lang) => lang in localized);
return (foundLang != null) ? localized[foundLang] : localized["en"];
}</script><script defer="defer" src="TankIcClient.js"></script></body></html>