二七王:确立「一个 View 恰好一个图层」不变式

原本允许 Layer1/Layer2 声明多图层,但「哪个 group 属于哪个图层」只能靠
注释隐含、无法机械判定。改为跨图层的界面拆成两个 View,各带自己的 Layer——
一个 View 将来对应一个 BaseComponent 组件,本就该分开。

SpriteIndex 加校验守住该不变式:View 缺 Layer、或写成 Layer1/Layer2 均报错。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 21:01:04 +08:00
co-authored by Claude Opus 5
parent 488f36b545
commit 69cb04ecfa
2 changed files with 25 additions and 16 deletions
@@ -1533,7 +1533,7 @@ EOF
```js ```js
EQW_Sprites.<ViewName> = { EQW_Sprites.<ViewName> = {
Layer: <图层常量>, // 该 View 用到的图层;用到多个时写 Layer1 / Layer2 … Layer: <图层常量>, // 该 View 的图层;【一个 View 恰好一个图层】
<GroupName>: { // 一个群组容器:群组 ID + 它名下的全部精灵 <GroupName>: { // 一个群组容器:群组 ID + 它名下的全部精灵
id: <群组常量>, id: <群组常量>,
@@ -1545,7 +1545,7 @@ EQW_Sprites.<ViewName> = {
``` ```
- **结构约定**(`SpriteIndex` 与守卫测试据此机械区分): - **结构约定**(`SpriteIndex` 与守卫测试据此机械区分):
- View 内:键名 `Layer`(或 `Layer1`/`Layer2`…)是 **View 级声明**;**其余键的值必须是对象**,即一个 group 容器。View 内不允许出现别的标量键。 - View 内:键名 `Layer` 是 **View 级声明**,且**必须有且只有这一个图层**——跨图层的界面拆成两个 View(一个 View 将来对应一个 `BaseComponent`);**其余键的值必须是对象**,即一个 group 容器。View 内不允许出现别的标量键。
- group 容器内:键名 `id` 是**群组 ID**;**其余键一律是精灵 id(数字)**。 - group 容器内:键名 `id` 是**群组 ID**;**其余键一律是精灵 id(数字)**。
- **精灵的所属群组由结构本身表达**,注释里不必再重复写属于哪个 group;但仍要写类型(图片/文字)+ 用途 + 资源键 + 帧说明。 - **精灵的所属群组由结构本身表达**,注释里不必再重复写属于哪个 group;但仍要写类型(图片/文字)+ 用途 + 资源键 + 帧说明。
- group 容器名用大驼峰、体现它是哪一块(`TopInfo`、`LeftMark`、`Footer`、`HandCards`…),不必与群组常量同名。 - group 容器名用大驼峰、体现它是哪一块(`TopInfo`、`LeftMark`、`Footer`、`HandCards`…),不必与群组常量同名。
@@ -1665,7 +1665,7 @@ EOF
> 布局配置里 `attach.target` 写的是**键名**而非数字 ID(清单 §6.1:「`target` 一律写键名,ID 回填时只改一处映射表」),而精灵常量是按 View → group 组织的,故需要这张扁平索引。 > 布局配置里 `attach.target` 写的是**键名**而非数字 ID(清单 §6.1:「`target` 一律写键名,ID 回填时只改一处映射表」),而精灵常量是按 View → group 组织的,故需要这张扁平索引。
> `groupIdOf` 是新结构带来的收益:group 容器把精灵与它的群组 ID 绑在一起,于是「某个精灵属于哪个群组」可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。 > `groupIdOf` 是新结构带来的收益:group 容器把精灵与它的群组 ID 绑在一起,于是「某个精灵属于哪个群组」可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。
> **结构约定**:View 内 `Layer`(含 `Layer1`/`Layer2`…)是 View 级声明、跳过;其余键的值必须是**对象**(group 容器),否则报错。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id,**值必须是数字**,否则报错。 > **结构约定**:View 内 `Layer` 是 View 级声明、跳过;**一个 View 恰好一个图层**(跨图层的界面拆成两个 View);其余键的值必须是**对象**(group 容器),否则报错。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id,**值必须是数字**,否则报错。
> **重复键必须报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。 > **重复键必须报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。
- [ ] **Step 1: 写失败的测试 `client/tests/test_spriteindex.js`** - [ ] **Step 1: 写失败的测试 `client/tests/test_spriteindex.js`**
@@ -1701,11 +1701,16 @@ t.eq('group 的 id 不进索引', flat.id, undefined);
t.eq('群组映射', EQW_SpriteIndex.buildGroupMap(tree), t.eq('群组映射', EQW_SpriteIndex.buildGroupMap(tree),
{ BTN_A: 201, BTN_B: 201, P_LEFT_BANKER: 202, P_RIGHT_BANKER: 203 }); { BTN_A: 201, BTN_B: 201, P_LEFT_BANKER: 202, P_RIGHT_BANKER: 203 });
// ---- 多个 Layer 的 View(Layer1/Layer2)也正确跳过 ---- // ---- 反面:一个 View 恰好一个 Layer;Layer1/Layer2 这种多图层写法要报错 ----
// (跨图层的界面应拆成两个 View,各带自己的 Layer——一个 View 将来对应一个 BaseComponent)
const multiLayer = { const multiLayer = {
OverlayView: { Layer1: 104, Layer2: 105, Bar: { id: 230, TIP_BG: 1750 } } OverlayView: { Layer1: 104, Layer2: 105, Bar: { id: 230, TIP_BG: 1750 } }
}; };
t.eq('多 Layer 声明跳过', EQW_SpriteIndex.build(multiLayer), { 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 = { const dupTree = {
@@ -1778,23 +1783,26 @@ var EQW_SpriteIndex = EQW_SpriteIndex || {
_index: null, _index: null,
_groupMap: null, _groupMap: null,
//View 级保留键:Layer,可带数字后缀(Layer1、Layer2…)
_isLayerKey: function (key) {
return (/^Layer\d*$/).test(key);
},
//遍历 树 → View → group → 精灵,对每个精灵回调 fn(key, spriteId, groupId) //遍历 树 → View → group → 精灵,对每个精灵回调 fn(key, spriteId, groupId)
//【不变式】一个 View 恰好一个图层:View 必须有数字型的 Layer 键。
//跨图层的界面应拆成两个 View(各带自己的 Layer)——一个 View 将来对应一个
//BaseComponent 组件,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」
//只能靠注释隐含、无法机械判定。
_walk: function (spriteTree, fn) { _walk: function (spriteTree, fn) {
for (var viewName in spriteTree) { for (var viewName in spriteTree) {
if (!spriteTree.hasOwnProperty(viewName)) { continue; } if (!spriteTree.hasOwnProperty(viewName)) { continue; }
var view = spriteTree[viewName]; var view = spriteTree[viewName];
if (typeof view.Layer !== 'number') {
throw new Error('[EQW_SpriteIndex] View ' + viewName +
' 缺少数字型的 Layer(一个 View 恰好一个图层;跨图层请拆成两个 View)');
}
for (var groupName in view) { for (var groupName in view) {
if (!view.hasOwnProperty(groupName)) { continue; } if (!view.hasOwnProperty(groupName)) { continue; }
if (this._isLayerKey(groupName)) { continue; } //View 级图层声明 if (groupName === 'Layer') { continue; } //View 级图层声明
var group = view[groupName]; var group = view[groupName];
if (!group || typeof group !== 'object') { if (!group || typeof group !== 'object') {
throw new Error('[EQW_SpriteIndex] ' + viewName + '.' + groupName + throw new Error('[EQW_SpriteIndex] ' + viewName + '.' + groupName +
' 不是 group 容器(值应为对象)——图层声明请用保留键名 Layer*'); ' 不是 group 容器(值应为对象)——图层声明请用保留键名 Layer');
} }
if (typeof group.id !== 'number') { if (typeof group.id !== 'number') {
throw new Error('[EQW_SpriteIndex] group 容器 ' + viewName + '.' + groupName + throw new Error('[EQW_SpriteIndex] group 容器 ' + viewName + '.' + groupName +
@@ -104,7 +104,7 @@ client/tests/
| `Groups.js` | `EQW_Groups` | 清单 §0.3 群组表 201–250 | | `Groups.js` | `EQW_Groups` | 清单 §0.3 群组表 201–250 |
| `ImageResources.js` | `EQW_Images` | 清单 §3 图片资源总表,**每条按 ImageResources.template 的要求注明用途 / 帧数 / 每帧含义 / 尺寸** | | `ImageResources.js` | `EQW_Images` | 清单 §3 图片资源总表,**每条按 ImageResources.template 的要求注明用途 / 帧数 / 每帧含义 / 尺寸** |
| `SoundResources.js` | `EQW_Sounds` | 清单 §4——**整节 T-20 未定**,本阶段只建文件骨架与说明注释,不臆造条目(见 §11) | | `SoundResources.js` | `EQW_Sounds` | 清单 §4——**整节 T-20 未定**,本阶段只建文件骨架与说明注释,不臆造条目(见 §11) |
| `Sprites_*.js` | `EQW_Sprites` | 清单 §5.1–§5.8。**以 UI View 为组织单位**(一个 View 将来对应一个 `BaseComponent`),View 下是一个个 **group 容器**:`{ Layer: 图层, GroupName: { id: 群组, 精灵键: id, … } }`。用到多个图层时写 `Layer1`/`Layer2`。每个精灵注明类型 / 用途 / 资源键 / 帧说明 | | `Sprites_*.js` | `EQW_Sprites` | 清单 §5.1–§5.8。**以 UI View 为组织单位**(一个 View 将来对应一个 `BaseComponent`),View 下是一个个 **group 容器**:`{ Layer: 图层, GroupName: { id: 群组, 精灵键: id, … } }`。一个 View 恰好一个图层,跨图层的界面拆成两个 View。每个精灵注明类型 / 用途 / 资源键 / 帧说明 |
| `LayoutConstants.js` | `EQW_Layout` | 清单 §6.3 `textStyle` 预设、§6.4 `CARD_SIZE` 三档、座位键常量 `SELF/LEFT/RIGHT` | | `LayoutConstants.js` | `EQW_Layout` | 清单 §6.3 `textStyle` 预设、§6.4 `CARD_SIZE` 三档、座位键常量 `SELF/LEFT/RIGHT` |
| `Layout_Table.js` | `EQW_Layout` | 清单 §6.5 常驻区 + §6.6 玩家位(含气泡朝向 `arrowRight`) | | `Layout_Table.js` | `EQW_Layout` | 清单 §6.5 常驻区 + §6.6 玩家位(含气泡朝向 `arrowRight`) |
| `Layout_Cards.js` | `EQW_Layout` | 清单 §6.7 牌区(手牌单排/双排、底牌、埋牌底牌、已出牌 `bySeat`、冲关牌) | | `Layout_Cards.js` | `EQW_Layout` | 清单 §6.7 牌区(手牌单排/双排、底牌、埋牌底牌、已出牌 `bySeat`、冲关牌) |
@@ -193,7 +193,7 @@ EQW_SeatMap.toSeat(displayKey, mySeat) // → 服务端座位号
```js ```js
EQW_Sprites.TopInfoView = { EQW_Sprites.TopInfoView = {
Layer: EQW_Layers.TABLE_STATIC, // 用到多个图层时写 Layer1 / Layer2… Layer: EQW_Layers.TABLE_STATIC, // 一个 View 恰好一个图层
TopInfo: { // group 容器 TopInfo: { // group 容器
id: EQW_Groups.TOP_INFO, id: EQW_Groups.TOP_INFO,
TOP_INFO_BG: 1001, TOP_INFO_BG: 1001,
@@ -214,7 +214,7 @@ EQW_SpriteIndex.groupIdOf(key) // 查它所属的群组 ID;查不
`groupIdOf` 是这个结构带来的收益:group 容器把精灵与群组 ID 绑在一起,「某个精灵属于哪个群组」于是可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。 `groupIdOf` 是这个结构带来的收益:group 容器把精灵与群组 ID 绑在一起,「某个精灵属于哪个群组」于是可以机械查出——显隐走群组时(前端红线:显隐由组件的 `showXxx`/`hideXxx` 控制)不必再手工对照。
**结构约定**:View 内 `Layer`(含 `Layer1`/`Layer2`…)是 View 级声明、跳过;其余键的值必须是**对象**(group 容器)。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id。 **结构约定**:View 内 `Layer` 是 View 级声明、跳过;**一个 View 恰好一个图层**——跨图层的界面拆成两个 View(一个 View 将来对应一个 `BaseComponent`,把两个图层塞进一个 View 会让「哪个 group 属于哪个图层」只能靠注释隐含)。其余键的值必须是**对象**(group 容器)。group 容器内 `id` 是群组 ID、跳过;其余键一律是精灵 id。
**显式失败**五处(工程总则 §7): **显式失败**五处(工程总则 §7):
@@ -222,7 +222,8 @@ EQW_SpriteIndex.groupIdOf(key) // 查它所属的群组 ID;查不
- **重复键报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。同名键在不同 View 下重复由清单 §5 的键名设计避免(`PLAY_SELF_*` / `PLAY_LEFT_*` / `PLAY_RIGHT_*`); - **重复键报错**而非后者覆盖前者——覆盖会让某个界面静默指向另一个界面的精灵。同名键在不同 View 下重复由清单 §5 的键名设计避免(`PLAY_SELF_*` / `PLAY_LEFT_*` / `PLAY_RIGHT_*`);
- **精灵值非数字报错**; - **精灵值非数字报错**;
- **View 下混入标量键报错**(例如把群组 ID 误写成 View 级的 `Group: 201` 而没包进 group 容器); - **View 下混入标量键报错**(例如把群组 ID 误写成 View 级的 `Group: 201` 而没包进 group 容器);
- **group 容器缺 `id` 报错**。 - **group 容器缺 `id` 报错**;
- **View 缺 `Layer`(或写成 `Layer1`/`Layer2` 的多图层形式)报错**。
--- ---