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