Files
erqiwang_youle/client/js/gameabc-framework/ui/NumberRenderer.js
T
joywayerandClaude Opus 5 bc75e40acc 二七王:新增图集叠绘数字能力(SpriteManager 源矩形 API + NumberRenderer)
多帧图片精灵一次只显示一帧=只能显示一位数,两位数就得两个精灵,
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) <noreply@anthropic.com>
2026-08-26 23:52:56 +08:00

337 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================================
// 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;
}