From 1976edf9bd5de970e7fee9b71b238cfe80733b7c Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 26 Aug 2026 21:06:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8C=E4=B8=83=E7=8E=8B=EF=BC=9A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20SpriteIndex=EF=BC=8C=E7=B2=BE=E7=81=B5=E9=94=AE?= =?UTF-8?q?=E5=90=8D=E6=89=81=E5=B9=B3=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 布局配置按键名引用精灵,本模块把嵌套的精灵常量展平供查。 重复键与未知键一律抛错——静默覆盖会让界面指向别处的精灵且极难排查。 用例含真实常量的全量建索引校验。 Co-Authored-By: Claude Opus 5 (1M context) --- .../js/01_SubGame/codes/core/SpriteIndex.js | 101 ++++++++++++++++++ client/tests/test_spriteindex.js | 87 +++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 client/js/01_SubGame/codes/core/SpriteIndex.js create mode 100644 client/tests/test_spriteindex.js diff --git a/client/js/01_SubGame/codes/core/SpriteIndex.js b/client/js/01_SubGame/codes/core/SpriteIndex.js new file mode 100644 index 0000000..e111195 --- /dev/null +++ b/client/js/01_SubGame/codes/core/SpriteIndex.js @@ -0,0 +1,101 @@ +/////////////////////////////////////////////////////////////// +////////// EQW_SpriteIndex: 精灵键名 → ID 扁平索引 ///////////// +/////////////////////////////////////////////////////////////// +// 精灵常量以 UI View 为组织单位,View 下是一个个 group 容器: +// EQW_Sprites.XxxView = { Layer: 图层, GroupName: { id: 群组, 精灵键: id, ... } } +// 而布局配置里 attach.target 写的是【键名】(清单 §6.1:ID 回填时只改一处 +// 映射表),故需把这棵树展平成 { 键名: 精灵ID } 供求解器查用; +// group 容器把精灵与群组 ID 绑在一起,顺带能给出 { 键名: 群组ID }。 +// 【显式失败】重复键、值非数字、结构不合法、查不到的键一律抛错——静默覆盖 +// 会让某个界面指向另一个界面的精灵,且极难排查(工程总则 §7)。 +var EQW_SpriteIndex = EQW_SpriteIndex || { + + _index: null, + _groupMap: null, + + //遍历 树 → View → group → 精灵,对每个精灵回调 fn(key, spriteId, groupId) + //【不变式】一个 View 恰好一个图层:View 必须有数字型的 Layer 键。 + //跨图层的界面应拆成两个 View(各带自己的 Layer)——一个 View 将来对应一个 + //BaseComponent 组件,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」 + //只能靠注释隐含、无法机械判定。 + _walk: function (spriteTree, fn) { + for (var viewName in spriteTree) { + if (!spriteTree.hasOwnProperty(viewName)) { continue; } + var view = spriteTree[viewName]; + if (typeof view.Layer !== 'number') { + throw new Error('[EQW_SpriteIndex] View ' + viewName + + ' 缺少数字型的 Layer(一个 View 恰好一个图层;跨图层请拆成两个 View)'); + } + for (var groupName in view) { + if (!view.hasOwnProperty(groupName)) { continue; } + if (groupName === 'Layer') { continue; } //View 级图层声明 + var group = view[groupName]; + if (!group || typeof group !== 'object') { + throw new Error('[EQW_SpriteIndex] ' + viewName + '.' + groupName + + ' 不是 group 容器(值应为对象)——图层声明请用保留键名 Layer'); + } + if (typeof group.id !== 'number') { + throw new Error('[EQW_SpriteIndex] group 容器 ' + viewName + '.' + groupName + + ' 缺少数字型的 id(群组 ID)'); + } + for (var key in group) { + if (!group.hasOwnProperty(key)) { continue; } + if (key === 'id') { continue; } //群组 ID + if (typeof group[key] !== 'number') { + throw new Error('[EQW_SpriteIndex] 精灵 ' + key + ' 的值不是数字(' + + viewName + '.' + groupName + ')'); + } + fn(key, group[key], group.id, viewName, groupName); + } + } + } + }, + + //展平成 { 键名: 精灵ID } + build: function (spriteTree) { + var index = {}; + this._walk(spriteTree, function (key, spriteId, groupId, viewName, groupName) { + if (index.hasOwnProperty(key)) { + throw new Error('[EQW_SpriteIndex] 精灵键名重复: ' + key + + '(' + viewName + '.' + groupName + ' 与更早的定义冲突)'); + } + index[key] = spriteId; + }); + return index; + }, + + //展平成 { 键名: 群组ID },供「显隐走群组」时查精灵属于哪个群组 + buildGroupMap: function (spriteTree) { + var map = {}; + this._walk(spriteTree, function (key, spriteId, groupId) { + map[key] = groupId; + }); + return map; + }, + + //用给定的树(默认 EQW_Sprites)建立两张内部索引 + init: function (spriteTree) { + var tree = spriteTree || EQW_Sprites; + this._index = this.build(tree); + this._groupMap = this.buildGroupMap(tree); + return this._index; + }, + + //按键名取精灵 ID;查不到抛错,绝不返回 undefined 让 SpriteManager 静默无效 + idOf: function (key) { + if (!this._index) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); } + if (!this._index.hasOwnProperty(key)) { + throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key); + } + return this._index[key]; + }, + + //按键名取它所属的群组 ID + groupIdOf: function (key) { + if (!this._groupMap) { throw new Error('[EQW_SpriteIndex] 尚未 init()'); } + if (!this._groupMap.hasOwnProperty(key)) { + throw new Error('[EQW_SpriteIndex] 未知精灵键名: ' + key); + } + return this._groupMap[key]; + } +}; diff --git a/client/tests/test_spriteindex.js b/client/tests/test_spriteindex.js new file mode 100644 index 0000000..3c88a73 --- /dev/null +++ b/client/tests/test_spriteindex.js @@ -0,0 +1,87 @@ +// View → group → 精灵 的常量树 → 扁平键名索引 +const { load, throws } = require('./_load'); +const t = require('./_assert')(); + +load('client/js/01_SubGame/codes/core/SpriteIndex.js'); + +// ---- 展平:Layer 与 group 的 id 都不进索引,精灵进 ---- +const tree = { + TopInfoView: { + Layer: 101, + TopInfo: { id: 201, BTN_A: 1001, BTN_B: 1002 } + }, + PlayerMarkView: { + Layer: 101, + LeftMark: { id: 202, P_LEFT_BANKER: 1100 }, + RightMark: { id: 203, P_RIGHT_BANKER: 1110 } + } +}; +t.eq('展平结果', EQW_SpriteIndex.build(tree), + { BTN_A: 1001, BTN_B: 1002, P_LEFT_BANKER: 1100, P_RIGHT_BANKER: 1110 }); + +// ---- Layer 与 group 的 id 不进索引 ---- +const flat = EQW_SpriteIndex.build(tree); +t.eq('Layer 不进索引', flat.Layer, undefined); +t.eq('group 的 id 不进索引', flat.id, undefined); + +// ---- 群组映射:精灵 → 它所属的群组 ID ---- +t.eq('群组映射', EQW_SpriteIndex.buildGroupMap(tree), + { BTN_A: 201, BTN_B: 201, P_LEFT_BANKER: 202, P_RIGHT_BANKER: 203 }); + +// ---- 反面:一个 View 恰好一个 Layer;Layer1/Layer2 这种多图层写法要报错 ---- +// (跨图层的界面应拆成两个 View,各带自己的 Layer——一个 View 将来对应一个 BaseComponent) +const multiLayer = { + OverlayView: { Layer1: 104, Layer2: 105, Bar: { id: 230, TIP_BG: 1750 } } +}; +t.eq('多 Layer 写法报错', throws(() => EQW_SpriteIndex.build(multiLayer)), true); + +// ---- 反面:View 缺 Layer 声明要报错 ---- +const noLayer = { ViewA: { G1: { id: 201, BTN: 1001 } } }; +t.eq('View 缺 Layer 报错', throws(() => EQW_SpriteIndex.build(noLayer)), true); + +// ---- 反面:重复键报错,不静默覆盖 ---- +const dupTree = { + ViewA: { Layer: 101, G1: { id: 201, SAME: 1001 } }, + ViewB: { Layer: 102, G2: { id: 202, SAME: 1002 } } +}; +t.eq('重复键报错', throws(() => EQW_SpriteIndex.build(dupTree)), true); + +// ---- 反面:精灵值不是数字要报错 ---- +const badValue = { ViewA: { Layer: 101, G1: { id: 201, NESTED: { x: 1 } } } }; +t.eq('精灵值非数字报错', throws(() => EQW_SpriteIndex.build(badValue)), true); + +// ---- 反面:View 下混入标量键(不是 group 容器)要报错 ---- +// 例如把群组 ID 误写成 View 级的 `Group: 201`,而不是包进 group 容器 +const strayScalar = { ViewA: { Layer: 101, Group: 201, G1: { id: 202, BTN: 1001 } } }; +t.eq('View 下混入标量键报错', throws(() => EQW_SpriteIndex.build(strayScalar)), true); + +// ---- 反面:group 容器缺 id 要报错 ---- +const noGroupId = { ViewA: { Layer: 101, G1: { BTN: 1001 } } }; +t.eq('group 缺 id 报错', throws(() => EQW_SpriteIndex.build(noGroupId)), true); + +// ---- idOf / groupIdOf:查得到 / 查不到抛错 ---- +EQW_SpriteIndex.init(tree); +t.eq('idOf 查到', EQW_SpriteIndex.idOf('BTN_A'), 1001); +t.eq('groupIdOf 查到', EQW_SpriteIndex.groupIdOf('P_RIGHT_BANKER'), 203); +t.eq('idOf 查不到抛错', throws(() => EQW_SpriteIndex.idOf('NOT_EXIST')), true); +t.eq('groupIdOf 查不到抛错', throws(() => EQW_SpriteIndex.groupIdOf('NOT_EXIST')), true); + +// ---- 空树 ---- +t.eq('空树', EQW_SpriteIndex.build({}), {}); + +// ---- 真实常量:EQW_Sprites 能完整建索引且无重复键 ---- +load('client/js/01_SubGame/codes/config/Layers.js'); +load('client/js/01_SubGame/codes/config/Groups.js'); +load('client/js/01_SubGame/codes/config/Sprites_Table.js'); +load('client/js/01_SubGame/codes/config/Sprites_Cards.js'); +load('client/js/01_SubGame/codes/config/Sprites_Action.js'); +load('client/js/01_SubGame/codes/config/Sprites_Result.js'); +load('client/js/01_SubGame/codes/config/Sprites_CreateRoom.js'); + +let realOk = true; +let realIndex = {}; +try { realIndex = EQW_SpriteIndex.build(EQW_Sprites); } catch (e) { realOk = false; console.log('建索引失败: ' + e.message); } +t.eq('真实精灵常量无重复键', realOk, true); +t.eq('真实精灵数量 > 200', Object.keys(realIndex).length > 200, true); + +process.exit(t.done('spriteindex') ? 0 : 1);