Files
erqiwang_youle/client/tests/test_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

160 lines
8.8 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.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);