求解器(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>
337 lines
17 KiB
JavaScript
337 lines
17 KiB
JavaScript
///////////////////////////////////////////////////////////////
|
||
////////// 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;
|
||
}
|
||
};
|