// ExportItemIcons.mjs —— 从官方解包源码导出「型号枚举 → 官方图标文件」表(客户端可用) // // 产物(幂等可重复运行): // 1. client/src/jsMain/resources/ItemIcons.json 给人看 / 给其它语言用的 JSON 表 // 2. client/src/jsMain/kotlin/Protocol/ItemIconsData.kt 内嵌同一份 JSON 的 Kotlin 常量 // (Kotlin/JS 没有跨 browser/node 一致的资源读取 API,口径与 ExportProtocolTables.mjs 相同) // // 数据来源(只读,绝不改动): // 3dtank.com/play/static/js/unpacked/resources.json webpack 模块 id → static/images/xxx.svg // 3dtank.com/play/static/js/unpacked/module_36659.js 底盘:Mb(eA).upf = switch (t.k3_1) { case → n = <形参>; } // 3dtank.com/play/static/js/unpacked/module_57128.js 炮塔:ct(Dn).duk = switch (t.k3_1) { … } // 抗性:ct(Dn).euk = switch (t.k3_1) { case → return <形参>; } // 3dtank.com/play/static/js/unpacked/module_82347.js 7 格道具图标表(ZMi 数组) // 3DTank/web/play/static/translations/zh-CN.*.json lobby.garage.* 类别中文(显示层) // // 口径(为什么不能「case 号 = 实参位置」): // 工厂形参只有 714 个,而 switch 里引用的实参是**形参名**;形参与实参一一对应: // 形参#k 在参数列表第 k 行,实参#k 在调用实参列表第 k 行(两者行号差是文件的固定常量)。 // 所以必须走:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>) → resources.json → 文件名。 // 自验:用 Dn.euk(抗性 20 项)全套跑一遍,全部命中且都落在本地镜像里(见 stdout 的 SelfCheck)。 // // 用法:node client/tools/ExportItemIcons.mjs 'use strict'; import fs from 'node:fs'; import path from 'node:path'; import url from 'node:url'; const Here = path.dirname(url.fileURLToPath(import.meta.url)); const ClientDir = path.resolve(Here, '..'); const RepoDir = path.resolve(ClientDir, '..'); const UnpackedDir = path.join(RepoDir, '3dtank.com', 'play', 'static', 'js', 'unpacked'); // 图标路径形如 "static/images/xxx.svg",相对 web/play 根(不是 web/play/static) const WebPlayDir = path.join(RepoDir, '3DTank', 'web', 'play'); const WebStaticDir = path.join(WebPlayDir, 'static'); const OutJson = path.join(ClientDir, 'src', 'jsMain', 'resources', 'ItemIcons.json'); const OutKotlin = path.join(ClientDir, 'src', 'jsMain', 'kotlin', 'Protocol', 'ItemIconsData.kt'); const Resources = JSON.parse(fs.readFileSync(path.join(UnpackedDir, 'resources.json'), 'utf8')).resourceMap; // ---------------------------------------------------------------- 通用解析:形参表 ↔ 实参表 function ReadLines(File) { return fs.readFileSync(File, 'utf8').split('\n'); } // 形参声明行形如 " Ns,";实参行形如 " i(5012)," 或 " t.exports,"。 function TrimComma(Text) { let S = Text.trim(); if (S.endsWith(',')) S = S.slice(0, -1); return S; } function ModuleIdOfArg(Line) { const M = /i\(([0-9]+)\)/.exec(Line); return M ? M[1] : null; } // 为单个文件建「形参名 → 模块 id」:参数列表从 `function (` 行之后开始,实参列表从 `})(` 行之后开始。 // 两者同序、同长(取较短者为长度)。返回 null 表示该文件不是这种工厂结构。 function BuildParamMap(Lines) { let FnAt = -1; for (let I = 0; I < Lines.length; I++) if (Lines[I].trim() === '!(function (') { FnAt = I; break; } if (FnAt < 0) return null; let ParamEnd = -1; for (let I = FnAt + 1; I < Lines.length; I++) if (Lines[I] === ' ) {') { ParamEnd = I; break; } if (ParamEnd < 0) return null; let InvAt = -1; for (let I = ParamEnd + 1; I < Lines.length; I++) if (Lines[I] === ' })(') { InvAt = I; break; } if (InvAt < 0) return null; let ArgEnd = -1; for (let I = InvAt + 1; I < Lines.length; I++) if (Lines[I] === ' );' || Lines[I] === ' )') { ArgEnd = I; break; } if (ArgEnd < 0) return null; const Count = Math.min(ParamEnd - FnAt - 1, ArgEnd - InvAt - 1); const Map = {}; for (let K = 0; K < Count; K++) { const Name = TrimComma(Lines[FnAt + 1 + K]); const Id = ModuleIdOfArg(Lines[InvAt + 1 + K]); if (Name && Id) Map[Name] = Id; } return Map; } // 解一个 switch:支持两种体—— // ① 赋值式(底盘/炮塔):case N: / n = <形参>; / break; // ② 返回式(抗性):case N: return <形参>; 以及 fall-through(case 40: case 44: return M;) // 返回 { 序号: 形参名 }。遇到 default / return n; / 收尾 } 即停。 function ParseSwitch(Lines, SwitchAt) { const Out = {}; let Pending = []; for (let I = SwitchAt + 1; I < Lines.length; I++) { const L = Lines[I].trim(); if (L.startsWith('default:') || L === '}' || /^return [A-Za-z0-9_$]+;$/.test(L)) break; const M = /^case ([0-9]+):(.*)$/.exec(L); if (!M) continue; const C = Number(M[1]); const Rest = M[2].trim(); let Name = null; if (Rest.startsWith('return ')) Name = Rest.slice(7).replace(/;.*/, ''); else if (Rest.startsWith('n = ')) Name = Rest.slice(4).replace(/;.*/, ''); else if (Rest === '') { const Nx = Lines[I + 1] ? Lines[I + 1].trim() : ''; if (Nx.startsWith('return ')) { Name = Nx.slice(7).replace(/;.*/, ''); I++; } else if (Nx.startsWith('n = ')) { Name = Nx.slice(4).replace(/;.*/, ''); I++; } else { Pending.push(C); continue; } // fall-through(本 case 与下一个 case 共用体) } if (Name != null) { for (const P of Pending) Out[P] = Name; Pending = []; Out[C] = Name; } } return Out; } // 找函数体内第一个 switch (...) 行号;找不到返回 null function FindSwitch(Lines, FromLine1Based) { for (let I = FromLine1Based - 1; I < Lines.length; I++) if (/switch \([A-Za-z0-9_$]+\.k3_1\)/.test(Lines[I])) return I; return null; } // case 表 → { 序号: 图标文件 }(形参 → 模块 id → resources.json) function ResolveCaseTable(Cases, ParamMap) { const Out = {}; const Misses = []; for (const C of Object.keys(Cases).sort((A, B) => Number(A) - Number(B))) { const Param = Cases[C]; const Id = ParamMap[Param]; const File = Id ? Resources[Id] : null; const Rel = File && File.startsWith('/') ? File.slice(1) : File; if (!Rel) Misses.push({ Case: Number(C), Param, ModuleId: Id || null }); else Out[C] = Rel; // 统一成 "static/images/xxx.svg"(不带前导 /) } return { Table: Out, Misses }; } // ---------------------------------------------------------------- 底盘 / 炮塔 / 抗性 const HullLines = ReadLines(path.join(UnpackedDir, 'module_36659.js')); const TurretLines = ReadLines(path.join(UnpackedDir, 'module_57128.js')); const HullParams = BuildParamMap(HullLines); const TurretParams = BuildParamMap(TurretLines); if (!HullParams || !TurretParams) { console.error('✗ 解析形参/实参表失败(工厂结构变了?)'); process.exit(1); } const HullSwitchAt = FindSwitch(HullLines, 5133); const HullRaw = ParseSwitch(HullLines, HullSwitchAt); const Hull = ResolveCaseTable(HullRaw, HullParams); const HullMissLines = Hull.Misses.map((M) => 'case ' + M.Case + ' -> 形参 ' + M.Param + '(模块 id ' + (M.ModuleId || '无') + ')'); const TurretSwitchAt = FindSwitch(TurretLines, 838); const TurretRaw = ParseSwitch(TurretLines, TurretSwitchAt); const Turret = ResolveCaseTable(TurretRaw, TurretParams); // 抗性:ct(Dn).euk(约 906 行)—— 自验用 const ResistSwitchAt = FindSwitch(TurretLines, 906); const ResistRaw = ParseSwitch(TurretLines, ResistSwitchAt); const Resist = ResolveCaseTable(ResistRaw, TurretParams); // 自验:抗性每一项都要(a)解析出图标文件、(b)文件在本地镜像里真的存在。 function LocalExists(Rel) { return fs.existsSync(path.join(WebPlayDir, Rel)); } const ResistLines = []; let ResistLocal = 0; for (const C of Object.keys(Resist.Table)) { const Rel = Resist.Table[C]; const Ok = LocalExists(Rel); if (Ok) ResistLocal++; ResistLines.push('case ' + C + ' -> ' + Resist.Table[C] + (Ok ? ' ✓本地存在' : ' ✗本地缺失')); } const ResistDistinct = new Set(Object.values(Resist.Table)).size; // ---------------------------------------------------------------- 7 格道具图标表(module_82347) const SupplyLines = ReadLines(path.join(UnpackedDir, 'module_82347.js')); const SupplyParams = BuildParamMap(SupplyLines); const SupplyEntries = []; if (SupplyParams) { // 表体:7 个 new ZMi(<类型函数>, <图标资源形参>, <缩放函数>) for (let I = 0; I < SupplyLines.length; I++) { const M = /new ZMi\(([^,]+),\s*([A-Za-z0-9_$]+),\s*([^)]+)\)/.exec(SupplyLines[I]); if (!M) continue; const TypeFn = M[1].trim(); const IconParam = M[2].trim(); const Id = SupplyParams[IconParam]; const File = Id ? Resources[Id] : null; const Rel = File || null; SupplyEntries.push({ Key: TypeFn, // 产出「道具类型枚举」的函数名(源码里的局部名,语义未解,照抄不猜) Icon: Rel, ModuleId: Id || null, Line: I + 1, }); } } // ---------------------------------------------------------------- 其它同类表(自动扫描,如实报告) // 扫描所有解包模块:对每个 `switch (X.k3_1)`,若其 case 体解析出的形参**绝大多数**都落在 // resources.json 的图标资源上(≥ 4 个 case 且 ≥ 80% 命中),即认定是一张「case → 图标」表。 function ScanOtherTables() { const Found = []; // 已单独导出的三张 switch 表不再重复计入「其它表」 const Known = new Set([ 'module_36659.js:' + (HullSwitchAt + 1), 'module_57128.js:' + (TurretSwitchAt + 1), 'module_57128.js:' + (ResistSwitchAt + 1), ]); const Files = fs.readdirSync(UnpackedDir).filter((F) => /^module_\d+\.js$/.test(F)); for (const F of Files) { let Lines; try { Lines = ReadLines(path.join(UnpackedDir, F)); } catch (E) { continue; } const Params = BuildParamMap(Lines); if (!Params) continue; for (let I = 0; I < Lines.length; I++) { if (!/switch \([A-Za-z0-9_$]+\.k3_1\)/.test(Lines[I])) continue; if (Known.has(F + ':' + (I + 1))) continue; const Cases = ParseSwitch(Lines, I); const Nums = Object.keys(Cases); if (Nums.length < 4) continue; const R = ResolveCaseTable(Cases, Params); const Hit = Object.keys(R.Table).length; if (Hit / Nums.length >= 0.8 && Hit >= 4) { Found.push({ Module: F, Line: I + 1, Cases: Nums.length, Icons: Object.keys(R.Table).length, Sample: Object.keys(R.Table).sort((A, B) => Number(A) - Number(B)).slice(0, 4).map((C) => C + '=' + R.Table[C]), All: R.Table, }); } } } return Found; } const OtherTables = ScanOtherTables(); // ---------------------------------------------------------------- 类别中文(lobby.garage.*) const ZhFiles = fs.readdirSync(path.join(WebStaticDir, 'translations')).filter((F) => /^zh-CN\..*\.json$/.test(F)); const Zh = JSON.parse(fs.readFileSync(path.join(WebStaticDir, 'translations', ZhFiles[0]), 'utf8')); const GarageLabels = {}; for (const [K, V] of Object.entries((Zh.lobby && Zh.lobby.garage) || {})) { if (typeof V === 'string') GarageLabels[K] = V; } // ---------------------------------------------------------------- 组装产物 const Selection = { HullCases: Object.keys(Hull.Table).length, HullMisses: Hull.Misses.length, TurretCases: Object.keys(Turret.Table).length, TurretMisses: Turret.Misses.length, ResistanceCases: Object.keys(Resist.Table).length, ResistanceMisses: Resist.Misses.length, SupplyCount: SupplyEntries.length, OtherTableCount: OtherTables.length, GarageLabelCount: Object.keys(GarageLabels).length, }; const Tables = { Note: '物品图标表,由 client/tools/ExportItemIcons.mjs 从官方解包源码生成,请勿手改。' + 'Hull = module_36659.js 的 Mb(eA).upf switch:case(底盘型号)→ i(<模块id>) → resources.json 文件名;' + 'Turret = module_57128.js 的 ct(Dn).duk switch(炮塔型号);Resistance = 同文件 ct(Dn).euk switch(抗性,' + '用于自验解析算法)。解析口径:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>),' + '不能假设「case 号 = 实参位置」。Supply = module_82347.js 的 7 格道具图标表(new ZMi 数组,非 switch)。' + 'OtherTables = 自动扫描到的其它同类 case→图标 表(供手榴弹/无人机/防御模块查用;没有就是空数组)。' + 'GarageLabels = zh-CN 的 lobby.garage.*(只取字符串叶子),显示层类别中文用。' + '图标路径统一为 "static/images/xxx.svg"(相对 web/play 根,客户端配 AssetRelay 的 /play 前缀取用)。', GeneratedAt: new Date().toISOString(), Source: '3dtank.com/play/static/js/unpacked/{resources.json,module_36659.js,module_57128.js,module_82347.js} + ' + '3DTank/web/play/static/translations/' + ZhFiles[0], Selection, SelfCheck: { ResistanceCases: Object.keys(Resist.Table).length, ResistanceResolved: Object.keys(Resist.Table).length, ResistanceLocalFilesPresent: ResistLocal, ResistanceDistinctFiles: ResistDistinct, Lines: ResistLines, }, Hull: Hull.Table, Turret: Turret.Table, Resistance: Resist.Table, Supply: SupplyEntries, OtherTables, GarageLabels, }; const Json = JSON.stringify(Tables); fs.mkdirSync(path.dirname(OutJson), { recursive: true }); fs.writeFileSync(OutJson, Json + '\n', 'utf8'); function EscapeKotlin(S) { let Out = ''; for (const Ch of S) { const Code = Ch.codePointAt(0); if (Ch === '\\') Out += '\\\\'; else if (Ch === '"') Out += '\\"'; else if (Ch === '$') Out += '\\$'; else if (Code < 0x20) Out += '\\u' + Code.toString(16).padStart(4, '0'); else Out += Ch; } return Out; } const Kotlin = '// 本文件由 client/tools/ExportItemIcons.mjs 生成,请勿手改。\n' + '// 内容 = client/src/jsMain/resources/ItemIcons.json(同一份 JSON)。\n' + 'package Protocol\n' + '\n' + 'internal const val ItemIconsJsonText: String =\n "' + EscapeKotlin(Json) + '"\n'; fs.mkdirSync(path.dirname(OutKotlin), { recursive: true }); fs.writeFileSync(OutKotlin, Kotlin, 'utf8'); // ---------------------------------------------------------------- 自验输出 console.log('已生成物品图标表:'); console.log(' ' + OutJson + ' ' + Json.length + ' 字符'); console.log(' ' + OutKotlin + ' ' + Kotlin.length + ' 字符'); console.log(' module_36659.js 形参数 ' + Object.keys(HullParams).length + ' / module_57128.js 形参数 ' + Object.keys(TurretParams).length); console.log(''); console.log('【自验】抗性表 Dn.euk(module_57128.js:' + (ResistSwitchAt + 1) + '):' + Object.keys(Resist.Table).length + ' 个 case → 解析出 ' + Object.keys(Resist.Table).length + ' 个图标,本地镜像里存在 ' + ResistLocal + ' 个,去重后 ' + ResistDistinct + ' 个文件'); for (const L of ResistLines) console.log(' ' + L); console.log(' 自验结论:' + (ResistLocal === Object.keys(Resist.Table).length ? '✓ ' + ResistLocal + '/' + Object.keys(Resist.Table).length + ' 全命中且本地存在(去重 ' + ResistDistinct + ' 个文件),case→形参→实参 的解析算法成立' : '✗ 有未命中')); console.log(''); console.log('【导出】底盘 upf(module_36659.js:' + (HullSwitchAt + 1) + '):' + Selection.HullCases + ' case → ' + Selection.HullCases + ' 图标(未解 ' + Selection.HullMisses + ')'); for (const L of HullMissLines) console.log(' 未解:' + L); console.log('【导出】炮塔 duk(module_57128.js:' + (TurretSwitchAt + 1) + '):' + Selection.TurretCases + ' case → ' + Selection.TurretCases + ' 图标(未解 ' + Selection.TurretMisses + ')'); console.log('【导出】7 格道具图标表(module_82347.js):' + SupplyEntries.length + ' 项 → ' + SupplyEntries.map((S) => S.Icon).join(', ')); console.log(''); console.log('【自动扫描】其它 case→图标 表:' + OtherTables.length + ' 张'); for (const T of OtherTables) { console.log(' ' + T.Module + ':' + T.Line + ' case ' + T.Cases + ' → 图标 ' + T.Icons + ' 样例 ' + T.Sample.join(', ')); } if (!OtherTables.length) console.log(' (无:手榴弹 / 无人机 / 防御模块未找到同类 case→图标 表,如实标注「无此表」)'); console.log(''); console.log('【显示层】lobby.garage.* 中文标签 ' + Object.keys(GarageLabels).length + ' 条');