/////////////////////////////////////////////////////////////// ////////// 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; } };