diff --git a/client/js/01_SubGame/codes/ui/LayoutSolver.js b/client/js/01_SubGame/codes/ui/LayoutSolver.js new file mode 100644 index 0000000..1dbdeff --- /dev/null +++ b/client/js/01_SubGame/codes/ui/LayoutSolver.js @@ -0,0 +1,196 @@ +/////////////////////////////////////////////////////////////// +////////// 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。 +// +// 参数名一律沿用框架 AlignmentUtils 的入参名(清单 §6.0),配置可原样喂入。 +// 精灵锚点恒在左上角,配置里的 anchorX/anchorY 是【对齐基准点】,不是精灵左上角。 +// 出错一律抛异常,不返回 {x:0,y:0} 之类的兜底值(工程总则 §7 显式失败)。 +var EQW_LayoutSolver = EQW_LayoutSolver || { + + //——— 对外:求解 ——— + solve: function (node, ctx) { + if (!node) { throw new Error('[EQW_LayoutSolver] node 为空'); } + ctx = ctx || {}; + var n = this._mergeBySeat(node, ctx.seat); + + 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 之处)——— + apply: function (node, spriteIds, ctx) { + var rects = this.solve(node, ctx); + for (var i = 0; i < spriteIds.length && i < rects.length; i++) { + SpriteManager.setPosition(spriteIds[i], rects[i].x, rects[i].y); + } + return rects; + }, + + //——— 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; + }, + + _solvePoint: function (n) { + return [{ x: n.x, y: n.y, width: n.w, height: n.h }]; + }, + + //line:等距排列。带 items 时逐项取宽(itemWidth 失效),此时不能走 distribute(它假设等宽) + _solveLine: function (n, ctx) { + 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) + _solveLineItems: function (n) { + 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) { + 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) { + var count = (typeof ctx.count === 'number') ? ctx.count : (n.cols * n.rows); + 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 为空 = 在目标【内部】对齐;有值 = 与目标对应角重合 + _solveAttach: function (n, ctx) { + if (!ctx.rects) { throw new Error('[EQW_LayoutSolver] attach 需要 ctx.rects'); } + var target = ctx.rects[n.target]; + if (!target) { throw new Error('[EQW_LayoutSolver] attach 的 target 未在 ctx.rects 中: ' + n.target); } + + var sprite = { width: n.w, height: n.h }; + var offset = { x: n.offsetX || 0, y: n.offsetY || 0 }; + var pos; + + if (n.corner) { + switch (n.corner) { + case 'topLeft': pos = AlignmentUtils.alignCornerTopLeft(target, sprite, offset); break; + case 'topRight': pos = AlignmentUtils.alignCornerTopRight(target, sprite, offset); break; + case 'bottomLeft': pos = AlignmentUtils.alignCornerBottomLeft(target, sprite, offset); break; + case 'bottomRight': pos = AlignmentUtils.alignCornerBottomRight(target, sprite, offset); break; + default: throw new Error('[EQW_LayoutSolver] 未知 corner: ' + n.corner); + } + } else { + pos = AlignmentUtils.alignTo(target, sprite, n.hAlign, n.vAlign, offset); + } + + return [{ x: pos.x, y: pos.y, width: n.w, height: n.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; + } +}; diff --git a/client/tests/test_layoutsolver.js b/client/tests/test_layoutsolver.js new file mode 100644 index 0000000..f998c9a --- /dev/null +++ b/client/tests/test_layoutsolver.js @@ -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);