From bc75e40acc5889be7b1ec91d1b12436ea3094f6b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Wed, 26 Aug 2026 23:52:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=8C=E4=B8=83=E7=8E=8B=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=9B=BE=E9=9B=86=E5=8F=A0=E7=BB=98=E6=95=B0=E5=AD=97?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=88SpriteManager=20=E6=BA=90=E7=9F=A9?= =?UTF-8?q?=E5=BD=A2=20API=20+=20NumberRenderer=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多帧图片精灵一次只显示一帧=只能显示一位数,两位数就得两个精灵, 14 个叫分档位(13 档两位数)每档只配一个精灵时根本渲染不出来。 - SpriteManager.drawImageRegion:把 GameABCUtils.Draw.drawImage 的源矩形 能力暴露到业务层(drawImage 只透传了整图版),校验/日志/返回值风格照现有 drawImage 一致;注明必须在精灵绘制回调中调用。 - gameabc-framework/ui/NumberRenderer.js:游戏中立,bind/setValue/clear/unbind, 绘制回调里按字宽逐位裁源矩形画在同一个精灵上;layout 抽为纯函数可脱离引擎单测; 字符映射不到显式抛错,不静默跳过。 - EQW_Layout.NUM_STYLE:5 套数字的字宽/字高/字间距/对齐/绘制区宽集中配置(零裸值), 资源只存键名(与 CARD_SIZE.res 同一约定)。 - client/tests/test_numberrenderer.js:43 checks(多位/单位/正负号/三种对齐/负字间距/ 空值/自定义 charMap/非法字符与非法样式抛错/5 套真实样式画得下)。 Co-Authored-By: Claude Opus 5 (1M context) --- client/index.html | 2 + .../codes/config/LayoutConstants.js | 28 ++ .../gameabc-framework/core/SpriteManager.js | 78 +++- .../js/gameabc-framework/ui/NumberRenderer.js | 336 ++++++++++++++++++ client/tests/test_numberrenderer.js | 159 +++++++++ 5 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 client/js/gameabc-framework/ui/NumberRenderer.js create mode 100644 client/tests/test_numberrenderer.js diff --git a/client/index.html b/client/index.html index c7c9747..c5c1ac2 100644 --- a/client/index.html +++ b/client/index.html @@ -191,6 +191,8 @@ + + diff --git a/client/js/01_SubGame/codes/config/LayoutConstants.js b/client/js/01_SubGame/codes/config/LayoutConstants.js index f15f3e3..af3710f 100644 --- a/client/js/01_SubGame/codes/config/LayoutConstants.js +++ b/client/js/01_SubGame/codes/config/LayoutConstants.js @@ -31,3 +31,31 @@ EQW_Layout.TEXT_STYLE = { ROOM_CAT: { fontSize: 18, color: '#7FBFB0', bold: true, align: 'right', maxWidth: 60 }, ROOM_CARD_COST_NOTE: { fontSize: 16, color: '#999999' } //建房房卡消耗附注(§6.9b) }; + +//数字样式预设(清单 §3.6 / §6.8,2026-08-26 新增)——图集叠绘数字用,一个精灵画多位美术字。 +//每套对应一张【等宽连续排列】的数字图:字符横向一行排开,第 idx 格的源矩形 = (idx*charWidth, 0, charWidth, charHeight)。 +//渲染由框架的 NumberRenderer 完成(NumberRenderer.bind(spriteId, style) → setValue); +//本表是【零裸值红线】的落点:charWidth/charHeight/spacing/align/width 只在此定义,业务代码只引用不内联。 +// +//字段:res —— 图片资源【键名】(与 CARD_SIZE.res 同一约定:纯数据文件不直接引用 EQW_Images, +// 调用方用 EQW_Images[style.res] 取真实 ID) +// charWidth/charHeight —— 图集单字符宽高(= 源矩形尺寸,也是原样绘制的目标尺寸) +// spacing —— 字间距(可为负=字符重叠) +// align —— 在绘制区内的水平对齐:'left'|'center'|'right' +// width —— 绘制区宽度(= 承载精灵的宽度;各 Layout_*.js 的对应节点直接引用本值,保持 SSOT) +EQW_Layout.NUM_STYLE = { + //倒计时(§2.4):最多两位(秒) + COUNTDOWN: { res: 'NUM_COUNTDOWN', charWidth: 44, charHeight: 60, spacing: 0, align: 'center', width: 88 }, + + //结算得分 · 赢(§1.8/§6.9):带符号,最多「符号 + 四位」 + RESULT_WIN: { res: 'NUM_RESULT_WIN', charWidth: 46, charHeight: 62, spacing: 0, align: 'center', width: 230 }, + + //结算得分 · 输(§1.8/§6.9):帧序与赢的一致,只是配色不同 + RESULT_LOSE: { res: 'NUM_RESULT_LOSE', charWidth: 46, charHeight: 62, spacing: 0, align: 'center', width: 230 }, + + //叫分档位分数(§1.3/§6.8):14 档中 13 档是两位数;绘制区宽 = 档位按钮宽 74,居中压在按钮上 + CALL_SCORE: { res: 'NUM_CALL_SCORE', charWidth: 30, charHeight: 40, spacing: 0, align: 'center', width: 74 }, + + //选主面板花色张数(§1.5/§6.8):两副牌单花色最多 26 张,最多两位 + MAIN_SUIT_COUNT: { res: 'NUM_MAIN_SUIT_COUNT', charWidth: 24, charHeight: 32, spacing: 0, align: 'center', width: 48 } +}; diff --git a/client/js/gameabc-framework/core/SpriteManager.js b/client/js/gameabc-framework/core/SpriteManager.js index 4ed4188..c5854fc 100644 --- a/client/js/gameabc-framework/core/SpriteManager.js +++ b/client/js/gameabc-framework/core/SpriteManager.js @@ -717,7 +717,83 @@ var SpriteManager = (function() { return false; } }, - + + /** + * 在精灵上绘制图片的【指定源矩形】(图集裁切绘制) + * + * 与 drawImage 的区别: + * - drawImage = 整图绘制,源矩形固定为图片资源的完整尺寸,一次只能画“一张完整的图” + * - drawImageRegion = 可指定源矩形 (srcX, srcY, srcW, srcH),从一张图里裁出任意一格再画 + * + * 典型用途: + * - 从等宽排列的图集里裁出第 N 格画上去(如把多位数字逐位画在【同一个】精灵上, + * 见 gameabc-framework/ui/NumberRenderer.js) + * - 九宫格拉伸、进度条按比例裁切、图集小图标叠绘 + * + * ⚠️ 前提:必须在【精灵的绘制回调中】调用(SpriteEventController.registerDraw 注册的 + * handler 里,或 registerGlobalDraw 的全局钩子里)。引擎的叠绘只在该精灵绘制的那一 + * 帧生效,在回调之外调用不会留下任何画面。 + * + * @param {Number} spriteId - 目标精灵ID(在哪个精灵上绘制) + * @param {Number} imageId - 要绘制的图片资源ID + * @param {Number} destX - 目标X坐标(在精灵内的相对坐标) + * @param {Number} destY - 目标Y坐标(在精灵内的相对坐标) + * @param {Number} destWidth - 目标绘制宽度 + * @param {Number} destHeight - 目标绘制高度 + * @param {Number} srcX - 源图片截取区域的X坐标 + * @param {Number} srcY - 源图片截取区域的Y坐标 + * @param {Number} srcWidth - 源图片截取区域的宽度 + * @param {Number} srcHeight - 源图片截取区域的高度 + * @returns {Boolean} 是否成功 + * + * @example + * // 数字图集单字宽 30、高 40,'7' 是第 8 格(索引 7): + * // 把它画到精灵 1617 内的 (0, 0) 处,原样大小 + * SpriteManager.drawImageRegion(1617, 635, 0, 0, 30, 40, 7 * 30, 0, 30, 40); + */ + drawImageRegion: function(spriteId, imageId, destX, destY, destWidth, destHeight, + srcX, srcY, srcWidth, srcHeight) { + if (!_validateSpriteId(spriteId)) return false; + + // 参数验证 + if (typeof imageId !== 'number' || imageId <= 0) { + console.error('❌ SpriteManager.drawImageRegion: 图片资源ID必须是正数:', imageId); + return false; + } + + if (typeof destX !== 'number' || typeof destY !== 'number') { + console.error('❌ SpriteManager.drawImageRegion: 目标坐标必须是数字'); + return false; + } + + if (typeof destWidth !== 'number' || destWidth <= 0 || + typeof destHeight !== 'number' || destHeight <= 0) { + console.error('❌ SpriteManager.drawImageRegion: 目标宽度和高度必须是正数'); + return false; + } + + if (typeof srcX !== 'number' || typeof srcY !== 'number') { + console.error('❌ SpriteManager.drawImageRegion: 源矩形坐标必须是数字'); + return false; + } + + if (typeof srcWidth !== 'number' || srcWidth <= 0 || + typeof srcHeight !== 'number' || srcHeight <= 0) { + console.error('❌ SpriteManager.drawImageRegion: 源矩形宽度和高度必须是正数'); + return false; + } + + try { + // 调用 GameABCUtils.Draw.drawImage(带源矩形的完整版绘图接口) + GameABCUtils.Draw.drawImage(spriteId, imageId, destX, destY, destWidth, destHeight, + srcX, srcY, srcWidth, srcHeight); + return true; + } catch (error) { + console.error('❌ SpriteManager.drawImageRegion 失败:', error); + return false; + } + }, + // ==================== 交互控制 ==================== /** diff --git a/client/js/gameabc-framework/ui/NumberRenderer.js b/client/js/gameabc-framework/ui/NumberRenderer.js new file mode 100644 index 0000000..b98bf3c --- /dev/null +++ b/client/js/gameabc-framework/ui/NumberRenderer.js @@ -0,0 +1,336 @@ +// ============================================================================ +// NumberRenderer (gameabc-framework / ui, 严格 ES5) +// 图集叠绘式数字渲染器——把一个【多位数值】画在【一个】精灵上。游戏中立。 +// ============================================================================ + +/** + * NumberRenderer —— 一个精灵画多位美术字数字 + * + * ── 解决什么问题 ──────────────────────────────────────────────────────── + * 带描边/渐变的美术字数字(倒计时、结算得分、叫分档位分数、张数…)文字精灵做不出, + * 只能用图。但「多帧图片精灵 + SpriteManager.setFrame」一个精灵一次只显示【一帧】, + * 也就是【一位】数字——两位数就要两个精灵(十位/个位),精灵数迅速膨胀。 + * + * 本模块改用【图集叠绘】:在精灵的绘制回调里,按字宽从数字图集里逐位裁源矩形, + * 逐位画到【同一个】精灵内的不同 x 上,于是一个精灵就能显示任意位数。 + * + * ── 三种显示机制的选用(详见 docs/client/development-guide/02 §2)────────── + * 文字精灵 SpriteManager.setText 任意长度,但只能用系统字体 + * 多帧图片精灵 SpriteManager.setFrame 一次一帧,适合「状态切换」(按钮态/花色/牌面) + * 图集叠绘 NumberRenderer 一个精灵画多位美术字 ← 本模块 + * + * ── 用法 ──────────────────────────────────────────────────────────────── + * // 1) 绑定:记住样式 + 注册绘制回调(样式由【调用方】即子游戏提供,本模块不认识业务) + * NumberRenderer.bind(spriteId, { + * imageId: 635, // 数字图集的图片资源 ID + * charWidth: 30, // 图集里单个字符的宽 + * charHeight: 40, // 图集里单个字符的高 + * spacing: 0, // 字间距(可为负 = 字符重叠) + * align: 'center', // 'left' | 'center' | 'right' + * width: 74 // 绘制区宽度(= 精灵内可用宽度,由调用方按布局给) + * // charMap: {...} // 可选:整体覆盖默认的「字符 → 图集索引」映射 + * }); + * + * // 2) 写值(只写数据、不画;引擎每帧回调绘制handler,自然就画出来了) + * NumberRenderer.setValue(spriteId, 70); + * + * // 3) 清空 / 解绑 + * NumberRenderer.clear(spriteId); // 不显示任何字符(精灵本身仍在) + * NumberRenderer.unbind(spriteId); // 注销绘制回调 + 清除状态(组件 onDestroy 里调) + * + * ── 图集规格(默认映射)──────────────────────────────────────────────── + * 图集是【等宽连续排列的一行】,第 idx 格的源矩形 = (idx * charWidth, 0, charWidth, charHeight): + * 索引 0–9 → 字符 '0'–'9' + * 索引 10 → '+' (有些数字图带正负号) + * 索引 11 → '-' + * 图集若不是这个排列(多了小数点、`分` 等),用 style.charMap 整体覆盖。 + * 映射不到的字符【显式抛错】,不静默跳过(engineering 总则 7:显式失败优于隐式兜底)。 + * + * ── 依赖与边界 ────────────────────────────────────────────────────────── + * - 只调 SpriteManager.drawImageRegion 与 SpriteEventController,不碰 GameABCUtils / 引擎原语。 + * - 游戏中立:不含任何具体玩法的资源 ID、常量或概念,样式全部由调用方传入。 + * - 只支持【数字 ID 的预置精灵】:SpriteEventController.registerDraw 要求 spriteId 是数字, + * SpriteCopyUtils 复制出的字符串 ID 精灵不接收独立绘制事件,不能用本模块。 + */ +var NumberRenderer = { + + /** + * 默认「字符 → 图集索引」映射:等宽连续排列的一行,0–9 后接 + 与 - + * @type {Object} + */ + DEFAULT_CHAR_MAP: { + '0': 0, '1': 1, '2': 2, '3': 3, '4': 4, + '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, + '+': 10, + '-': 11 + }, + + /** + * 合法的对齐方式 + * @type {Array} + */ + ALIGNS: ['left', 'center', 'right'], + + /** + * 已绑定精灵的状态表 { spriteId: { style: {...}, value: String|null, items: Array } } + * items 是 layout() 的结果,在 setValue 时算好、绘制回调里直接重放 + * @type {Object} + */ + _bound: {}, + + // ======================================================================== + // 纯函数:布局计算(不碰引擎、可脱离运行时单测) + // ======================================================================== + + /** + * 校验样式对象;不合法直接抛错(显式失败) + * @param {Object} style - 样式 + * @returns {Object} 归一化后的样式副本 + */ + normalizeStyle: function (style) { + if (!style || typeof style !== 'object') { + throw new Error('NumberRenderer: style 必须是对象'); + } + if (typeof style.imageId !== 'number' || style.imageId <= 0) { + throw new Error('NumberRenderer: style.imageId 必须是正数,收到 ' + style.imageId); + } + if (typeof style.charWidth !== 'number' || style.charWidth <= 0) { + throw new Error('NumberRenderer: style.charWidth 必须是正数,收到 ' + style.charWidth); + } + if (typeof style.charHeight !== 'number' || style.charHeight <= 0) { + throw new Error('NumberRenderer: style.charHeight 必须是正数,收到 ' + style.charHeight); + } + if (typeof style.width !== 'number' || style.width <= 0) { + throw new Error('NumberRenderer: style.width(绘制区宽度)必须是正数,收到 ' + style.width); + } + if (typeof style.spacing !== 'undefined' && typeof style.spacing !== 'number') { + throw new Error('NumberRenderer: style.spacing 必须是数字(可为负),收到 ' + style.spacing); + } + if (this.ALIGNS.indexOf(style.align) < 0) { + throw new Error('NumberRenderer: style.align 必须是 left/center/right 之一,收到 ' + style.align); + } + if (typeof style.charMap !== 'undefined' && + (!style.charMap || typeof style.charMap !== 'object')) { + throw new Error('NumberRenderer: style.charMap 必须是对象'); + } + + return { + imageId: style.imageId, + charWidth: style.charWidth, + charHeight: style.charHeight, + spacing: (typeof style.spacing === 'number') ? style.spacing : 0, + align: style.align, + width: style.width, + charMap: style.charMap || this.DEFAULT_CHAR_MAP + }; + }, + + /** + * 计算一个数值在精灵内的逐字符绘制矩形(纯函数,无副作用) + * + * 第 idx 个字符(idx = charMap[字符])的源矩形固定为 + * (idx * charWidth, 0, charWidth, charHeight) + * 起始 x 由 style.width 与总宽 n*charWidth + (n-1)*spacing 按 align 算出。 + * 【不读精灵实际尺寸】——宽度一律由 style 给,保证本函数可脱离引擎单测。 + * + * @param {String|Number|null} value - 要显示的值(null/undefined/'' = 不显示任何字符) + * @param {Object} style - 样式(字段见文件头;本函数内部会先 normalizeStyle) + * @returns {Array} [{ char, index, srcX, srcY, srcW, srcH, destX, destY, destW, destH }, ...] + * @throws {Error} 样式非法、或字符映射不到图集索引 + * + * @example + * NumberRenderer.layout(70, { imageId: 635, charWidth: 30, charHeight: 40, + * spacing: 0, align: 'left', width: 74 }); + * // → [ {char:'7', index:7, srcX:210, ..., destX: 0, ...}, + * // {char:'0', index:0, srcX:0, ..., destX:30, ...} ] + */ + layout: function (value, style) { + var s = this.normalizeStyle(style); + + if (value === null || typeof value === 'undefined') { return []; } + var text = String(value); + if (text.length === 0) { return []; } + + var n = text.length; + var totalWidth = n * s.charWidth + (n - 1) * s.spacing; + + var startX; + if (s.align === 'left') { + startX = 0; + } else if (s.align === 'right') { + startX = s.width - totalWidth; + } else { + startX = (s.width - totalWidth) / 2; + } + + var items = []; + for (var i = 0; i < n; i++) { + var ch = text.charAt(i); + if (!Object.prototype.hasOwnProperty.call(s.charMap, ch)) { + throw new Error('NumberRenderer: 字符 "' + ch + '" 不在 charMap 里(值 "' + text + + '")——图集没有这一格,请补映射或改用文字精灵'); + } + var idx = s.charMap[ch]; + if (typeof idx !== 'number' || idx < 0) { + throw new Error('NumberRenderer: 字符 "' + ch + '" 映射到非法索引 ' + idx); + } + items.push({ + char: ch, + index: idx, + srcX: idx * s.charWidth, + srcY: 0, + srcW: s.charWidth, + srcH: s.charHeight, + destX: startX + i * (s.charWidth + s.spacing), + destY: 0, + destW: s.charWidth, + destH: s.charHeight + }); + } + return items; + }, + + // ======================================================================== + // 绑定 / 写值 / 清空 / 解绑 + // ======================================================================== + + /** + * 绑定精灵:记下样式并注册绘制回调 + * + * 重复 bind 同一个精灵 = 换样式(回调只注册一次,值被重置为空)。 + * + * @param {Number} spriteId - 精灵ID(必须是编辑器预置的数字 ID) + * @param {Object} style - 样式(字段见文件头) + * @returns {Boolean} 是否成功 + * @throws {Error} 样式非法(显式失败,不静默返回 false) + */ + bind: function (spriteId, style) { + if (typeof spriteId !== 'number') { + throw new Error('NumberRenderer.bind: spriteId 必须是数字(复制精灵不支持叠绘)'); + } + if (typeof SpriteEventController === 'undefined') { + console.error('❌ NumberRenderer.bind: 缺少依赖 SpriteEventController'); + return false; + } + + var normalized = this.normalizeStyle(style); + this._bound[spriteId] = { style: normalized, value: null, items: [] }; + + var self = this; + return SpriteEventController.registerDraw(spriteId, function (e) { + self._draw(e.spriteId); + }); + }, + + /** + * 写值(只写数据,不画;实际绘制由引擎每帧回调完成) + * + * 逐字符矩形在此刻就算好并缓存:一是让「字符映射不到」这类错误在【调用处】当场抛出, + * 而不是每帧在绘制回调里刷屏;二是避免每帧重复计算与分配。 + * + * @param {Number} spriteId - 精灵ID(须先 bind) + * @param {String|Number} value - 要显示的值 + * @returns {Boolean} 是否成功 + * @throws {Error} 未绑定、或值含图集里没有的字符 + */ + setValue: function (spriteId, value) { + var state = this._bound[spriteId]; + if (!state) { + throw new Error('NumberRenderer.setValue: 精灵 ' + spriteId + ' 尚未 bind'); + } + state.items = this.layout(value, state.style); + state.value = (value === null || typeof value === 'undefined') ? null : String(value); + return true; + }, + + /** + * 清空:不显示任何字符(精灵本身与绑定都还在) + * @param {Number} spriteId - 精灵ID(须先 bind) + * @returns {Boolean} 是否成功 + * @throws {Error} 未绑定 + */ + clear: function (spriteId) { + var state = this._bound[spriteId]; + if (!state) { + throw new Error('NumberRenderer.clear: 精灵 ' + spriteId + ' 尚未 bind'); + } + state.value = null; + state.items = []; + return true; + }, + + /** + * 解绑:注销绘制回调并清除状态(精灵销毁/组件 onDestroy 时必须调,防回调残留) + * @param {Number} spriteId - 精灵ID + * @returns {Boolean} 是否成功 + */ + unbind: function (spriteId) { + if (typeof spriteId !== 'number') { + console.error('❌ NumberRenderer.unbind: spriteId 必须是数字:', spriteId); + return false; + } + delete this._bound[spriteId]; + + if (typeof SpriteEventController === 'undefined') { + console.error('❌ NumberRenderer.unbind: 缺少依赖 SpriteEventController'); + return false; + } + return SpriteEventController.unregister(spriteId, 'draw'); + }, + + /** + * 取当前值(调试/自检用) + * @param {Number} spriteId - 精灵ID + * @returns {String|null} 当前值;未绑定返回 null + */ + getValue: function (spriteId) { + var state = this._bound[spriteId]; + return state ? state.value : null; + }, + + /** + * 是否已绑定 + * @param {Number} spriteId - 精灵ID + * @returns {Boolean} + */ + isBound: function (spriteId) { + return Object.prototype.hasOwnProperty.call(this._bound, spriteId); + }, + + // ======================================================================== + // 内部:绘制回调(由引擎每帧触发) + // ======================================================================== + + /** + * 绘制回调实现:把缓存的逐字符矩形画到精灵上 + * @param {Number} spriteId - 精灵ID + * @private + */ + _draw: function (spriteId) { + var state = this._bound[spriteId]; + if (!state || state.items.length === 0) { return; } + + if (typeof SpriteManager === 'undefined' || !SpriteManager.drawImageRegion) { + console.error('❌ NumberRenderer: 缺少依赖 SpriteManager.drawImageRegion'); + return; + } + + var imageId = state.style.imageId; + for (var i = 0; i < state.items.length; i++) { + var it = state.items[i]; + SpriteManager.drawImageRegion(spriteId, imageId, + it.destX, it.destY, it.destW, it.destH, + it.srcX, it.srcY, it.srcW, it.srcH); + } + } +}; + +// ============================================================================ +// 导出 +// ============================================================================ + +if (typeof module !== 'undefined' && module.exports) { + module.exports = NumberRenderer; +} else if (typeof window !== 'undefined') { + window.NumberRenderer = NumberRenderer; +} diff --git a/client/tests/test_numberrenderer.js b/client/tests/test_numberrenderer.js new file mode 100644 index 0000000..5d2bd63 --- /dev/null +++ b/client/tests/test_numberrenderer.js @@ -0,0 +1,159 @@ +// NumberRenderer.layout:图集叠绘的逐字符矩形计算(纯函数,唯一可脱离引擎单测的部分) +const { load, throws } = require('./_load'); +const t = require('./_assert')(); + +load('client/js/gameabc-framework/ui/NumberRenderer.js'); + +// 基准样式:单字 30×40、无间距、绘制区宽 100 +function style(over) { + const s = { imageId: 635, charWidth: 30, charHeight: 40, spacing: 0, align: 'left', width: 100 }; + Object.keys(over || {}).forEach(k => { s[k] = over[k]; }); + return s; +} +// 只取关心的字段,断言更可读 +const brief = items => items.map(i => ({ c: i.char, srcX: i.srcX, destX: i.destX })); + +// ---- 正面:多位数逐位裁源矩形,第 idx 格 = (idx*charWidth, 0, charWidth, charHeight) ---- +const two = NumberRenderer.layout(70, style()); +t.eq('两位数:字符数', two.length, 2); +t.eq('两位数:逐位源/目标 x', brief(two), [ + { c: '7', srcX: 210, destX: 0 }, + { c: '0', srcX: 0, destX: 30 } +]); +t.eq('两位数:源矩形完整字段', two[0], { + char: '7', index: 7, srcX: 210, srcY: 0, srcW: 30, srcH: 40, + destX: 0, destY: 0, destW: 30, destH: 40 +}); + +// ---- 正面:单位数 ---- +t.eq('单位数', brief(NumberRenderer.layout(5, style())), [{ c: '5', srcX: 150, destX: 0 }]); + +// ---- 正面:字符串入参与数字入参等价 ---- +t.eq('字符串入参等价', brief(NumberRenderer.layout('70', style())), brief(two)); + +// ---- 正面:四位数(结算得分级别) ---- +t.eq('四位数', brief(NumberRenderer.layout(1234, style({ width: 200 }))), [ + { c: '1', srcX: 30, destX: 0 }, + { c: '2', srcX: 60, destX: 30 }, + { c: '3', srcX: 90, destX: 60 }, + { c: '4', srcX: 120, destX: 90 } +]); + +// ---- 正面:负号 → 默认映射索引 11;正号 → 10 ---- +t.eq('负号', brief(NumberRenderer.layout('-5', style())), [ + { c: '-', srcX: 330, destX: 0 }, + { c: '5', srcX: 150, destX: 30 } +]); +t.eq('正号', brief(NumberRenderer.layout('+18', style())), [ + { c: '+', srcX: 300, destX: 0 }, + { c: '1', srcX: 30, destX: 30 }, + { c: '8', srcX: 240, destX: 60 } +]); + +// ---- 正面:三种对齐(总宽 = 2*30 + 1*0 = 60,绘制区宽 100)---- +t.eq('align left 起始 x', NumberRenderer.layout(70, style({ align: 'left' }))[0].destX, 0); +t.eq('align center 起始 x', NumberRenderer.layout(70, style({ align: 'center' }))[0].destX, 20); +t.eq('align right 起始 x', NumberRenderer.layout(70, style({ align: 'right' }))[0].destX, 40); + +// ---- 边界:绘制区恰好等于总宽时,三种对齐结果一致 ---- +t.eq('绘制区等于总宽时 center 起始 x', NumberRenderer.layout(70, style({ align: 'center', width: 60 }))[0].destX, 0); +t.eq('绘制区等于总宽时 right 起始 x', NumberRenderer.layout(70, style({ align: 'right', width: 60 }))[0].destX, 0); + +// ---- 边界:内容超出绘制区时不裁剪,center/right 起始 x 为负(由调用方自行调宽)---- +t.eq('超宽 center 起始 x 为负', NumberRenderer.layout(1234, style({ align: 'center' }))[0].destX, -10); + +// ---- 边界:字间距为负(字符重叠)---- +t.eq('负字间距(重叠 6px)', brief(NumberRenderer.layout(123, style({ spacing: -6 }))), [ + { c: '1', srcX: 30, destX: 0 }, + { c: '2', srcX: 60, destX: 24 }, + { c: '3', srcX: 90, destX: 48 } +]); +// 负间距下总宽 = 3*30 + 2*(-6) = 78,居中起始 x = (100-78)/2 = 11 +t.eq('负字间距 + center', NumberRenderer.layout(123, style({ spacing: -6, align: 'center' }))[0].destX, 11); + +// ---- 边界:正字间距 ---- +t.eq('正字间距 4px', brief(NumberRenderer.layout(12, style({ spacing: 4 }))), [ + { c: '1', srcX: 30, destX: 0 }, + { c: '2', srcX: 60, destX: 34 } +]); + +// ---- 边界:空值不产生任何字符 ---- +t.eq('null 值', NumberRenderer.layout(null, style()), []); +t.eq('undefined 值', NumberRenderer.layout(undefined, style()), []); +t.eq('空串', NumberRenderer.layout('', style()), []); +t.eq('数值 0 照常显示(不被当空值)', brief(NumberRenderer.layout(0, style())), [{ c: '0', srcX: 0, destX: 0 }]); + +// ---- 正面:charMap 可整体覆盖默认映射(图集排列不同的数字图)---- +const zhMap = { '0': 0, '1': 1, '2': 2, '张': 3 }; +t.eq('自定义 charMap', brief(NumberRenderer.layout('12张', style({ charMap: zhMap }))), [ + { c: '1', srcX: 30, destX: 0 }, + { c: '2', srcX: 60, destX: 30 }, + { c: '张', srcX: 90, destX: 60 } +]); +// 覆盖是整体替换:默认的 '-' 在自定义表里没有 → 抛错,不回退到默认表 +t.eq('charMap 是整体覆盖而非合并', throws(() => NumberRenderer.layout('-1', style({ charMap: zhMap }))), true); + +// ---- 反面:映射不到的字符必须显式抛错,不静默跳过 ---- +t.eq('字母抛错', throws(() => NumberRenderer.layout('7A', style())), true); +t.eq('小数点抛错(默认图集没有这一格)', throws(() => NumberRenderer.layout('1.5', style())), true); +t.eq('中文抛错', throws(() => NumberRenderer.layout('5分', style())), true); +t.eq('空格抛错', throws(() => NumberRenderer.layout('7 0', style())), true); +// 抛错而不是返回残缺结果:错误信息要点名是哪个字符 +let msg = ''; +try { NumberRenderer.layout('7A', style()); } catch (e) { msg = e.message; } +t.eq('错误信息含出问题的字符', msg.indexOf('"A"') >= 0, true); + +// ---- 反面:样式非法一律抛错(显式失败优于隐式兜底)---- +t.eq('style 缺失抛错', throws(() => NumberRenderer.layout(70, null)), true); +t.eq('imageId 非正数抛错', throws(() => NumberRenderer.layout(70, style({ imageId: 0 }))), true); +t.eq('charWidth 非正数抛错', throws(() => NumberRenderer.layout(70, style({ charWidth: 0 }))), true); +t.eq('charHeight 缺失抛错', throws(() => NumberRenderer.layout(70, style({ charHeight: undefined }))), true); +t.eq('width 缺失抛错', throws(() => NumberRenderer.layout(70, style({ width: undefined }))), true); +t.eq('align 非法抛错', throws(() => NumberRenderer.layout(70, style({ align: 'middle' }))), true); +t.eq('align 缺失抛错', throws(() => NumberRenderer.layout(70, style({ align: undefined }))), true); +t.eq('spacing 非数字抛错', throws(() => NumberRenderer.layout(70, style({ spacing: '0' }))), true); +t.eq('charMap 非对象抛错', throws(() => NumberRenderer.layout(70, style({ charMap: 'x' }))), true); +// spacing 省略时按 0 处理(唯一的可选项) +t.eq('spacing 省略 = 0', NumberRenderer.layout(70, style({ spacing: undefined }))[1].destX, 30); + +// ---- 默认映射本身:0–9 连续,+/- 接在后面 ---- +t.eq('默认映射 0-9 连续', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => NumberRenderer.DEFAULT_CHAR_MAP[String(n)]), + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); +t.eq('默认映射 + 为 10', NumberRenderer.DEFAULT_CHAR_MAP['+'], 10); +t.eq('默认映射 - 为 11', NumberRenderer.DEFAULT_CHAR_MAP['-'], 11); + +// ---- 子游戏的 5 套数字样式都能通过校验,且各自的典型值画得下 ---- +load('client/js/01_SubGame/codes/config/ImageResources.js'); +load('client/js/01_SubGame/codes/config/LayoutConstants.js'); + +const CASES = { + COUNTDOWN: '15', // 倒计时秒数,最多两位 + RESULT_WIN: '+1234', // 带符号四位 + RESULT_LOSE: '-1234', + CALL_SCORE: '70', // 叫分档位最大值 + MAIN_SUIT_COUNT: '26' // 两副牌单花色最多 26 张 +}; +const styleProblems = []; +Object.keys(CASES).forEach(key => { + const st = EQW_Layout.NUM_STYLE[key]; + if (!st) { styleProblems.push(key + ' 缺样式'); return; } + if (!EQW_Images.hasOwnProperty(st.res)) { styleProblems.push(key + ' 的资源键不存在: ' + st.res); return; } + // 用真实资源 ID 组出完整 style(业务代码也是这么组的:纯数据配置只存资源键名) + const real = { imageId: EQW_Images[st.res], charWidth: st.charWidth, charHeight: st.charHeight, + spacing: st.spacing, align: st.align, width: st.width }; + let items; + try { items = NumberRenderer.layout(CASES[key], real); } + catch (e) { styleProblems.push(key + ' -> ' + e.message); return; } + if (items.length !== CASES[key].length) { styleProblems.push(key + ' 字符数不对'); return; } + // 典型最长值必须画得进绘制区(否则 width 配小了,画面会溢出到精灵外) + const right = items[items.length - 1].destX + items[items.length - 1].destW; + if (items[0].destX < 0 || right > st.width) { + styleProblems.push(key + ' 典型最长值 "' + CASES[key] + '" 超出绘制区 width=' + st.width + + '(实际 ' + items[0].destX + '..' + right + ')'); + } +}); +if (styleProblems.length) { console.log('数字样式问题:\n ' + styleProblems.join('\n ')); } +t.eq('5 套数字样式合法且典型最长值画得下', styleProblems, []); +t.eq('数字样式恰好 5 套', Object.keys(EQW_Layout.NUM_STYLE).sort(), Object.keys(CASES).sort()); + +process.exit(t.done('numberrenderer') ? 0 : 1);