Files
erqiwang_youle/client/js/01_SubGame/codes/ui/LayoutSolver.js
T
joywayerandClaude Opus 5 92edb6a3f5 二七王:布局求解器补齐显式失败与运行时注入契约,配置补 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>
2026-08-26 22:24:16 +08:00

337 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
///////////////////////////////////////////////////////////////
////////// EQW_LayoutSolver: 五型布局求解器 ////////////////////
///////////////////////////////////////////////////////////////
// 清单 §6:布局配置是【纯数据】,一切求解逻辑集中在本文件。
// point 定点 line 等距排列(可 items 逐项不等宽)
// fan 自适应压缩 grid 网格 attach 相对贴附
//
// solve(node, ctx) 是【纯函数】:不碰精灵、不查引擎,故可完整单测。
// node: 一份布局配置(五型之一,可含 bySeat)
// 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._mergeCtx(this._mergeBySeat(node, ctx.seat), ctx);
switch (n.kind) {
case 'point': return this._solvePoint(n);
case 'line': return this._solveLine(n, ctx);
case 'fan': return this._solveFan(n, ctx);
case 'grid': return this._solveGrid(n, ctx);
case 'attach': return this._solveAttach(n, ctx);
default: throw new Error('[EQW_LayoutSolver] 未知 kind: ' + n.kind);
}
},
//——— 对外:求解并摆精灵(唯一触碰 SpriteManager 之处)———
//精灵个数与求解出的矩形数必须一一对应:数量不等时显式抛错,【不静默截断】——
//少摆的那几个精灵会停在上一手牌的旧坐标上,是最难反查的一类错位(工程总则 §7 显式失败)
apply: function (node, spriteIds, ctx) {
var rects = this.solve(node, ctx);
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; }
if (!seat) { throw new Error('[EQW_LayoutSolver] 配置含 bySeat,但 ctx.seat 未给出'); }
if (!node.bySeat[seat]) { throw new Error('[EQW_LayoutSolver] bySeat 无此显示位: ' + seat); }
var merged = {};
var k;
for (k in node) {
if (node.hasOwnProperty(k) && k !== 'bySeat') { merged[k] = node[k]; }
}
var v = node.bySeat[seat];
for (k in v) {
if (v.hasOwnProperty(k)) { merged[k] = v[k]; }
}
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;
if (typeof count !== 'number') { throw new Error('[EQW_LayoutSolver] line 需要 ctx.count'); }
if (count <= 0) { return []; }
var positions = AlignmentUtils.distribute({
direction: n.direction,
anchorX: n.anchorX,
anchorY: n.anchorY,
count: count,
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: n.spacing || 0,
anchor: n.anchor
});
return this._withSize(positions, n.itemWidth, n.itemHeight);
},
//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;
for (i = 0; i < items.length; i++) { total += items[i].width; }
total += (items.length - 1) * spacing;
var start;
switch (n.anchor) {
case 'left': start = n.anchorX; break;
case 'right': start = n.anchorX - total; break;
default: start = n.anchorX - total / 2; break;
}
var out = [];
var cursor = start;
for (i = 0; i < items.length; i++) {
out.push({ x: cursor, y: n.anchorY, width: items[i].width, height: n.itemHeight });
cursor += items[i].width + spacing;
}
return out;
},
//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 []; }
var spacing = 0;
if (count > 1) {
var raw = (n.maxWidth - n.itemWidth) / (count - 1) - n.itemWidth;
spacing = Math.max(n.spacingMin, Math.min(n.spacingMax, raw));
}
var positions = AlignmentUtils.distribute({
direction: n.direction,
anchorX: n.anchorX,
anchorY: n.anchorY,
count: count,
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: spacing,
anchor: n.anchor
});
return this._withSize(positions, n.itemWidth, n.itemHeight);
},
//grid:固定行列的按钮阵,逐行调 distributeHorizontally(清单 §6.1)
_solveGrid: function (n, ctx) {
//fillOrder 目前只实现行优先;不写等同 row,写了别的值一律显式拒绝(YAGNI,不做无人用的列优先)
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;
//配置与运行时项数不一致时必须显式报错,不能静默截断多出的项(工程总则 §7 显式失败)
if (count > capacity) {
throw new Error('[EQW_LayoutSolver] grid 项数超出容量: count=' + count + ' > cols(' + n.cols + ')*rows(' + n.rows + ')=' + capacity);
}
if (count <= 0) { return []; }
var out = [];
for (var row = 0; row < n.rows && out.length < count; row++) {
var remain = count - out.length;
var inRow = Math.min(n.cols, remain);
var positions = AlignmentUtils.distributeHorizontally({
anchorX: n.anchorX,
anchorY: n.anchorY + row * (n.itemHeight + (n.spacingY || 0)),
count: n.cols, //按满格算位置,保证不足一行时列位不漂移
itemWidth: n.itemWidth,
itemHeight: n.itemHeight,
spacing: n.spacingX || 0,
anchor: n.anchor
});
for (var c = 0; c < inRow; c++) {
out.push({ x: positions[c].x, y: positions[c].y, width: n.itemWidth, height: n.itemHeight });
}
}
return out;
},
//attach:贴在另一个精灵上。corner 为空 = 在目标【内部】对齐;有值 = 与目标对应角重合
//target/w/h 都支持运行时注入:叫分档位贴自己的按钮、花色图标贴自己的按钮、牌角标贴"打出的那张牌",
//这类目标配置里写不出固定值;动态文字标签的真实宽高要等文案渲染出来才知道,配置里也留空。
//这些节点写成模板并用 runtime 声明留空字段,由 ctx 在运行时补上(已在 solve 里合并进 n)
_solveAttach: function (n, ctx) {
if (!ctx.rects) { throw new Error('[EQW_LayoutSolver] attach 需要 ctx.rects'); }
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); }
var w = n.w;
var h = n.h;
var sprite = { width: w, height: h };
var offset = { x: n.offsetX || 0, y: n.offsetY || 0 };
var pos;
//w/h 是否必填取决于本次实际走的对齐分支是否用到精灵尺寸(AlignmentUtils 源码逐分支核对):
//纯文字标签在 left/top/bottom 对齐下不需要尺寸,一刀切要求 w/h 会逼配置去编造假数值
if (n.corner) {
switch (n.corner) {
case 'topLeft':
pos = AlignmentUtils.alignCornerTopLeft(target, sprite, offset); break;
case 'topRight':
this._requireW(w, targetKey, 'corner=topRight');
pos = AlignmentUtils.alignCornerTopRight(target, sprite, offset); break;
case 'bottomLeft':
this._requireH(h, targetKey, 'corner=bottomLeft');
pos = AlignmentUtils.alignCornerBottomLeft(target, sprite, offset); break;
case 'bottomRight':
this._requireW(w, targetKey, 'corner=bottomRight');
this._requireH(h, targetKey, 'corner=bottomRight');
pos = AlignmentUtils.alignCornerBottomRight(target, sprite, offset); break;
default: throw new Error('[EQW_LayoutSolver] 未知 corner: ' + n.corner);
}
} else {
//hAlign: center/right 要用 sprite.width;left 不用(AlignmentUtils.js 第106/111/101行)
if (n.hAlign === 'center' || n.hAlign === 'right') {
this._requireW(w, targetKey, 'hAlign=' + n.hAlign);
}
//vAlign: 只有 middle 用 sprite.height;top/bottom 都不用(bottom 用的是 target.height,第132行)
if (n.vAlign === 'middle') {
this._requireH(h, targetKey, 'vAlign=' + n.vAlign);
}
pos = AlignmentUtils.alignTo(target, sprite, n.hAlign, n.vAlign, offset);
}
return [{ x: pos.x, y: pos.y, width: w, height: h }];
},
_requireW: function (w, targetKey, mode) {
if (typeof w !== 'number') {
throw new Error('[EQW_LayoutSolver] attach target=' + targetKey + '(' + mode + ')需要数字 w:配置未写 w,且 ctx.w 未给出');
}
},
_requireH: function (h, targetKey, mode) {
if (typeof h !== 'number') {
throw new Error('[EQW_LayoutSolver] attach target=' + targetKey + '(' + mode + ')需要数字 h:配置未写 h,且 ctx.h 未给出');
}
},
//distribute* 只返回 {x,y},这里补上尺寸
_withSize: function (positions, w, h) {
var out = [];
for (var i = 0; i < positions.length; i++) {
out.push({ x: positions[i].x, y: positions[i].y, width: w, height: h });
}
return out;
}
};