二七王:布局求解器补齐显式失败与运行时注入契约,配置补 runtime/targetKind 声明

求解器(ui/LayoutSolver.js):
- 按方向校验 anchor:竖排只收 top/bottom/center、横排只收 left/right/center,
  未知 direction 一并拒绝。框架 AlignmentUtils 的 switch 对写错方向的 anchor 落到
  default(居中),笔误会被静默吞掉——ROOM_CATEGORY_COLUMN 就是这么把前两行摆到画布外的。
- grid 校验合并 ctx 后的 cols/rows 必须是数字:缺 rows 时 capacity=NaN 让超载守卫失效、
  循环一次不跑,静默返回空数组(一个矩形都不产出)。
- line/fan/grid 要求 anchorX/anchorY 必须是数字(框架的 anchorY||0 会把缺失变成 0);
  point 要求 x/y 必须是数字。
- apply() 精灵数与矩形数不等时抛错,不再静默截断:截断会让多余精灵停在上一手牌的旧坐标上。
- 统一运行时注入:ctx 里的 INJECT_KEYS(target/x/y/w/h/anchorX/anchorY/rows/
  itemWidth/itemHeight)覆盖配置同名字段,优先级 ctx > bySeat > base。
- line + items 竖排显式拒绝(只实现了水平)。

配置:
- ROOM_CATEGORY_COLUMN anchor 由 'left' 改为 'top'(竖排语义)。
- ROOM_OPTION_OVERFLOW_GRID 补 rows(runtime)、槽尺寸与 anchor 沿用 ROOM_OPTION_ROW,
  cols 直接引用 ROOM_OPTION_MAX_PER_ROW;ROOM_OPTION_ROW 补上注释里已声明的 itemHeight。
- 所有依赖运行时注入的节点加 runtime 声明,所有 attach 节点加 targetKind
  (sprite / layout / platform),显式区分「忘了写」与「故意延后到运行时」。

守卫(tests/test_constants.js):
- 求解冒烟改为只对 runtime 声明过的键注入假值,没声明却缺字段的照常抛错变红。
- 补矩形数量断言(line/fan/grid 按项数、point/attach 恒 1)——此前只查数组与坐标类型,
  空数组照样通过,「少画了几张牌」对守卫完全不可见。
- attach.target 改为只在 targetKind 指定的那一个命名空间里校验存在。
- 补 runtime 声明自身的合法性检查与布局节点总数(62)钉死。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-26 22:24:16 +08:00
co-authored by Claude Opus 5
parent b81be36f6e
commit 92edb6a3f5
8 changed files with 374 additions and 63 deletions
+99 -8
View File
@@ -7,22 +7,36 @@
//
// solve(node, ctx) 是【纯函数】:不碰精灵、不查引擎,故可完整单测。
// node: 一份布局配置(五型之一,可含 bySeat)
// ctx : { seat, count, rects }
// ctx : { seat, count, rects, <运行时注入字段> }
// seat —— 'SELF'|'LEFT'|'RIGHT',仅 bySeat 需要
// count —— 运行时项数(清单 §6.1:count 由数据给出、不进配置)
// rects —— { 键名: {x,y,width,height} },仅 attach 需要
// 返回恒为数组,point / attach 长度为 1。
//
// 【运行时注入】配置里写不出固定值的字段(动态文字宽高、贴哪张牌、随数据变化的行数/锚点…)
// 一律留空,由调用方在 ctx 里补上;可注入字段见 INJECT_KEYS,优先级恒为 ctx > bySeat > base。
// 布局节点用 runtime: ['target','w',…] 显式声明自己有哪些字段延后到运行时——那是给人和
// 机械守卫看的契约(区分「忘了写」与「故意留空」),求解器本身对所有 INJECT_KEYS 一视同仁。
//
// 参数名一律沿用框架 AlignmentUtils 的入参名(清单 §6.0),配置可原样喂入。
// 精灵锚点恒在左上角,配置里的 anchorX/anchorY 是【对齐基准点】,不是精灵左上角。
// 出错一律抛异常,不返回 {x:0,y:0} 之类的兜底值(工程总则 §7 显式失败)。
var EQW_LayoutSolver = EQW_LayoutSolver || {
//可由 ctx 运行时注入的配置字段(与布局节点 runtime 声明里允许出现的键名同一份清单)
INJECT_KEYS: ['target', 'x', 'y', 'w', 'h', 'anchorX', 'anchorY', 'rows', 'itemWidth', 'itemHeight'],
//各方向合法的对齐基准:框架 AlignmentUtils 的 switch 对不认识的 anchor 一律落到
//default(居中),写错方向的 anchor(如竖排写 'left')会被静默吞掉、界面偏到画外也不报错。
//故在此按方向白名单校验,把笔误挡在求解入口(工程总则 §7 显式失败)
ANCHORS_HORIZONTAL: ['left', 'right', 'center'],
ANCHORS_VERTICAL: ['top', 'bottom', 'center'],
//——— 对外:求解 ———
solve: function (node, ctx) {
if (!node) { throw new Error('[EQW_LayoutSolver] node 为空'); }
ctx = ctx || {};
var n = this._mergeBySeat(node, ctx.seat);
var n = this._mergeCtx(this._mergeBySeat(node, ctx.seat), ctx);
switch (n.kind) {
case 'point': return this._solvePoint(n);
@@ -35,14 +49,69 @@ var EQW_LayoutSolver = EQW_LayoutSolver || {
},
//——— 对外:求解并摆精灵(唯一触碰 SpriteManager 之处)———
//精灵个数与求解出的矩形数必须一一对应:数量不等时显式抛错,【不静默截断】——
//少摆的那几个精灵会停在上一手牌的旧坐标上,是最难反查的一类错位(工程总则 §7 显式失败)
apply: function (node, spriteIds, ctx) {
var rects = this.solve(node, ctx);
for (var i = 0; i < spriteIds.length && i < rects.length; i++) {
if (!spriteIds || spriteIds.length !== rects.length) {
throw new Error('[EQW_LayoutSolver] apply 精灵数与矩形数不一致: spriteIds=' +
(spriteIds ? spriteIds.length : 0) + ' rects=' + rects.length);
}
for (var i = 0; i < spriteIds.length; i++) {
SpriteManager.setPosition(spriteIds[i], rects[i].x, rects[i].y);
}
return rects;
},
//——— ctx 运行时注入:ctx 里给出的 INJECT_KEYS 字段覆盖配置同名字段 ———
_mergeCtx: function (node, ctx) {
var hit = false;
var k, i;
for (i = 0; i < this.INJECT_KEYS.length; i++) {
if (typeof ctx[this.INJECT_KEYS[i]] !== 'undefined') { hit = true; break; }
}
if (!hit) { return node; }
var merged = {};
for (k in node) {
if (node.hasOwnProperty(k)) { merged[k] = node[k]; }
}
for (i = 0; i < this.INJECT_KEYS.length; i++) {
k = this.INJECT_KEYS[i];
if (typeof ctx[k] !== 'undefined') { merged[k] = ctx[k]; }
}
return merged;
},
//——— 对齐基准点校验:line/fan/grid 都要 anchorX/anchorY ———
//框架 AlignmentUtils 内部是 options.anchorY || 0,缺失会被静默当成 0(整排贴到画布顶边),
//故在此显式要求;写不出固定值的(如随类别行浮动的 anchorY)用 runtime 声明 + ctx 注入
_requireAnchorPoint: function (n) {
if (typeof n.anchorX !== 'number' || typeof n.anchorY !== 'number') {
throw new Error('[EQW_LayoutSolver] ' + n.kind + ' 需要数字 anchorX/anchorY:配置未写,' +
'且 ctx.anchorX/ctx.anchorY 未给出(anchorX=' + n.anchorX + ' anchorY=' + n.anchorY + ')');
}
},
//——— 方向 × 对齐基准校验 ———
_checkAnchor: function (direction, anchor) {
var allowed;
if (direction === 'vertical') {
allowed = this.ANCHORS_VERTICAL;
} else if (direction === 'horizontal' || typeof direction === 'undefined') {
allowed = this.ANCHORS_HORIZONTAL; //不写 direction 等同水平(框架默认)
} else {
throw new Error('[EQW_LayoutSolver] 未知 direction: ' + direction);
}
//不写 anchor 等同框架默认 center,两个方向都合法;写了就必须与方向匹配
if (typeof anchor === 'undefined') { return; }
for (var i = 0; i < allowed.length; i++) {
if (allowed[i] === anchor) { return; }
}
throw new Error('[EQW_LayoutSolver] direction=' + (direction || 'horizontal') +
' 不接受 anchor=' + anchor + ',只能是 ' + allowed.join('/'));
},
//——— bySeat 合并:bySeat[seat] 覆盖外层 base,未列出的字段继承(清单 §6.2)———
_mergeBySeat: function (node, seat) {
if (!node.bySeat) { return node; }
@@ -61,12 +130,20 @@ var EQW_LayoutSolver = EQW_LayoutSolver || {
return merged;
},
//point:定点。x/y 缺失即无法定位,显式抛错——不返回 {x:undefined} 让 NaN 流到界面上
//(x/y 写不出固定值的节点,用 runtime 声明并由 ctx.x/ctx.y 注入)
_solvePoint: function (n) {
if (typeof n.x !== 'number' || typeof n.y !== 'number') {
throw new Error('[EQW_LayoutSolver] point 需要数字 x/y:配置未写,且 ctx.x/ctx.y 未给出(x=' +
n.x + ' y=' + n.y + ')');
}
return [{ x: n.x, y: n.y, width: n.w, height: n.h }];
},
//line:等距排列。带 items 时逐项取宽(itemWidth 失效),此时不能走 distribute(它假设等宽)
_solveLine: function (n, ctx) {
this._checkAnchor(n.direction, n.anchor);
this._requireAnchorPoint(n);
if (n.items) { return this._solveLineItems(n); }
var count = ctx.count;
@@ -87,7 +164,11 @@ var EQW_LayoutSolver = EQW_LayoutSolver || {
},
//line + items:逐项不等宽(底栏功能钮组、埋牌操作条等,清单 §6.1)
//只实现了水平方向(逐项取宽、沿 x 推进);竖排逐项不等高暂无用例,显式拒绝而不是按水平算
_solveLineItems: function (n) {
if (n.direction === 'vertical') {
throw new Error('[EQW_LayoutSolver] line + items 仅支持 direction=horizontal');
}
var items = n.items;
var spacing = n.spacing || 0;
var i, total = 0;
@@ -112,6 +193,8 @@ var EQW_LayoutSolver = EQW_LayoutSolver || {
//fan:张数可变、总宽受限、动态压缩间距(清单 §6.1,间距算法【唯一实现】)
_solveFan: function (n, ctx) {
this._checkAnchor(n.direction, n.anchor);
this._requireAnchorPoint(n);
var count = ctx.count;
if (typeof count !== 'number') { throw new Error('[EQW_LayoutSolver] fan 需要 ctx.count'); }
if (count <= 0) { return []; }
@@ -141,6 +224,15 @@ var EQW_LayoutSolver = EQW_LayoutSolver || {
if (n.fillOrder && n.fillOrder !== 'row') {
throw new Error('[EQW_LayoutSolver] grid 仅支持 fillOrder=row,暂不支持: ' + n.fillOrder);
}
//cols/rows 缺一即容量为 NaN:超载守卫失效(count > NaN 恒为 false)、循环一次都不跑、
//静默返回空数组——摆不下与「一个都没摆」同样必须显式失败(工程总则 §7)。
//rows 允许由 ctx.rows 运行时注入(行数随实际项数而定的场景),此处校验的是合并后的有效值
if (typeof n.cols !== 'number' || typeof n.rows !== 'number') {
throw new Error('[EQW_LayoutSolver] grid 需要数字 cols/rows:配置未写,且 ctx.rows 未给出(cols=' +
n.cols + ' rows=' + n.rows + ')');
}
this._checkAnchor('horizontal', n.anchor); //grid 逐行走水平分布,anchor 同水平语义
this._requireAnchorPoint(n);
var capacity = n.cols * n.rows;
var count = (typeof ctx.count === 'number') ? ctx.count : capacity;
@@ -173,17 +265,16 @@ var EQW_LayoutSolver = EQW_LayoutSolver || {
//attach:贴在另一个精灵上。corner 为空 = 在目标【内部】对齐;有值 = 与目标对应角重合
//target/w/h 都支持运行时注入:叫分档位贴自己的按钮、花色图标贴自己的按钮、牌角标贴"打出的那张牌",
//这类目标配置里写不出固定值;动态文字标签的真实宽高要等文案渲染出来才知道,配置里也留空。
//这些节点写成模板,由 ctx.target/ctx.w/ctx.h 在运行时补上(ctx.* 优先于 n.* 里的同名字段)
//这些节点写成模板并用 runtime 声明留空字段,由 ctx 在运行时补上(已在 solve 里合并进 n)
_solveAttach: function (n, ctx) {
if (!ctx.rects) { throw new Error('[EQW_LayoutSolver] attach 需要 ctx.rects'); }
var targetKey = ctx.target || n.target;
var targetKey = n.target;
if (!targetKey) { throw new Error('[EQW_LayoutSolver] attach 配置未写 target,且 ctx.target 未给出'); }
var target = ctx.rects[targetKey];
if (!target) { throw new Error('[EQW_LayoutSolver] attach 的 target 未在 ctx.rects 中: ' + targetKey); }
//ctx.w/ctx.h 优先于配置里的 n.w/n.h;后续的校验、求解、返回值一律用合并后的值
var w = (typeof ctx.w === 'number') ? ctx.w : n.w;
var h = (typeof ctx.h === 'number') ? ctx.h : n.h;
var w = n.w;
var h = n.h;
var sprite = { width: w, height: h };
var offset = { x: n.offsetX || 0, y: n.offsetY || 0 };