二七王:前端 SpriteIndex,精灵键名扁平索引

布局配置按键名引用精灵,本模块把嵌套的精灵常量展平供查。
重复键与未知键一律抛错——静默覆盖会让界面指向别处的精灵且极难排查。
用例含真实常量的全量建索引校验。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:06:15 +08:00
co-authored by Claude Opus 5
parent 4d05457590
commit 1976edf9bd
2 changed files with 188 additions and 0 deletions
@@ -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];
}
};
+87
View File
@@ -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);