diff --git a/client/src/jsMain/kotlin/Main.kt b/client/src/jsMain/kotlin/Main.kt index 7f0533b..e27847d 100644 --- a/client/src/jsMain/kotlin/Main.kt +++ b/client/src/jsMain/kotlin/Main.kt @@ -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 { - 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 + } } diff --git a/client/src/jsMain/kotlin/Protocol/GarageSession.kt b/client/src/jsMain/kotlin/Protocol/GarageSession.kt index c43d8d4..3754a9f 100644 --- a/client/src/jsMain/kotlin/Protocol/GarageSession.kt +++ b/client/src/jsMain/kotlin/Protocol/GarageSession.kt @@ -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 = StateObj.ItemInfos() + + // 调试抽屉用:整份语义状态 JSON(与 M5 的 <<>> 同一份 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 } } } diff --git a/client/src/jsMain/kotlin/Protocol/SpaceSession.kt b/client/src/jsMain/kotlin/Protocol/SpaceSession.kt new file mode 100644 index 0000000..8f0bc7a --- /dev/null +++ b/client/src/jsMain/kotlin/Protocol/SpaceSession.kt @@ -0,0 +1,176 @@ +// SpaceSession.kt —— 浏览器侧「进空间 + 收帧 + 解码」会话(M12-B 的 node 侧实现 SpaceDumpDriver 的浏览器移植) +// +// 用途:大厅里除车库之外还能进的两个空间(商店 100072911 / 聊天 100045720)——它们没有会话回放模板, +// 服务端走「进空间即下发初始化帧」那条路(names/space-init-.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, + val Summaries: List, + 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() + val Logs = ArrayList() + 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() + 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 + } +} diff --git a/client/src/jsMain/kotlin/Ui/Official/OfficialGarage.kt b/client/src/jsMain/kotlin/Ui/Official/OfficialGarage.kt new file mode 100644 index 0000000..6532b50 --- /dev/null +++ b/client/src/jsMain/kotlin/Ui/Official/OfficialGarage.kt @@ -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(官方是 ,见 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) } diff --git a/client/src/jsMain/kotlin/Ui/Official/UiDom.kt b/client/src/jsMain/kotlin/Ui/Official/UiDom.kt new file mode 100644 index 0000000..8dcb84a --- /dev/null +++ b/client/src/jsMain/kotlin/Ui/Official/UiDom.kt @@ -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 运行时注入 +// - - - -
- - - - +3D坦克