二七王:前端 LayoutSolver,五型布局求解器
solve 是纯函数(attach 靠 ctx.rects 查目标矩形而非反查引擎), fan 的 clamp 间距算法只此一处实现;line 带 items 时自行按累计宽度推进, 不走假设等宽的 distribute。未知 kind/缺参/target 缺失一律抛错,不兜底。 apply 是唯一触碰 SpriteManager 的三行封装。 测试里 attach 外侧右上角一条按 AlignmentUtils.alignCornerTopRight 的真实实现 (角对角重合,非贴外侧)修正了期望值,未在求解器里做偏移补偿迁就猜测值。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// 五型布局求解器(清单 §6.1 / §6.2)
|
||||
const { load, throws } = require('./_load');
|
||||
const t = require('./_assert')();
|
||||
|
||||
load('client/js/gameabc-framework/ui/AlignmentUtils.js');
|
||||
load('client/js/01_SubGame/codes/ui/LayoutSolver.js');
|
||||
|
||||
// ============ point ============
|
||||
const point = { kind: 'point', x: 486, y: 4, w: 276, h: 68 };
|
||||
t.eq('point 返回单元素数组', EQW_LayoutSolver.solve(point, {}), [{ x: 486, y: 4, width: 276, height: 68 }]);
|
||||
|
||||
// ============ line 等距 ============
|
||||
// 底牌区:8 张 110×190,spacing -36,anchor center,基准 (655,110)
|
||||
// totalWidth = 8*110 + 7*(-36) = 880 - 252 = 628;startX = 655 - 314 = 341
|
||||
const line8 = { kind: 'line', direction: 'horizontal', anchorX: 655, anchorY: 110,
|
||||
itemWidth: 110, itemHeight: 190, spacing: -36, anchor: 'center' };
|
||||
const r1 = EQW_LayoutSolver.solve(line8, { count: 8 });
|
||||
t.eq('line 张数', r1.length, 8);
|
||||
t.eq('line 首张 x', r1[0].x, 341);
|
||||
t.eq('line 次张 x', r1[1].x, 341 + 110 - 36);
|
||||
t.eq('line y 恒定', r1[7].y, 110);
|
||||
t.eq('line 带上尺寸', { w: r1[0].width, h: r1[0].height }, { w: 110, h: 190 });
|
||||
|
||||
// ============ line + items 逐项不等宽 ============
|
||||
// 底栏功能钮组:宽 74/87/93/78,spacing 15,anchor right,基准 x=1250
|
||||
// totalWidth = 74+87+93+78 + 3*15 = 332 + 45 = 377;startX = 1250 - 377 = 873
|
||||
const lineItems = { kind: 'line', direction: 'horizontal', anchorX: 1250, anchorY: 672,
|
||||
itemHeight: 30, spacing: 15, anchor: 'right',
|
||||
items: [{ key: 'A', width: 74 }, { key: 'B', width: 87 },
|
||||
{ key: 'C', width: 93 }, { key: 'D', width: 78 }] };
|
||||
const r2 = EQW_LayoutSolver.solve(lineItems, {});
|
||||
t.eq('items 项数(count 由 items 决定)', r2.length, 4);
|
||||
t.eq('items 首项 x', r2[0].x, 873);
|
||||
t.eq('items 第二项 x', r2[1].x, 873 + 74 + 15);
|
||||
t.eq('items 第三项 x', r2[2].x, 873 + 74 + 15 + 87 + 15);
|
||||
t.eq('items 逐项宽度', r2.map(r => r.width), [74, 87, 93, 78]);
|
||||
t.eq('items 末项右边缘贴基准', r2[3].x + r2[3].width, 1250);
|
||||
|
||||
// ============ line 垂直 ============
|
||||
const lineV = { kind: 'line', direction: 'vertical', anchorX: 100, anchorY: 0,
|
||||
itemWidth: 200, itemHeight: 32, spacing: 8, anchor: 'top' };
|
||||
const r3 = EQW_LayoutSolver.solve(lineV, { count: 3 });
|
||||
t.eq('垂直 y 递增', r3.map(r => r.y), [0, 40, 80]);
|
||||
t.eq('垂直 x 恒定', r3.map(r => r.x), [100, 100, 100]);
|
||||
|
||||
// ============ fan 自适应压缩 ============
|
||||
// 手牌单排:maxWidth 1215,itemWidth 110,spacingMin -78,spacingMax 0
|
||||
// 36 张:raw = (1215-110)/35 - 110 = 31.571... - 110 = -78.43 → clamp 到 -78
|
||||
const fan = { kind: 'fan', direction: 'horizontal', anchorX: 640, anchorY: 455,
|
||||
maxWidth: 1215, itemWidth: 110, itemHeight: 190,
|
||||
spacingMax: 0, spacingMin: -78, anchor: 'center', overlapFrom: 'left' };
|
||||
const f36 = EQW_LayoutSolver.solve(fan, { count: 36 });
|
||||
t.eq('fan 36 张间距取下限', f36[1].x - f36[0].x, 110 - 78);
|
||||
|
||||
// 少量牌时 raw 为正 → clamp 到 spacingMax
|
||||
const f2 = EQW_LayoutSolver.solve(fan, { count: 2 });
|
||||
t.eq('fan 2 张间距取上限', f2[1].x - f2[0].x, 110 + 0);
|
||||
|
||||
// count <= 1 的边界
|
||||
const f1 = EQW_LayoutSolver.solve(fan, { count: 1 });
|
||||
t.eq('fan 单张', f1.length, 1);
|
||||
t.eq('fan 单张居中', f1[0].x, 640 - 55);
|
||||
t.eq('fan 零张', EQW_LayoutSolver.solve(fan, { count: 0 }), []);
|
||||
|
||||
// clamp 中间值:raw 落在 [min,max] 内时原样取用
|
||||
// maxWidth 500、itemWidth 100、count 5:raw = (500-100)/4 - 100 = 0 → 落在 [-60, 10] 内
|
||||
const fanMid = { kind: 'fan', direction: 'horizontal', anchorX: 0, anchorY: 0,
|
||||
maxWidth: 500, itemWidth: 100, itemHeight: 100,
|
||||
spacingMax: 10, spacingMin: -60, anchor: 'left' };
|
||||
const fm = EQW_LayoutSolver.solve(fanMid, { count: 5 });
|
||||
t.eq('fan clamp 中间值', fm[1].x - fm[0].x, 100 + 0);
|
||||
|
||||
// ============ grid ============
|
||||
// 叫分 14 档:7 列 2 行,74×56,间距 7/24,anchor center,基准 (640,160)
|
||||
// 行宽 = 7*74 + 6*7 = 518 + 42 = 560;startX = 640 - 280 = 360
|
||||
const grid = { kind: 'grid', anchorX: 640, anchorY: 160, cols: 7, rows: 2,
|
||||
itemWidth: 74, itemHeight: 56, spacingX: 7, spacingY: 24,
|
||||
anchor: 'center', fillOrder: 'row' };
|
||||
const g = EQW_LayoutSolver.solve(grid, { count: 14 });
|
||||
t.eq('grid 项数', g.length, 14);
|
||||
t.eq('grid 首项 x', g[0].x, 360);
|
||||
t.eq('grid 首项 y', g[0].y, 160);
|
||||
t.eq('grid 第2项 x', g[1].x, 360 + 74 + 7);
|
||||
t.eq('grid 第8项换行 x', g[7].x, 360);
|
||||
t.eq('grid 第8项 y', g[7].y, 160 + 56 + 24);
|
||||
|
||||
// count 少于满格时只出 count 个
|
||||
t.eq('grid 不足一行', EQW_LayoutSolver.solve(grid, { count: 3 }).length, 3);
|
||||
|
||||
// ============ attach 内部对齐(corner 为空)============
|
||||
const rects = { TARGET: { x: 100, y: 200, width: 80, height: 40 } };
|
||||
const attachIn = { kind: 'attach', target: 'TARGET', hAlign: 'right', vAlign: 'top',
|
||||
offsetX: 8, offsetY: -4, w: 20, h: 10 };
|
||||
const a1 = EQW_LayoutSolver.solve(attachIn, { rects: rects });
|
||||
t.eq('attach 内部右上', a1, [{ x: 100 + 80 - 20 + 8, y: 200 - 4, width: 20, height: 10 }]);
|
||||
|
||||
// ============ attach 贴外侧角 ============
|
||||
// 注意:AlignmentUtils.alignCornerTopRight(client/js/gameabc-framework/ui/AlignmentUtils.js:292-298)
|
||||
// 的真实语义是【精灵的右上角与目标的右上角重合】,即
|
||||
// x = target.x + target.width - sprite.width + offsetX
|
||||
// y = target.y + offsetY
|
||||
// 这与「贴在目标外侧右上角」(x = target.x + target.width + offsetX, y = target.y - sprite.height + offsetY)
|
||||
// 不是一回事——它是角对角重合(精灵会与目标重叠在右上角内侧),不是贴到外侧。
|
||||
// 期望值以框架实际实现为准:x = 100 + 80 - 20 + 4 = 164,y = 200 + 6 = 206。
|
||||
const attachCorner = { kind: 'attach', target: 'TARGET', corner: 'topRight',
|
||||
offsetX: 4, offsetY: 6, w: 20, h: 10 };
|
||||
const a2 = EQW_LayoutSolver.solve(attachCorner, { rects: rects });
|
||||
t.eq('attach 外侧右上角', a2, [{ x: 100 + 80 - 20 + 4, y: 200 + 6, width: 20, height: 10 }]);
|
||||
|
||||
// 四个角都要能解
|
||||
['topLeft', 'topRight', 'bottomLeft', 'bottomRight'].forEach(c => {
|
||||
const r = EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: c, w: 20, h: 10 }, { rects: rects });
|
||||
t.eq('attach 角 ' + c + ' 可解', r.length === 1 && typeof r[0].x === 'number', true);
|
||||
});
|
||||
|
||||
// ============ bySeat 合并:列出的覆盖、未列出的继承 ============
|
||||
const bySeat = {
|
||||
kind: 'fan', direction: 'horizontal', itemWidth: 90, itemHeight: 155,
|
||||
maxWidth: 170, spacingMax: -35, spacingMin: -62,
|
||||
bySeat: {
|
||||
SELF: { anchorX: 650, anchorY: 265, anchor: 'center', overlapFrom: 'left' },
|
||||
RIGHT: { anchorX: 1022, anchorY: 120, anchor: 'center', overlapFrom: 'left' },
|
||||
LEFT: { anchorX: 258, anchorY: 120, anchor: 'center', overlapFrom: 'right' }
|
||||
}
|
||||
};
|
||||
const sSelf = EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 1 });
|
||||
const sLeft = EQW_LayoutSolver.solve(bySeat, { seat: 'LEFT', count: 1 });
|
||||
t.eq('bySeat SELF 用自己的锚点', sSelf[0].x, 650 - 45);
|
||||
t.eq('bySeat LEFT 用自己的锚点', sLeft[0].x, 258 - 45);
|
||||
t.eq('bySeat 继承 base 的尺寸', sLeft[0].width, 90);
|
||||
|
||||
// ============ 显式失败:不兜底 ============
|
||||
t.eq('未知 kind 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'blob' }, {})), true);
|
||||
t.eq('attach target 缺失抛错', throws(() => EQW_LayoutSolver.solve(attachIn, { rects: {} })), true);
|
||||
t.eq('attach 未给 rects 抛错', throws(() => EQW_LayoutSolver.solve(attachIn, {})), true);
|
||||
t.eq('bySeat 缺 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { count: 1 })), true);
|
||||
t.eq('bySeat 未知 seat 抛错', throws(() => EQW_LayoutSolver.solve(bySeat, { seat: 'TOP', count: 1 })), true);
|
||||
t.eq('node 为空抛错', throws(() => EQW_LayoutSolver.solve(null, {})), true);
|
||||
t.eq('未知 corner 抛错', throws(() => EQW_LayoutSolver.solve({ kind: 'attach', target: 'TARGET', corner: 'middle', w: 1, h: 1 }, { rects: rects })), true);
|
||||
|
||||
// ============ 纯函数:不修改入参 ============
|
||||
const before = JSON.stringify(bySeat);
|
||||
EQW_LayoutSolver.solve(bySeat, { seat: 'SELF', count: 3 });
|
||||
t.eq('solve 不修改配置对象', JSON.stringify(bySeat), before);
|
||||
|
||||
process.exit(t.done('layoutsolver') ? 0 : 1);
|
||||
Reference in New Issue
Block a user