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:
1 parent
6c36c5e98d
commit
2224ddc3f6
12 files changed
+2060
-172
No files matched your search
@@ -1,151 +1,41 @@
|
||||
// TankIcClient 客户端入口
|
||||
//
|
||||
// node 分支(jsNodeDevelopmentRun):M1–M5 的协议层自检,保持原样不动。
|
||||
// browser 分支:M6 的最小「车库」只读界面 —— 把 M5 已经能拿到的语义状态(ClientState)渲染出来。
|
||||
// node 分支(jsNodeDevelopmentRun / jsDevelopmentExecutableRun):M1–M13 的协议层自检与 dump,**保持原样不动**。
|
||||
// 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 一次,
|
||||
// React 按元素类型做协调、复用同一批 DOM 节点(所以输入框里已输入的内容不会被清掉)。
|
||||
// 为什么不走 hooks:本页面只有一份全局会话状态,用根节点重渲染语义最简单,也不引入 hook 依赖数组的心智负担。
|
||||
// 刷新方式:沿用既有的「全局状态 + 手动重渲染根节点」——会话对象每次收帧/发上行回调一次,
|
||||
// 这里把 #app-root 清空后按官方层级重挂一遍(DOM 直写,不走 React;理由见 UiDom.kt 文件头)。
|
||||
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.GarageDriver
|
||||
import Protocol.GarageSession
|
||||
import Protocol.IsNodeRuntime
|
||||
import Protocol.ItemInfoDriver
|
||||
import Protocol.SelfTest
|
||||
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)
|
||||
private val Session = GarageSession { RenderApp() }
|
||||
|
||||
// 页面根组件:只读渲染 Session 的快照
|
||||
private val App = FC<Props> {
|
||||
div {
|
||||
h1 { +"TankIcClient · M10 车库(物品装备 + 挂载位设备装备)" }
|
||||
|
||||
// ---------------- 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 渲染官方界面:清空 #app-root → 按官方节点层级重挂 → 移除 #preloader(官方行为,module_13431.js:625-643)
|
||||
private fun RenderOfficial() {
|
||||
val Root = AppRoot() ?: return
|
||||
InjectOfficialGlobal()
|
||||
Root.innerHTML = ""
|
||||
GarageMainScreen.Mount(Root)
|
||||
RemovePreloader()
|
||||
}
|
||||
|
||||
// React 根节点只建一次;之后每次刷新都是对同一个根 render 一个新元素
|
||||
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 上
|
||||
// 入口函数:Node 下跑协议层自检;浏览器下渲染官方界面并把探针挂到 window 上
|
||||
fun main() {
|
||||
if (IsNodeRuntime()) {
|
||||
// 比对脚本用:只把指定抓包帧解成 JSON 输出(TANKIC_CONTAINER_DUMP=1),不连服务器
|
||||
@@ -171,6 +61,33 @@ fun main() {
|
||||
SelfTest.Run()
|
||||
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
|
||||
var LastError: String = ""
|
||||
private set
|
||||
// 是否已进空间(大厅菜单据此避免重复重连;连接/重连时清零)
|
||||
var Connected: Boolean = false
|
||||
private set
|
||||
|
||||
private var Conn: Connection? = null
|
||||
private var Auto: AutoResponder? = null
|
||||
@@ -62,6 +65,20 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
|
||||
// 类别的展示文本(成员名 + 原始序数):UI/日志用;查不到写「enum#N(未解)」,无 pt 数据写「?」
|
||||
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() {
|
||||
@@ -72,6 +89,7 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
|
||||
FrameNo = 0
|
||||
EnterSent = false
|
||||
LastError = ""
|
||||
Connected = false
|
||||
LsDerived = emptyList()
|
||||
BaDerived = emptyList()
|
||||
LsEnqueued = false
|
||||
@@ -167,6 +185,7 @@ internal class GarageSession(private val OnChanged: () -> Unit) {
|
||||
AddLog("→ c2s Auth/EnterSpace($SpaceId) ${Req.size}B")
|
||||
Conn?.Send(Req)
|
||||
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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TankIcClient · 车库</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 16px; background: #14181f; color: #d7dde6;
|
||||
font-family: "Cascadia Mono", Consolas, "Microsoft YaHei", monospace; }
|
||||
h1 { font-size: 20px; margin: 0 0 12px; }
|
||||
h2 { font-size: 14px; margin: 0 0 8px; color: #8fb7ff; }
|
||||
p { margin: 4px 0; }
|
||||
/* 面板:每块一个卡片,避免整页挤成一坨 */
|
||||
.Card { border: 1px solid #2b3442; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
|
||||
.Row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.UrlInput { flex: 0 0 360px; background: #0f1319; color: #d7dde6;
|
||||
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; }
|
||||
button:hover { background: #2d5078; }
|
||||
ul { margin: 0; padding-left: 18px; }
|
||||
li { line-height: 1.5; }
|
||||
/* 市场 id 列表与日志:去掉项目符号、等宽排版,长行折行 */
|
||||
ul.Plain { list-style: none; padding-left: 0; }
|
||||
ul.Plain li { white-space: pre-wrap; word-break: break-all; font-size: 12px; color: #b9c4d2; }
|
||||
ul.Log { max-height: 420px; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- React 挂载点,与 Main.kt 中的 id 保持一致。
|
||||
连接与刷新由页面上的「连接并进车库(100073070)」按钮触发,数据全部来自 WebSocket。 -->
|
||||
<div id="root"></div>
|
||||
<!-- webpack 产物名由 build.gradle.kts 的 outputFileName 决定 -->
|
||||
<script src="TankIcClient.js"></script>
|
||||
</body>
|
||||
</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 {
|
||||
font-family: BaseFontRegular;
|
||||
src: url('/play/static/fonts/Rubik-Regular.7e001ec0.woff') format('woff2'),
|
||||
url('/play/static/fonts/Rubik-Regular.7e001ec0.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: BaseFontBold;
|
||||
src: url('/play/static/fonts/Rubik-Bold.26ae1fce.woff') format('woff2'),
|
||||
url('/play/static/fonts/Rubik-Bold.26ae1fce.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: BaseFontMedium;
|
||||
src: url('/play/static/fonts/Rubik-Medium.fc871fe9.woff') format('woff2'),
|
||||
url('/play/static/fonts/Rubik-Medium.fc871fe9.woff') format('woff');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: FallbackFontRegular;
|
||||
src: url('/play/static/fonts/MPLUS1p-Regular.d7322c05.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: FallbackFontBold;
|
||||
src: url('/play/static/fonts/MPLUS1p-Bold.be4e37ac.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: FallbackFontMedium;
|
||||
src: url('/play/static/fonts/MPLUS1p-Medium.2d50fad9.ttf') format('truetype');
|
||||
}
|
||||
|
||||
html, body {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
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"> </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>
|
||||
Reference in new issue
Block a user