二七王:回退自造的图集叠绘,改用平台既有 SpriteManager.setNumberImage

方案作废并纠正认知:多帧图片精灵【不是】只能显示一位数——SpriteManager 第 1154 行
早有 setNumberImage(spriteId, text, charWidth):把整串文本设给一个多帧图片精灵,
引擎按字符逐帧渲染,精灵宽度按「字符数 × charWidth」自动调整。bc75e40 造的
drawImageRegion + NumberRenderer 是重复轮子,本次整体回退(revert bc75e40)。

- 回退:删除 gameabc-framework/ui/NumberRenderer.js 与其单测,SpriteManager 与
  index.html 恢复到改动前(与 bc75e40^ 逐字节一致)。
- ImageResources.js:数字类资源改为 16 帧、固定帧序 0123456789.+-x/p,逐条写明
  各帧含义、第16帧后缀(选主张数=「张」;结算/叫分的后缀标注待美术与规则确认,未瞎填)、
  单字符宽高与整图尺寸(16 × 单字宽),并说明帧序不可重排、不可省略占位。
- LayoutConstants.js:NUM_STYLE 改为 setNumberImage 所需的 res/charWidth/charHeight/suffix。
- Layout_Action.js:两个数字节点 h 取 charHeight(SSOT),w 留运行时注入
  (宽度由 setNumberImage 按字符数自动改,随位数变)。
- Sprites_Action.js / Sprites_Result.js:注释改为 setNumberImage 用法;
  结算玩家栏 ACC_TPL_SCORE_NUM 是复制精灵也能用(该接口不依赖绘制回调)。
- test_constants.js:NUM_STYLE 守卫改为校验 charWidth/charHeight/suffix 与
  「h 同源、w 运行时注入」;suffix 限定为 setNumberImage 支持的 分/倍/张。
- 文档:清单 §1.3/§1.5/§3.6/§5.4/§6.8 与前端 02 的那一节全部按 setNumberImage 重写,
  三种机制改为「文字精灵 / setFrame 切帧 / setNumberImage」,并记下这次误判作为反例。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 00:07:35 +08:00
co-authored by Claude Opus 5
parent dbdd6607d7
commit 35ee85e1d6
12 changed files with 174 additions and 712 deletions
+12 -8
View File
@@ -319,30 +319,34 @@ t.eq('roomtype 位号铺满 0-4', Object.keys(bitsSeen).map(Number).sort((a, b)
t.eq('CARD_SIZE.' + size + ' 的资源存在', EQW_Images.hasOwnProperty(EQW_Layout.CARD_SIZE[size].res), true);
});
// ---- 数字样式(图集叠绘):资源键存在、字段齐全合法 ----
// ---- 多帧图数字样式(配合 SpriteManager.setNumberImage):资源键存在、字段齐全合法 ----
// 与 CARD_SIZE 同一约定:纯数据配置只存资源【键名】,调用方用 EQW_Images[res] 取真实 ID
const numStyleProblems = [];
Object.keys(EQW_Layout.NUM_STYLE).forEach(key => {
const s = EQW_Layout.NUM_STYLE[key];
if (!EQW_Images.hasOwnProperty(s.res)) { numStyleProblems.push(key + ' 的资源键不存在: ' + s.res); }
['charWidth', 'charHeight', 'width'].forEach(f => {
// charWidth 是 setNumberImage 的必需入参(该接口对非正数直接返回 false)
['charWidth', 'charHeight'].forEach(f => {
if (typeof s[f] !== 'number' || s[f] <= 0) { numStyleProblems.push(key + '.' + f + ' 非正数: ' + s[f]); }
});
if (typeof s.spacing !== 'number') { numStyleProblems.push(key + '.spacing 非数字: ' + s.spacing); }
if (['left', 'center', 'right'].indexOf(s.align) < 0) { numStyleProblems.push(key + '.align 非法: ' + s.align); }
// 绘制区至少放得下一个字符,否则这套样式一开始就画不出东西
if (s.width < s.charWidth) { numStyleProblems.push(key + ' 绘制区比单字还窄'); }
// 后缀落在资源第 16 帧,只能是 0 或 1 个字符;且必须是 setNumberImage 认识的那三个
// (编码表 /[分倍张]/ → 'g' → 帧16;写别的字会被原样丢给引擎,按未知字符渲染)
if (typeof s.suffix !== 'string') { numStyleProblems.push(key + '.suffix 必须是字符串: ' + s.suffix); }
else if (s.suffix !== '' && ['分', '倍', '张'].indexOf(s.suffix) < 0) {
numStyleProblems.push(key + '.suffix 不是 setNumberImage 支持的后缀(分/倍/张): ' + s.suffix);
}
});
if (numStyleProblems.length) { console.log('数字样式问题:\n ' + numStyleProblems.join('\n ')); }
t.eq('NUM_STYLE 资源键存在且字段合法', numStyleProblems, []);
// 引用 NUM_STYLE 的布局节点必须与样式同源(SSOT:数值只在 LayoutConstants 定义一处,
// 有人在 Layout_Action.js 里手抄一个不同的数就在这里变红)
// 有人在 Layout_Action.js 里手抄一个不同的数就在这里变红)。
// 注意 w 不在此列:setNumberImage 会按【字符数 × charWidth】自动改精灵宽,故 w 由运行时注入
[['CALL_BTN_SCORE_TEXT', 'CALL_SCORE'], ['MAIN_SUIT_COUNT', 'MAIN_SUIT_COUNT']].forEach(pair => {
const node = EQW_Layout[pair[0]];
const st = EQW_Layout.NUM_STYLE[pair[1]];
t.eq(pair[0] + '.w 与 NUM_STYLE.' + pair[1] + '.width 同源', node.w, st.width);
t.eq(pair[0] + '.h 与 NUM_STYLE.' + pair[1] + '.charHeight 同源', node.h, st.charHeight);
t.eq(pair[0] + '.w 留到运行时注入', node.runtime.indexOf('w') >= 0 && typeof node.w === 'undefined', true);
});
process.exit(t.done('constants') ? 0 : 1);
-159
View File
@@ -1,159 +0,0 @@
// 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);