diff --git a/client/index.html b/client/index.html
index c5c1ac2..c7c9747 100644
--- a/client/index.html
+++ b/client/index.html
@@ -191,8 +191,6 @@
-
-
diff --git a/client/js/01_SubGame/codes/config/ImageResources.js b/client/js/01_SubGame/codes/config/ImageResources.js
index 735baa0..7e67701 100644
--- a/client/js/01_SubGame/codes/config/ImageResources.js
+++ b/client/js/01_SubGame/codes/config/ImageResources.js
@@ -281,30 +281,41 @@ var EQW_Images = EQW_Images || {
//===================== §3.6 数字与符号 =====================
//
- //【2026-08-26 出图方式变更】以下 NUM_* 数字类资源【不是】编辑器里配好帧数的多帧资源,
- //而是【等宽连续排列的一张图】:所有字符横向排成一行、每格等宽,图里不留间隙。
- //代码不用 setFrame(一个精灵一次只显示一帧=只能显示一位数),改为按字宽裁源矩形叠绘:
- //第 idx 格的源矩形 = (idx * 单字宽, 0, 单字宽, 单字高),由框架 NumberRenderer 逐位画在
- //【同一个】精灵上,故一个多位数只需一个精灵。样式(单字宽高/字间距/对齐/绘制区宽)见
- //EQW_Layout.NUM_STYLE.*(LayoutConstants.js),本处只记出图规格。
- //美术出图请严格按「字符排列顺序 + 单字符宽高 + 整图尺寸」执行,字符必须等宽对齐格子。
+ //【数字类资源的统一规范:16 帧固定帧序】(2026-08-26 订正)
+ //以下 NUM_* 一律是【多帧图片资源,16 帧】,帧序固定为:0 1 2 3 4 5 6 7 8 9 . + - x / p
+ // 帧1–10 = 数字 0–9 帧11 = 「.」 帧12 = 「+」 帧13 = 「-」
+ // 帧14 = 「x」 帧15 = 「/」 帧16 = 可变后缀(该套资源自己的后缀字,如「分」「倍」「张」)
+ //
+ //用法前提:显示多位数字【不拆精灵、不用 setFrame 逐位切】,而是用平台既有接口
+ // SpriteManager.setNumberImage(spriteId, text, charWidth)
+ //把整串文本设给【一个】多帧图片精灵,引擎按字符逐帧渲染,精灵宽度由该接口按
+ //【字符数 × charWidth】自动调整(charWidth 见 EQW_Layout.NUM_STYLE.*,本处只记出图规格)。
+ //
+ //出图要求:帧号由字符直接换算('0'–'9' → 帧1–10,后缀 → 帧16),所以帧序【不可重排、不可省略占位】——
+ //即使某套资源用不到 . + - x / 这几帧,也要按顺序留出帧位,否则帧号对不上、画面串位。
+ //整图尺寸一律 = 16 × 单字符宽(横向 16 帧)。
//用途:倒计时数字(带描边/渐变美术字)
- //排列:等宽连续一行,字符顺序 0 1 2 3 4 5 6 7 8 9(索引 0–9)
+ //帧数:16(固定帧序 0123456789.+-x/p)
+ //帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
+ // 帧16 = 后缀【本套无后缀,出空白占位】
//单字符尺寸:44×60
- //整图尺寸:440×60(10 格 × 44)
+ //整图尺寸:704×60(16 帧 × 44)
NUM_COUNTDOWN: 631,
//用途:结算得分数字(赢,橙,带描边/渐变美术字)
- //排列:等宽连续一行,字符顺序 0 1 2 3 4 5 6 7 8 9 + -(索引 0–9 为数字,10 = 「+」,11 = 「-」)
+ //帧数:16(固定帧序 0123456789.+-x/p)
+ //帧说明:帧1–10 = 0–9;帧12 = 「+」、帧13 = 「-」(得分带正负号,必出);帧11/14/15 = . x /(占位);
+ // 帧16 = 后缀【待美术与规则确认:结算大字目前只显示数字,若日后要显示「+120分」则此帧出「分」】
//单字符尺寸:46×62
- //整图尺寸:552×62(12 格 × 46)
+ //整图尺寸:736×62(16 帧 × 46)
NUM_RESULT_WIN: 632,
//用途:结算得分数字(输,蓝,带描边/渐变美术字)
- //排列:与 NUM_RESULT_WIN 完全一致——等宽连续一行,0–9 后接 「+」「-」(共 12 格),仅配色不同
+ //帧数:16(固定帧序 0123456789.+-x/p)
+ //帧说明:与 NUM_RESULT_WIN 完全一致(含帧16 待确认项),仅配色不同
//单字符尺寸:46×62
- //整图尺寸:552×62(12 格 × 46)
+ //整图尺寸:736×62(16 帧 × 46)
NUM_RESULT_LOSE: 633,
//用途:判定结果动画(大光/小光/过庄/升N级/投降)——帧动画资源,播放参数进 AnimationConfigs【待设计 D-8】
@@ -314,16 +325,21 @@ var EQW_Images = EQW_Images || {
ANI_JUDGE: 634,
//用途:叫分档位的分数数字(深棕描边美术字,带描边/渐变,文字精灵做不出此效果)
- //排列:等宽连续一行,字符顺序 0 1 2 3 4 5 6 7 8 9(索引 0–9)
+ //帧数:16(固定帧序 0123456789.+-x/p)
+ //帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
+ // 帧16 = 后缀【待确认:档位面板只显示分数数字(70…5),若日后要显示「70分」则此帧出「分」】
//单字符尺寸:30×40
- //整图尺寸:300×40(10 格 × 30)
- //说明:14 个档位中 13 个是两位数(70…10),靠叠绘一个精灵画两位,见 Sprites_Action.js 的 CALL_BTN_NUM_1..14
+ //整图尺寸:480×40(16 帧 × 30)
+ //说明:14 个档位中 13 个是两位数(70…10),整串交给 setNumberImage,一档【一个】精灵,
+ // 见 Sprites_Action.js 的 CALL_BTN_NUM_1..14;面板上的「N子」角标是文字精灵,不走本资源
NUM_CALL_SCORE: 635,
//用途:选主面板花色张数数字(美术单独出图,字号配色与以上几套不同;一个花色一个精灵,见 Sprites_Action.js)
- //排列:等宽连续一行,字符顺序 0 1 2 3 4 5 6 7 8 9(索引 0–9)
+ //帧数:16(固定帧序 0123456789.+-x/p)
+ //帧说明:帧1–10 = 0–9;帧11–15 = . + - x /(本套用不到,仅占位);
+ // 帧16 = 后缀「张」(显示形如「26张」,后缀与数字等宽,同为单字符宽)
//单字符尺寸:24×32(估值,与选主按钮 128×55 协调估算,出图前须与最终设计稿核对)
- //整图尺寸:240×32(10 格 × 24)
+ //整图尺寸:384×32(16 帧 × 24)
NUM_MAIN_SUIT_COUNT: 636,
//===================== §3.7 浮层与气泡 =====================
diff --git a/client/js/01_SubGame/codes/config/LayoutConstants.js b/client/js/01_SubGame/codes/config/LayoutConstants.js
index af3710f..5a67200 100644
--- a/client/js/01_SubGame/codes/config/LayoutConstants.js
+++ b/client/js/01_SubGame/codes/config/LayoutConstants.js
@@ -32,30 +32,31 @@ EQW_Layout.TEXT_STYLE = {
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 只在此定义,业务代码只引用不内联。
+//多帧图数字样式(清单 §3.6 / §6.8,2026-08-26 新增)——配合平台既有的
+//SpriteManager.setNumberImage(spriteId, text, charWidth):把整串数字设给【一个】多帧图片精灵,
+//引擎按字符逐帧渲染,精灵宽度由该接口按【字符数 × charWidth】自动调整。
+//故多位数不需要拆成十位/个位多个精灵,也不用 setFrame 逐位切。
+//本表是【零裸值红线】的落点:charWidth/charHeight 只在此定义一处,业务代码只引用不内联。
//
-//字段:res —— 图片资源【键名】(与 CARD_SIZE.res 同一约定:纯数据文件不直接引用 EQW_Images,
-// 调用方用 EQW_Images[style.res] 取真实 ID)
-// charWidth/charHeight —— 图集单字符宽高(= 源矩形尺寸,也是原样绘制的目标尺寸)
-// spacing —— 字间距(可为负=字符重叠)
-// align —— 在绘制区内的水平对齐:'left'|'center'|'right'
-// width —— 绘制区宽度(= 承载精灵的宽度;各 Layout_*.js 的对应节点直接引用本值,保持 SSOT)
+//字段:res —— 图片资源【键名】(与 CARD_SIZE.res 同一约定:纯数据文件不直接引用 EQW_Images,
+// 调用方用 EQW_Images[style.res] 取真实 ID)
+// charWidth —— 单字符宽(传给 setNumberImage;精灵宽 = 字符数 × charWidth,运行时自动变)
+// charHeight —— 单字符高(setNumberImage 不改高度,此值供布局节点定 h)
+// suffix —— 该套资源第 16 帧承载的后缀字(无后缀为 '';见 ImageResources.js §3.6 的帧序说明)
EQW_Layout.NUM_STYLE = {
- //倒计时(§2.4):最多两位(秒)
- COUNTDOWN: { res: 'NUM_COUNTDOWN', charWidth: 44, charHeight: 60, spacing: 0, align: 'center', width: 88 },
+ //倒计时(§2.4):纯数字无后缀,秒数最多两位
+ COUNTDOWN: { res: 'NUM_COUNTDOWN', charWidth: 44, charHeight: 60, suffix: '' },
- //结算得分 · 赢(§1.8/§6.9):带符号,最多「符号 + 四位」
- RESULT_WIN: { res: 'NUM_RESULT_WIN', charWidth: 46, charHeight: 62, spacing: 0, align: 'center', width: 230 },
+ //结算得分 · 赢(§1.8/§6.9):用到 '+'(帧12)与 '-'(帧13);后缀是否为「分」待定,见资源注释
+ RESULT_WIN: { res: 'NUM_RESULT_WIN', charWidth: 46, charHeight: 62, suffix: '' },
- //结算得分 · 输(§1.8/§6.9):帧序与赢的一致,只是配色不同
- RESULT_LOSE: { res: 'NUM_RESULT_LOSE', charWidth: 46, charHeight: 62, spacing: 0, align: 'center', width: 230 },
+ //结算得分 · 输(§1.8/§6.9):帧序与赢的一致,仅配色不同
+ RESULT_LOSE: { res: 'NUM_RESULT_LOSE', charWidth: 46, charHeight: 62, suffix: '' },
- //叫分档位分数(§1.3/§6.8):14 档中 13 档是两位数;绘制区宽 = 档位按钮宽 74,居中压在按钮上
- CALL_SCORE: { res: 'NUM_CALL_SCORE', charWidth: 30, charHeight: 40, spacing: 0, align: 'center', width: 74 },
+ //叫分档位分数(§1.3/§6.8):14 档中 13 档是两位数,整串交给 setNumberImage,无后缀
+ //(面板上的「N子」角标是文字精灵,不走这套)
+ CALL_SCORE: { res: 'NUM_CALL_SCORE', charWidth: 30, charHeight: 40, suffix: '' },
- //选主面板花色张数(§1.5/§6.8):两副牌单花色最多 26 张,最多两位
- MAIN_SUIT_COUNT: { res: 'NUM_MAIN_SUIT_COUNT', charWidth: 24, charHeight: 32, spacing: 0, align: 'center', width: 48 }
+ //选主面板花色张数(§1.5/§6.8):显示「N张」,「张」= 第 16 帧;单花色最多 26 张
+ MAIN_SUIT_COUNT: { res: 'NUM_MAIN_SUIT_COUNT', charWidth: 24, charHeight: 32, suffix: '张' }
};
diff --git a/client/js/01_SubGame/codes/config/Layout_Action.js b/client/js/01_SubGame/codes/config/Layout_Action.js
index 2eb2472..2af8b4e 100644
--- a/client/js/01_SubGame/codes/config/Layout_Action.js
+++ b/client/js/01_SubGame/codes/config/Layout_Action.js
@@ -20,12 +20,12 @@ EQW_Layout.CALL_BTN_GRID = {
//档位分数数字:贴对应档位按钮居中。CALL_BTN_1..14 是 14 个固定精灵,但本节点是共用模板,
//target 由调用方按第几档注入(不预置单一 target)。
-//2026-08-26:改为图集叠绘(NumberRenderer),一个精灵画完整分数,故宽高【不再随位数变化】——
-//直接取 NUM_STYLE.CALL_SCORE 的绘制区宽与单字高(SSOT:数值只在 LayoutConstants 定义一处)
+//宽度随位数变化(SpriteManager.setNumberImage 会按【字符数 × charWidth】自动改精灵宽),
+//故 w 留到运行时注入;高度恒为单字高,取 NUM_STYLE.CALL_SCORE.charHeight(SSOT,2026-08-26)
EQW_Layout.CALL_BTN_SCORE_TEXT = {
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'middle', offsetY: 4,
- w: EQW_Layout.NUM_STYLE.CALL_SCORE.width, h: EQW_Layout.NUM_STYLE.CALL_SCORE.charHeight,
- runtime: ['target']
+ h: EQW_Layout.NUM_STYLE.CALL_SCORE.charHeight,
+ runtime: ['target', 'w']
};
//档位子数角标:贴对应档位按钮右上角,纯文字无底图,清单未给宽高(同上,调用方注入 target)
@@ -51,12 +51,13 @@ EQW_Layout.MAIN_SUIT_LINE = {
itemWidth: 128, itemHeight: 55, spacing: 9, anchor: 'left'
};
-//张数数字:贴对应花色按钮下方居中,一个花色【一个】精灵(图集叠绘,两位数由 NumberRenderer 逐位画)。
-//2026-08-26:原十位/个位两节点合并为本节点;宽高取 NUM_STYLE.MAIN_SUIT_COUNT(SSOT,调用方注入 target)
+//张数数字:贴对应花色按钮下方居中,一个花色【一个】精灵,整串「N张」由 setNumberImage 显示。
+//2026-08-26:原十位/个位两节点合并为本节点;宽度随位数变化(2 或 3 个字符)留到运行时注入,
+//高度恒为单字高,取 NUM_STYLE.MAIN_SUIT_COUNT.charHeight(SSOT)
EQW_Layout.MAIN_SUIT_COUNT = {
kind: 'attach', targetKind: 'sprite', hAlign: 'center', vAlign: 'bottom', offsetY: -6,
- w: EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.width, h: EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charHeight,
- runtime: ['target']
+ h: EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charHeight,
+ runtime: ['target', 'w']
};
//对数角标文字:贴对应花色按钮右上角(按钮帧内已含角标底,本节点只定位叠加的文字)。
diff --git a/client/js/01_SubGame/codes/config/Sprites_Action.js b/client/js/01_SubGame/codes/config/Sprites_Action.js
index 0ae998a..dde6a4f 100644
--- a/client/js/01_SubGame/codes/config/Sprites_Action.js
+++ b/client/js/01_SubGame/codes/config/Sprites_Action.js
@@ -36,9 +36,10 @@ EQW_Sprites.CallPanelView = {
CALL_BTN_13: 1615,
CALL_BTN_14: 1616,
- //—— 14 档位分数数字(图集叠绘数字精灵),资源 EQW_Images.NUM_CALL_SCORE,样式 EQW_Layout.NUM_STYLE.CALL_SCORE。
- // 一个精灵画完整档位分数:NumberRenderer 按字宽从等宽连续数字图里逐位裁源矩形叠绘,
- // 故 14 档(其中 13 档是两位数)各只需【一个】精灵(2026-08-26 改,原多帧 setFrame 一次只显示一位、两位数根本画不出)——
+ //—— 14 档位分数数字(多帧图数字精灵),资源 EQW_Images.NUM_CALL_SCORE(16 帧固定帧序),
+ // 字符宽见 EQW_Layout.NUM_STYLE.CALL_SCORE.charWidth。
+ // 显示用 SpriteManager.setNumberImage(spriteId, '70', charWidth):整串交给引擎按字符逐帧渲染、
+ // 精灵宽度自动按字符数调整,故 14 档(其中 13 档是两位数)各只需【一个】精灵,不拆十位/个位 ——
CALL_BTN_NUM_1: 1617,
CALL_BTN_NUM_2: 1618,
CALL_BTN_NUM_3: 1619,
@@ -83,8 +84,9 @@ EQW_Sprites.CallPanelView = {
// 1660–1663 MAIN_SUIT_COUNT_1..4 —— 原文字精灵,改为下方数字图片精灵(键名沿用,ID 改到 1674–1677)
// 1664–1667 MAIN_SUIT_PAIR_BG_1..4 —— 角标底并入 BTN_SUIT_CHOOSE 帧1–4
// 1673 MAIN_BTN_SURRENDER_TEXT —— 「投降」二字并入 BTN_SUIT_CHOOSE 帧5
-// 1678–1681 原 MAIN_SUIT_COUNT_3/4_TENS/ONES —— 张数改为「一个花色一个精灵」(图集叠绘,见下),
-// 上一轮的十位/个位拆分作废,空出的 4 个 ID 不复用(2026-08-26)
+// 1678–1681 原 MAIN_SUIT_COUNT_3/4_TENS/ONES —— 张数改回「一个花色一个精灵」(多帧图数字,见下:
+// SpriteManager.setNumberImage 一个精灵就能显示整串),上一轮的十位/个位拆分作废,
+// 空出的 4 个 ID 不复用(2026-08-26)
EQW_Sprites.ChooseMainView = {
Layer: EQW_Layers.ACTION, //104
@@ -107,10 +109,11 @@ EQW_Sprites.ChooseMainView = {
MAIN_SUIT_PAIR_TEXT_3: 1670,
MAIN_SUIT_PAIR_TEXT_4: 1671,
- //—— 4 个花色张数数字精灵(图集叠绘,一花色一个):该花色在庄家手中的张数
- // (两副牌单花色最多 26 张,两位数),资源 EQW_Images.NUM_MAIN_SUIT_COUNT,
- // 样式 EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT;NumberRenderer 逐位裁源矩形画在同一精灵上,
- // 不再需要十位/个位各一个精灵(2026-08-26)——
+ //—— 4 个花色张数数字精灵(多帧图数字精灵,一花色一个):该花色在庄家手中的张数
+ // (两副牌单花色最多 26 张,两位数),资源 EQW_Images.NUM_MAIN_SUIT_COUNT(16 帧固定帧序,
+ // 第16帧=后缀「张」),字符宽见 EQW_Layout.NUM_STYLE.MAIN_SUIT_COUNT.charWidth。
+ // 显示用 SpriteManager.setNumberImage(spriteId, '26张', charWidth),整串一个精灵搞定,
+ // 不拆十位/个位(2026-08-26)——
MAIN_SUIT_COUNT_1: 1674,
MAIN_SUIT_COUNT_2: 1675,
MAIN_SUIT_COUNT_3: 1676,
diff --git a/client/js/01_SubGame/codes/config/Sprites_Result.js b/client/js/01_SubGame/codes/config/Sprites_Result.js
index c54a9e5..6ea9028 100644
--- a/client/js/01_SubGame/codes/config/Sprites_Result.js
+++ b/client/js/01_SubGame/codes/config/Sprites_Result.js
@@ -96,11 +96,10 @@ EQW_Sprites.AccountView = {
ACC_TPL_RANK: 1911, //模板:名次徽章,资源 EQW_Images.BADGE_RANK,帧=名次1–3
ACC_TPL_AVATAR: 1912, //模板:头像,运行时贴玩家头像
ACC_TPL_TEXT: 1913, //模板:通用文字(昵称/ID/各项分数,一行一个)
- ACC_TPL_SCORE_NUM: 1914, //模板:右侧总分大字(数字精灵),资源 EQW_Images.NUM_RESULT_WIN / NUM_RESULT_LOSE
- //【2026-08-26 待定】NUM_RESULT_* 已改为等宽连续图 + 图集叠绘(见 ImageResources.js §3.6),
- //而本精灵是【复制模板】、运行时 ID 为字符串,SpriteEventController.registerDraw 只收数字 ID、
- //复制精灵也不单独收绘制事件 → 无法用 NumberRenderer。玩家栏总分的呈现方式待 F 阶段定
- //(改文字精灵 / 改预置精灵 / 由父容器统一叠绘),此处先记风险不改结构
+ ACC_TPL_SCORE_NUM: 1914, //模板:右侧总分大字(多帧图数字精灵),资源 EQW_Images.NUM_RESULT_WIN / NUM_RESULT_LOSE,
+ //显示用 SpriteManager.setNumberImage(复制精灵ID, '+120', charWidth)——
+ //setNumberImage 走的是 TEXT/WIDTH 属性,_validateSpriteId 也接受复制精灵的字符串 ID,
+ //故复制出来的玩家栏总分同样一个精灵显示整串(2026-08-26 核对)
ACC_TPL_DIVIDER: 1915, //模板:行间分隔线,资源 EQW_Images.LINE_DIVIDER
//—— 底部 4 个功能按钮 ——
diff --git a/client/js/gameabc-framework/core/SpriteManager.js b/client/js/gameabc-framework/core/SpriteManager.js
index c5854fc..4ed4188 100644
--- a/client/js/gameabc-framework/core/SpriteManager.js
+++ b/client/js/gameabc-framework/core/SpriteManager.js
@@ -717,83 +717,7 @@ 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
deleted file mode 100644
index b98bf3c..0000000
--- a/client/js/gameabc-framework/ui/NumberRenderer.js
+++ /dev/null
@@ -1,336 +0,0 @@
-// ============================================================================
-// 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_constants.js b/client/tests/test_constants.js
index 5b865e8..31f8966 100644
--- a/client/tests/test_constants.js
+++ b/client/tests/test_constants.js
@@ -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);
diff --git a/client/tests/test_numberrenderer.js b/client/tests/test_numberrenderer.js
deleted file mode 100644
index 5d2bd63..0000000
--- a/client/tests/test_numberrenderer.js
+++ /dev/null
@@ -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);
diff --git a/docs/client/development-guide/02-渲染与UI组件体系.md b/docs/client/development-guide/02-渲染与UI组件体系.md
index 7aa371d..8b118d1 100644
--- a/docs/client/development-guide/02-渲染与UI组件体系.md
+++ b/docs/client/development-guide/02-渲染与UI组件体系.md
@@ -26,9 +26,10 @@ UI 组件 → SpriteManager(业务级 API:ID 范围校验 + 单位换算)
| `setPosition(id, x, y)` | 设置坐标 | |
| `setScale(id, scale)` | 缩放 | 业务用倍数(`1.2`),框架自动转引擎百分比 |
| `setOpacity(id, o)` | 透明度 | 业务用 `0.0–1.0`,框架自动转 `0–255` |
-| `setText(id, text)` / `setTextWithWidth(...)` | 文字 | 文字精灵 |
-| `drawImage(id, imgId, dx,dy,dw,dh)` | 在精灵上叠绘整张图 | **只能在绘制回调里调**(见 §5) |
-| `drawImageRegion(id, imgId, dx,dy,dw,dh, sx,sy,sw,sh)` | 在精灵上叠绘图片的**指定源矩形** | 从图集里裁一格再画,同上只在绘制回调里调 |
+| `setText(id, text)` | 文字 | 文字精灵,系统字体 |
+| `setTextWithWidth(id, text, charWidth)` | 文字 + 自动定宽 | **文字精灵**:设文本并把宽度设为「字符数 × `charWidth`」,中文按 2 个字符计 |
+| `setNumberImage(id, text, charWidth)` | **多帧图美术字数字** | **多帧图片精灵**:一个精灵显示**整串**(见下) |
+| `drawImage(id, imgId, dx,dy,dw,dh)` | 在精灵上叠绘整张图 | **只能在绘制回调里调**(见 §5),用于标记叠加 |
| `showGroup/hideGroup(gid)` | 群组批量 | 整块 UI 显隐 |
| `showLayer/hideLayer(lid)` | 图层批量 | 整个界面显隐 |
| `exists(id)` | 存在性检查 | |
@@ -57,48 +58,48 @@ SpriteManager.show(sid); SpriteManager.setFrame(sid, card.code - 1);
ID 必须与编辑器中实际存在的精灵**完全一致**。超范围 → 校验失败返回 `false`;编辑器里不存在的 ID → 操作静默无效。**绝不随意编造 ID。**
-### 三种显示机制:文字精灵 / 多帧图片精灵 / 图集叠绘
+### 三种显示机制:文字精灵 / 多帧图片精灵切帧 / 多帧图数字
-同样是"在精灵上显示内容",引擎给了三条路,**能力边界完全不同,选错了会在后期变成返工**:
+同样是"在精灵上显示内容",`SpriteManager` 给了三条路,**能力边界完全不同,选错了会在后期变成返工**:
| 机制 | API | 一次能显示 | 字形 | 适用 |
|------|-----|-----------|------|------|
-| **文字精灵** | `setText(id, text)` | **任意长度**的字符串 | **系统字体**(只能调字号/颜色,做不出描边、渐变、投影) | 纯数据型文本:昵称、局数、`N对`/`主N` 角标、`2子`、提示文案 |
-| **多帧图片精灵** | `setFrame(id, frame)` | **一帧**(整张精灵就是那一帧) | 美术出图 | **状态切换**:按钮可用/置灰、单选框选中/未选、花色图标、牌面(一张牌 = 一帧) |
-| **图集叠绘** | `NumberRenderer`(底层 `drawImageRegion`) | **一行任意个字符**,都画在**同一个**精灵内 | 美术出图 | **多位美术字**:倒计时、结算得分、叫分档位分数、张数、比分 |
+| **文字精灵** | `setText(id, text)`、`setTextWithWidth(id, text, charWidth)` | **任意长度**的文本 | **系统字体**(只能调字号/颜色,做不出描边、渐变、投影) | 纯数据型文本:昵称、局数、`N对`/`主N` 角标、`2子`、提示文案 |
+| **多帧图片精灵切帧** | `setFrame(id, frame)` | **一帧**(整张精灵就是那一帧) | 美术出图 | **"选其一"的状态**:按钮可用/置灰、单选框选中/未选、花色图标、牌面(一张牌 = 一帧) |
+| **多帧图数字** | `setNumberImage(id, text, charWidth)` | **整串**(多位数字 + 符号 + 后缀),都在**同一个**精灵里 | 美术出图 | **多位美术字数字**:倒计时、结算得分、叫分档位分数、张数、比分 |
**选用判据(按顺序问自己三个问题)**:
-1. **要不要美术字形**(描边/渐变/投影)?不要 → **文字精灵**,到此为止,最省。
-2. 要美术字形,那**一次只显示一个符号**吗(一张牌、一个图标、一个按钮态)?是 → **多帧图片精灵** + `setFrame`。
-3. 要美术字形、且**可能不止一个字符**(两位数、带正负号、可变长度)?→ **图集叠绘**。
+1. **要不要美术字形**(描边/渐变/投影)?不要 → **文字精灵**,到此为止,最省。需要按字符数定宽就用 `setTextWithWidth`(中文按 2 个字符计)。
+2. 要美术字形,显示的是**"若干形态里选一个"**吗(一张牌、一个图标、一个按钮态)?是 → **`setFrame`**。
+3. 要美术字形,显示的是**一串数字/符号**(可能两位、带正负号、带 `分`/`张` 后缀)?→ **`setNumberImage`**。
-> ⚠️ **最容易踩的坑:用多帧图片精灵去显示多位数。**
-> `setFrame` 是"整个精灵显示图集的第 N 帧",**一个精灵一次只能显示一位数字**。于是两位数被迫拆成"十位精灵 + 个位精灵",三位数拆三个……精灵数量随位数线性膨胀,布局、显隐、清理全都要按位处理(十位为 0 还要单独隐藏),而**位数一变就得重排**。
-> 真实事故:某处 14 个档位按钮各配了**一个**分数精灵,而 14 档里 13 档是两位数——按 `setFrame` 方案**根本渲染不出来**,直到改成叠绘才修好。
-> **判据一句话:值可能超过一个字符,就不要用 `setFrame`。**
+> ⚠️ **最容易踩的坑:显示多位数字时拆成多个精灵,或用 `setFrame` 逐位切。**
+> `setFrame` 是"整个精灵显示第 N 帧",**一个精灵一次只显示一位**。有人据此把两位数拆成"十位精灵 + 个位精灵",三位数拆三个——精灵数随位数线性膨胀,布局、显隐、清理全要按位处理(十位为 0 还得单独隐藏),位数一变就得重排。
+> **平台早就给了 `setNumberImage`,一个精灵显示整串,别再造这个轮子。**
+> 真实事故:某次评审误判"一个精灵只能显示一位",先把选主张数拆成十位/个位 8 个精灵,随后又差点为此新造一套"图集叠绘"渲染器——直到发现 `SpriteManager` 第 1154 行本来就有这个接口。
+> **判据一句话:值可能超过一个字符,用 `setNumberImage`,既不拆精灵也不用 `setFrame`。**
-**图集叠绘怎么工作**:`GameABCUtils.Draw.drawImage` 支持**源矩形**,`SpriteManager.drawImageRegion` 把这能力暴露到业务层——可以从一张图里裁出任意区域,画到精灵内的任意位置。数字图出成**等宽连续排列的一行**,第 `idx` 格的源矩形就是 `(idx × 单字宽, 0, 单字宽, 单字高)`;把每一位分别裁出来、画到精灵内递增的 x 上,一个精灵就显示了一个多位数。
+**`setNumberImage` 怎么工作**(读实现,不要凭印象):它把文本里的符号编码后设给精灵的 TEXT 属性,由**引擎按字符逐帧渲染**(帧号由字符直接换算),并把精灵宽度设为 **`字符数 × charWidth`**。所以:
```js
-// 框架的 NumberRenderer 封装了这套逻辑,业务侧只有三步(样式全部来自常量,禁裸值)
-NumberRenderer.bind(SPRITE_ID, {
- imageId: ImageResources.NUM_SCORE, // 等宽连续排列的数字图
- charWidth: 30, charHeight: 40, // 图集单字宽高
- spacing: 0, // 字间距(可为负 = 重叠)
- align: 'center', width: 74 // 在绘制区内怎么对齐、绘制区多宽
-});
-NumberRenderer.setValue(SPRITE_ID, 70); // 只写值;引擎每帧回调时才真正画
-NumberRenderer.unbind(SPRITE_ID); // 组件 onDestroy 里注销回调,防残留
+// 资源须是 16 帧、帧序固定为 0123456789.+-x/p 的多帧图片
+SpriteManager.setNumberImage(spriteId, '70', charWidth); // 叫分档位分数(两位数,一个精灵)
+SpriteManager.setNumberImage(spriteId, '+120', charWidth); // 结算得分(帧12 = '+'、帧13 = '-')
+SpriteManager.setNumberImage(spriteId, '26张', charWidth); // 带后缀('张' 落在第 16 帧)
+// charWidth 来自常量文件,禁裸值;精灵宽度由该接口自动设,无需再 setSize
```
-**三条前提,缺一不可**:
+**资源规格(写进图片资源常量的注释里,供美术照做)**:
-- **叠绘只能在精灵的绘制回调里进行**(`SpriteEventController.registerDraw` 注册的 handler 内),在回调之外调 `drawImageRegion` 不会留下任何画面——因为引擎只在绘制那一帧接受叠绘。
-- **绘制回调依赖平台事件链路接通**:`Game_Modify.gamemydraw` →(转发壳)→ 子游戏 hook → `SpriteEventController.handleDraw`。这条链断了,叠绘、按钮点击一起静默失效(见 §5)。
-- **只对编辑器预置的数字 ID 精灵有效**:`SpriteCopyUtils` 复制出的字符串 ID 精灵不接收独立绘制事件,不能用 `NumberRenderer`;那种场合改用文字精灵,或由父容器统一叠绘。
+| 帧 | 1–10 | 11 | 12 | 13 | 14 | 15 | 16 |
+|----|------|----|----|----|----|----|----|
+| 内容 | `0`–`9` | `.` | `+` | `-` | `x` | `/` | **可变后缀**(每套资源自己的后缀字,只支持 `分` / `倍` / `张`) |
-**图集出图规格**(写进图片资源常量的注释里,供美术照做):字符**排列顺序**(如 `0…9` 后接 `+`、`-`)、**单字符宽高**、**整图尺寸**;字符必须严格等宽对齐格子、格间不留缝,否则裁出来的字会串位。
+- **帧序不可重排、不可省略占位**:帧号由字符直接换算,用不到的帧位也要按顺序留出来,否则帧号对不上、画面串位。
+- **16 帧等宽**(后缀字与数字同宽),整图宽 = `16 × 单字符宽`;单字符宽即代码里传的 `charWidth`。
+- 第 16 帧的后缀只认 `分` / `倍` / `张` 这三个字(接口内部把它们统一编码到该帧),别的字写进去不会被识别。
+- **复制精灵也能用**:`setNumberImage` 走的是属性设置、不依赖绘制回调,`SpriteCopyUtils` 复制出的字符串 ID 精灵同样适用。
---
@@ -218,7 +219,7 @@ SpriteEventController.registerMouseMove(sprites.HAND_AREA, function (event) { se
支持的事件类型:`mouseDown` / `mouseDownNoMove`(长按) / `mouseUp` / `mouseMove`(拖拽) / `drawBegin` / `draw`。
-> ⚠️ **转发这一段必须真的接上,且参数顺序要逐个对齐 `handleXxx` 的签名**。转发缺失或参数错位不会报错,只会让**所有**精灵交互与叠绘**静默失效**——按钮点了没反应、叠绘一片空白,却查不到任何异常日志。接线时对着 `SpriteEventController` 里各 `handleXxx` 的形参表逐个核对,转发层**只转发、不写业务**。
+> ⚠️ **转发这一段必须真的接上,且参数顺序要逐个对齐 `handleXxx` 的签名**。转发缺失或参数错位不会报错,只会让**所有**精灵交互与绘制回调**静默失效**——按钮点了没反应、叠绘的标记一片空白,却查不到任何异常日志。接线时对着 `SpriteEventController` 里各 `handleXxx` 的形参表逐个核对,转发层**只转发、不写业务**。
对**每个**绘制精灵统一处理(不针对某个固定 ID)用 `registerGlobalDraw(fn)`——框架对每个 draw 事件都回调它、但**不认识**其业务含义,保持中立(“精牌标记”这类玩法叠绘即以此挂载,框架零感知,见 05「框架中立」)。
@@ -265,7 +266,7 @@ list.onClick = function (type, rowIndex, rowData) { /* ... */ };
|------|---------|
| UI 只调 `SpriteManager` | 直接调 `GameABCUtils`/引擎原生 API |
| 一精灵多帧、`setFrame` 切换**状态** | 为每种牌面建一个精灵 |
-| 多位美术字用 `NumberRenderer` 叠绘,**一个精灵画完整个数** | 用 `setFrame` 显示多位数(一帧只有一位)/ 拆十位个位各建一个精灵 |
+| 多位美术字数字用 `setNumberImage`,**一个精灵显示整串** | 用 `setFrame` 逐位切 / 拆十位个位各建一个精灵 / 另造一套数字渲染器 |
| 精灵 ID 1001–3000、群组 ≥201、从编辑器查证 | 随意编造 ID / 超范围 ID |
| 检查 `SpriteManager` 返回值 | 忽略 `false` 返回 |
| 精灵/资源/坐标全进常量文件 | 在业务代码内联裸数字 |
diff --git a/docs_dev/二七王-UI资源与精灵清单.md b/docs_dev/二七王-UI资源与精灵清单.md
index da1c00e..14c694c 100644
--- a/docs_dev/二七王-UI资源与精灵清单.md
+++ b/docs_dev/二七王-UI资源与精灵清单.md
@@ -535,15 +535,15 @@ EQW_RoomOptions = {
| 元素 | 精灵类型 | 说明 |
| --- | --- | --- |
-| **叫分的分数**(70/65/…/5) | **图集叠绘数字精灵**(资源 `NUM_CALL_SCORE`,样式 `NUM_STYLE.CALL_SCORE`) | 带描边、需与图一致的美术字;**一个精灵画完整两位数** |
+| **叫分的分数**(70/65/…/5) | **多帧图数字精灵**(资源 `NUM_CALL_SCORE`,`SpriteManager.setNumberImage`) | 带描边、需与图一致的美术字;**一个精灵显示整串**(两位数不拆精灵) |
| **角标的子数**(`2子`/`7子`…) | **文字精灵**(`SpriteManager.setText`) | 随爬坡开关动态变,值域 2–15,用文字精灵最省 |
故每档 **3 个精灵**:按钮底(含角标底)+ 分数数字精灵 + 角标文字精灵。
-> **2026-08-26 修订(档位分数的实现方式)**:14 档里 **13 档是两位数**(70…10),而每档只有**一个**分数精灵。
-> 多帧图片精灵靠 `setFrame` 切帧、**一次只显示一帧=只能显示一位数**,按原方案这 13 档**根本渲染不出来**(要么显示不全,要么被迫每档再加一个十位精灵、精灵数翻倍)。
-> 因此改为**图集叠绘**:`NUM_CALL_SCORE` 出成等宽连续的一张图(§3.6),由框架 `NumberRenderer` 在精灵的绘制回调里按字宽逐位裁源矩形、画在**同一个**精灵内的不同 x 上。
-> 于是 `CALL_BTN_NUM_1..14` **保持 14 个不变**,且现在能正确显示两位数。用法:`NumberRenderer.bind(spriteId, style)` → `setValue(spriteId, 70)`,样式取 `EQW_Layout.NUM_STYLE.CALL_SCORE`(§6.8)。
+> **2026-08-26 补充(档位分数的实现方式,务必照做)**:14 档里 **13 档是两位数**(70…10),而每档只有**一个**分数精灵——这是**对的**,不要拆成十位/个位两个精灵。
+> 显示走平台既有接口 `SpriteManager.setNumberImage(spriteId, '70', charWidth)`:整串文本交给一个多帧图片精灵,引擎按字符逐帧渲染,精灵宽度按**字符数 × `charWidth`** 自动调整。
+> **不要用 `setFrame` 逐位切**——`setFrame` 是"整个精灵显示第 N 帧",一次只能显示一位,用它做多位数必然要拆精灵。
+> 资源 `NUM_CALL_SCORE` 须按 §3.6 的 **16 帧固定帧序**出图;`charWidth` 取 `EQW_Layout.NUM_STYLE.CALL_SCORE.charWidth`(§6.8,业务代码不写裸值)。
- 可选性规则(design §4.2,**由服务端权威 `currcall` 决定,前端只据其置灰、不自行推导规则**):
- 暂定庄家首叫:5–70 全部可选,**无 `不叫`**;
- 其后:只有**严格低于 `currcall`** 的档位可选,`不叫` 可选。
@@ -586,11 +586,11 @@ EQW_RoomOptions = {
> **2026-08-26 修订(其一)**:美术侧实际出图方式与原规格不符——花色按钮改为**一张图 5 帧**(帧1–4=四花色按钮,每帧已含按钮底+花色图标+右上角蓝色角标底;帧5=投降按钮,已含按钮底+「投降」字),花色图标、角标底、投降文字不再单独出精灵。按此改写以下条目,代码同步见 `ImageResources.js`/`Sprites_Action.js`/`Layout_Action.js`。
>
-> **2026-08-26 修订(其二)**:张数数字改为**图集叠绘**(§3.6)——一个精灵就能画完两位数,故**每花色一个数字精灵**(`MAIN_SUIT_COUNT_1..4` = 1674–1677),当日早前定的「十位/个位各一个精灵」(8 个)作废,空出的 1678–1681 不复用。
+> **2026-08-26 修订(其二)**:张数数字用平台既有的 `SpriteManager.setNumberImage`(§3.6)——一个精灵就能显示整串 `26张`,故**每花色一个数字精灵**(`MAIN_SUIT_COUNT_1..4` = 1674–1677);当日早前定的「十位/个位各一个精灵」(8 个)**作废**,空出的 1678–1681 不复用。
- 标题斜角条:`亮主` 金色字 + 倒计时数字。
- **4 个花色按钮**:每个按钮由**一个精灵**呈现,帧 = 花色序号 1–4(服务端 flower 编号),该帧已含「按钮底 + 花色图标 + 右上角蓝色角标底」;角标底之上叠加一个**对数文字精灵**(`N对`)。
- - 按钮下方叠加**张数数字精灵**:该花色在庄家手中的**总张数**——两副牌单花色最多 26 张(两位数),用**图集叠绘**在**一个**精灵上画完(`NumberRenderer`,无需拆十位/个位),资源见 §3.6 的 `NUM_MAIN_SUIT_COUNT`、样式见 §6.8 的 `NUM_STYLE.MAIN_SUIT_COUNT`
+ - 按钮下方叠加**张数数字精灵**:该花色在庄家手中的**总张数**——两副牌单花色最多 26 张(两位数),用 `SpriteManager.setNumberImage(spriteId, '26张', charWidth)` 在**一个**精灵上显示整串(无需拆十位/个位),资源见 §3.6 的 `NUM_MAIN_SUIT_COUNT`(第16帧=`张`)、`charWidth` 见 §6.8 的 `NUM_STYLE.MAIN_SUIT_COUNT`
- 右上角标文字 = 该花色的**对子数**(design §4/§11 明确要求)
- **张数与对子数都由前端据 `MyCards`/`cards` 自行统计**,协议 §「ChooseMain」明确「服务端不额外下发」。
- **投降按钮**:仅 `touxiang === 1`(即叫分 70)时显示,与 4 个花色按钮并列;按钮为**一个精灵**(`BTN_SUIT_CHOOSE` 帧5,已含底与「投降」字),不再单独出文字精灵。
@@ -1175,24 +1175,30 @@ y≈648–720 深色条。左侧头像/昵称/分数由平台渲染(§0.2)
### 3.6 数字与符号
-> **2026-08-26 修订(重要,出图方式变更)**:数字类资源 631/632/633/635/636 **不再是编辑器里配好帧数的多帧资源**,改为**等宽连续排列的一张图**——所有字符横向排成一行、每格等宽、格间不留缝。
-> 缘由:多帧图片精灵靠 `setFrame` 切帧,**一个精灵一次只显示一帧=只能显示一位数**;两位数就得两个精灵(十位/个位),精灵数迅速膨胀,而 `CALL_BTN_NUM_1..14` 每档只有一个精灵、14 档里 13 档是两位数,**按多帧方案根本渲染不出来**。
-> 新方案是**图集叠绘**:在精灵的绘制回调里按字宽裁源矩形,第 `idx` 格的源矩形 = `(idx × 单字宽, 0, 单字宽, 单字高)`,逐位画在**同一个**精灵上,于是**一个多位数只用一个精灵**。实现见框架 `gameabc-framework/ui/NumberRenderer.js`(`bind` / `setValue` / `clear` / `unbind`)与 `SpriteManager.drawImageRegion`;三种显示机制的选用判据见前端文档 `02-渲染与UI组件体系.md`。
-> **样式(单字宽高 / 字间距 / 对齐 / 绘制区宽)在 `EQW_Layout.NUM_STYLE.*`(`LayoutConstants.js`)集中配置**,本表只记出图规格。
+> **2026-08-26 修订(重要,出图规范订正)**:数字类资源 631/632/633/635/636 一律出成 **16 帧、固定帧序 `0123456789.+-x/p`** 的多帧图片资源。
+> 缘由:平台的 `SpriteManager.setNumberImage(spriteId, text, charWidth)` 就是**为多位美术字数字准备的现成接口**——把整串文本设给**一个**多帧图片精灵,引擎按字符逐帧渲染,并按**字符数 × `charWidth`** 自动调整精灵宽度。所以**一个精灵就能显示一整串数字**:既不用拆十位/个位,也不用 `setFrame` 逐位切(`setFrame` 是"整个精灵显示第 N 帧",只适合状态/花色/牌面这类"选其一")。
+> 帧号由字符**直接换算**(`'0'–'9'` → 帧1–10,后缀字 → 帧16),因此帧序**不可重排、不可省略占位**;用不到的帧位也要按顺序留出来,否则帧号对不上、画面串位。
+> 三种显示机制的选用判据见前端文档 `02-渲染与UI组件体系.md`;`charWidth`/`charHeight` 等数值在 `EQW_Layout.NUM_STYLE.*`(`LayoutConstants.js`)集中配置,本表只记出图规格。
-| ID | 键名 | 用途 | 字符排列(等宽连续一行) | 单字符尺寸 | 整图尺寸 |
-| --- | --- | --- | --- | --- | --- |
-| 631 | `NUM_COUNTDOWN` | 倒计时数字 | `0 1 2 3 4 5 6 7 8 9`(索引 0–9) | 44 × 60 | **440 × 60**(10 格) |
-| 635 | `NUM_CALL_SCORE` | **叫分档位的分数数字**(深棕描边美术字) | `0 1 2 3 4 5 6 7 8 9`(索引 0–9) | 30 × 40 | **300 × 40**(10 格) |
-| 632 | `NUM_RESULT_WIN` | 结算得分数字(赢,橙) | `0 1 2 3 4 5 6 7 8 9 + -`(索引 0–9 数字、10 = `+`、11 = `-`) | 46 × 62 | **552 × 62**(12 格) |
-| 633 | `NUM_RESULT_LOSE` | 结算得分数字(输,蓝) | 与 632 完全一致,仅配色不同 | 46 × 62 | **552 × 62**(12 格) |
-| 634 | `ANI_JUDGE` | **判定结果动画**(大光 / 小光 / 过庄 / 升N级 / 投降)——帧动画资源,播放参数进 `AnimationConfigs`【待设计 D-8】。**注意:这是帧动画,不属于本节的等宽数字图** | 待定 | 待定 | 待定 |
-| 636 | `NUM_MAIN_SUIT_COUNT` | **选主面板花色张数数字**(美术单独出图,字号配色与以上几套不同;**一个花色一个精灵**) | `0 1 2 3 4 5 6 7 8 9`(索引 0–9) | 24 × 32(估值) | **240 × 32**(10 格) |
+**固定帧序(所有 NUM_\* 通用)**:
-> **出图要求**:字符必须**严格等宽对齐格子**(第 N 个字符的左边缘恰好在 `N × 单字宽`),否则裁出来的字会串位;格与格之间**不留间隙**,字间距由代码的 `spacing` 控制(可为负=重叠)。
-> **带描边/渐变的美术字用图集叠绘数字精灵**(倒计时、结算得分、叫分分数、选主张数),文字精灵做不出这种效果——631–636 都要出图。
-> **纯数据型小字用文字精灵**(`SpriteManager.setText`):叫分角标的子数(值域 2–15,随爬坡开关变)、`主N`/`对N` 角标、局数、亮牌统计等。
-> **2026-08-26 新增 636**:选主张数原计划走文字精灵,但美术侧单独出了一套数字图(字号配色不同于既有几套),且张数最多两位(两副牌单花色最多 26 张);同日改为图集叠绘后,**十位/个位不再拆两个精灵**,每花色一个精灵即可。
+| 帧 | 1–10 | 11 | 12 | 13 | 14 | 15 | 16 |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| 内容 | `0`–`9` | `.` | `+` | `-` | `x` | `/` | **可变后缀**(该套资源自己的后缀字:`分`/`倍`/`张`) |
+
+| ID | 键名 | 用途 | 帧数 | 第 16 帧(后缀) | 单字符尺寸 | 整图尺寸 |
+| --- | --- | --- | --- | --- | --- | --- |
+| 631 | `NUM_COUNTDOWN` | 倒计时数字 | **16** | **无后缀**(出空白占位) | 44 × 60 | **704 × 60**(16 × 44) |
+| 635 | `NUM_CALL_SCORE` | **叫分档位的分数数字**(深棕描边美术字) | **16** | 【**待美术与规则确认**】档位只显示分数数字;若日后要显示 `70分` 则此帧出 `分` | 30 × 40 | **480 × 40**(16 × 30) |
+| 632 | `NUM_RESULT_WIN` | 结算得分数字(赢,橙)——用到帧12 `+`、帧13 `-` | **16** | 【**待美术与规则确认**】结算大字目前只显示数字,也可能是 `分` | 46 × 62 | **736 × 62**(16 × 46) |
+| 633 | `NUM_RESULT_LOSE` | 结算得分数字(输,蓝) | **16** | 同 632(含待确认项),仅配色不同 | 46 × 62 | **736 × 62**(16 × 46) |
+| 634 | `ANI_JUDGE` | **判定结果动画**(大光 / 小光 / 过庄 / 升N级 / 投降)——帧动画资源,播放参数进 `AnimationConfigs`【待设计 D-8】。**注意:这是帧动画,不适用本节的 16 帧数字规范** | 待定 | —— | 待定 | 待定 |
+| 636 | `NUM_MAIN_SUIT_COUNT` | **选主面板花色张数数字**(美术单独出图,字号配色与以上几套不同;**一个花色一个精灵**) | **16** | **`张`**(显示形如 `26张`) | 24 × 32(估值) | **384 × 32**(16 × 24) |
+
+> **出图要求**:16 帧**等宽**(后缀字与数字同宽,因为宽度按"字符数 × 单字宽"算);帧序严格照上表,用不到的帧位(如某些套的 `.` `x` `/`)**仍要占位**。
+> **带描边/渐变的美术字用多帧图数字精灵 + `setNumberImage`**(倒计时、结算得分、叫分分数、选主张数),文字精灵做不出这种效果——631–636 都要出图。
+> **纯数据型小字用文字精灵**(`SpriteManager.setText` / `setTextWithWidth`):叫分角标的子数(值域 2–15,随爬坡开关变)、`主N`/`对N` 角标、局数、亮牌统计等。
+> **2026-08-26 新增 636**:选主张数原计划走文字精灵,但美术侧单独出了一套数字图(字号配色不同于既有几套);同日一度定为「十位/个位各一个精灵」,**现已撤销**——`setNumberImage` 一个精灵就能显示 `26张`。
### 3.7 浮层与气泡
@@ -1321,7 +1327,7 @@ design 与协议均**未定义任何音效**。以下是按牌类游戏常规推
| | 1601 | `CALL_HINT_BG` | 图片 | 提示条底 | `BAR_HINT_TOP` |
| | 1602 | `CALL_HINT_TEXT` | 文字 | `70分坐庄才可投降` | —— |
| | 1603–1616 | `CALL_BTN_1..14` | 图片 ×14 | 档位按钮底(**含角标底**,70…5) | `BTN_CALL_SCORE` 帧1–3 按档位位置固定 / 帧4 灰 |
-| | 1617–1630 | `CALL_BTN_NUM_1..14` | **图集叠绘数字精灵** ×14 | 档位分数(70…5),**一个精灵画完整两位数**(2026-08-26,见 §3.6) | `NUM_CALL_SCORE` + `NUM_STYLE.CALL_SCORE` |
+| | 1617–1630 | `CALL_BTN_NUM_1..14` | **多帧图数字精灵** ×14 | 档位分数(70…5),**一个精灵显示整串**(`setNumberImage`,两位数不拆精灵;2026-08-26,见 §1.3/§3.6) | `NUM_CALL_SCORE`(16 帧)+ `NUM_STYLE.CALL_SCORE.charWidth` |
| | 1631–1644 | `CALL_BTN_BADGE_1..14` | **文字** ×14 | 该档 `multiple` 子数(`2子`…`15子`),随爬坡开关变 | —— |
| | 1645 | `CALL_BTN_NONE` | 图片 | `不叫` | `BTN_NO_CALL` |
| | 1646 | `CALL_BTN_NONE_TEXT` | 文字 | —— | —— |
@@ -1329,10 +1335,10 @@ design 与协议均**未定义任何音效**。以下是按牌类游戏常规推
| | 1651 | `MAIN_TITLE_TEXT` | 文字 | `亮主` | —— |
| | 1652–1655 | `MAIN_SUIT_BTN_1..4` | 图片 ×4 | 花色按钮(**已含按钮底+花色图标+角标底**),帧=花色序1–4 | `BTN_SUIT_CHOOSE` 帧1–4 |
| | 1668–1671 | `MAIN_SUIT_PAIR_TEXT_1..4` | 文字 ×4 | `N对`,叠在对应按钮角标底之上 | —— |
-| | 1674–1677 | `MAIN_SUIT_COUNT_1..4` | **图集叠绘数字精灵** ×4 | 该花色在庄家手中的**张数**(最多两位,**一个花色一个精灵**;2026-08-26 由十位/个位 8 个精灵改回 4 个,见 §3.6) | `NUM_MAIN_SUIT_COUNT` + `NUM_STYLE.MAIN_SUIT_COUNT` |
+| | 1674–1677 | `MAIN_SUIT_COUNT_1..4` | **多帧图数字精灵** ×4 | 该花色在庄家手中的**张数**,显示 `26张`(**一个花色一个精灵**,`setNumberImage`;2026-08-26 由十位/个位 8 个精灵改回 4 个,见 §3.6) | `NUM_MAIN_SUIT_COUNT`(16 帧,第16帧=`张`)+ `NUM_STYLE.MAIN_SUIT_COUNT.charWidth` |
| | 1672 | `MAIN_BTN_SURRENDER` | 图片 | `投降`(**已含按钮底+文字**) | `BTN_SUIT_CHOOSE` 帧5 |
| | ~~1656–1659 / 1660–1663 / 1664–1667 / 1673~~ | ~~`MAIN_SUIT_ICON_1..4` / 原 `MAIN_SUIT_COUNT_1..4`(文字精灵)/ `MAIN_SUIT_PAIR_BG_1..4` / `MAIN_BTN_SURRENDER_TEXT`~~ | —— | **不需要**:分别并入按钮帧、或改为上方 1674–1677 的数字精灵,空出的 ID 不复用 | —— |
-| | ~~1678–1681~~ | ~~`MAIN_SUIT_COUNT_3/4_TENS/ONES`~~ | —— | **不需要**(2026-08-26):改用图集叠绘后一个精灵就能画两位数,十位/个位拆分作废,空出的 4 个 ID 不复用 | —— |
+| | ~~1678–1681~~ | ~~`MAIN_SUIT_COUNT_3/4_TENS/ONES`~~ | —— | **不需要**(2026-08-26):`setNumberImage` 一个精灵就能显示整串,十位/个位拆分作废,空出的 4 个 ID 不复用 | —— |
| **222** 埋牌操作条 | 1700 | `BURY_BTN_TIP` | 图片 | `提示` | `BTN_TIP` |
| | 1701 | `BURY_BTN_TIP_TEXT` | 文字 | —— | —— |
| | 1702 | `BURY_BTN_SUBMIT` | 图片 | `埋牌` | `BTN_BURY` 帧1可提交/帧2灰 |
@@ -1716,20 +1722,21 @@ CARD_SIZE: {
### 6.8 阶段操作区配置
> **2026-08-26 修订(其一)**:选主面板花色图标、对数角标底已并入按钮帧(见 §1.5/§3.3),对应布局节点删除。
-> **2026-08-26 修订(其二)**:数字改为图集叠绘(§3.6)后,一个多位数只占一个精灵——花色张数的「十位 / 个位」两个节点**合并为一个** `MAIN_SUIT_COUNT`;两个数字节点的 `w/h` 不再随位数变化,直接引用 `EQW_Layout.NUM_STYLE.*` 的 `width` / `charHeight`(SSOT:数值只在 `LayoutConstants.js` 定义一处,布局文件不再手抄)。
+> **2026-08-26 修订(其二)**:数字统一走 `SpriteManager.setNumberImage`(§3.6)后,一个多位数只占一个精灵——花色张数的「十位 / 个位」两个节点**合并为一个** `MAIN_SUIT_COUNT`。
+> 两个数字节点的 `h` 恒为单字高,直接引用 `EQW_Layout.NUM_STYLE.*.charHeight`(SSOT:数值只在 `LayoutConstants.js` 定义一处,布局文件不再手抄);`w` 则**留到运行时注入**——`setNumberImage` 会按「字符数 × `charWidth`」自动改精灵宽,位数变宽度就变。
| 部件 | kind | 参数(实测估值) |
| --- | --- | --- |
| 叫分 · 面板底 | `point` | `x:355 y:140 w:575 h:170` |
| 叫分 · 提示条 | `point` | `x:470 y:100 w:345 h:33` |
| **叫分 · 14 档按钮** | `grid` | `anchorX:640 anchorY:160 cols:7 rows:2 itemWidth:74 itemHeight:56 spacingX:7 spacingY:24 anchor:center fillOrder:row` |
-| 叫分 · 档位分数数字 | `attach` | `target:对应档位按钮, hAlign:center, vAlign:middle, offsetY:4, w:NUM_STYLE.CALL_SCORE.width(74), h:NUM_STYLE.CALL_SCORE.charHeight(40)` |
+| 叫分 · 档位分数数字 | `attach` | `target:对应档位按钮, hAlign:center, vAlign:middle, offsetY:4, h:NUM_STYLE.CALL_SCORE.charHeight(40)`;`w` 运行时注入(= 字符数 × 30,由 `setNumberImage` 自动设) |
| 叫分 · 档位子数角标 | `attach` | `target:对应档位按钮, corner:topRight, offsetX:-30, offsetY:2` |
| 叫分 · `不叫` 按钮 | `point` | `x:553 y:345 w:184 h:57` |
| 叫分 · 自己叫分大字 | `point` | `x:570 y:320 w:140 h:70`,`textStyle{fontSize:52, align:center}` |
| 选主 · 标题斜角条 | `point` | `x:330 y:315 w:425 h:50` |
| **选主 · 4 花色 + 投降** | `line` | `direction:horizontal anchorX:330 anchorY:373 itemWidth:128 itemHeight:55 spacing:9 anchor:left`(共 5 项;投降不显示时只排 4 项,`anchor:left` 保证前 4 个位置不变) |
-| 选主 · 张数 | `attach` | `target:对应花色按钮, hAlign:center, vAlign:bottom, offsetY:-6, w:NUM_STYLE.MAIN_SUIT_COUNT.width(48), h:NUM_STYLE.MAIN_SUIT_COUNT.charHeight(32)`(一个花色一个精灵,两位数由叠绘逐位画) |
+| 选主 · 张数 | `attach` | `target:对应花色按钮, hAlign:center, vAlign:bottom, offsetY:-6, h:NUM_STYLE.MAIN_SUIT_COUNT.charHeight(32)`;`w` 运行时注入(`9张`=2 字符、`26张`=3 字符,宽度随之变)。一个花色一个精灵 |
| 选主 · 对数角标文字 | `attach` | `target:对应花色按钮, corner:topRight, offsetX:-36, offsetY:2`(按钮帧内已含角标底,本节点只定位叠加文字) |
| 埋牌 · 操作条 | `line` + `items` | `direction:horizontal anchorX:640 anchorY:165 itemHeight:63 spacing:45 anchor:center`,`items:[180,60,180]`(提示 / 倒计时 / 埋牌) |
| 出牌 · 操作条 | `line` + `items` | `direction:horizontal anchorX:640 anchorY:345 itemHeight:55 spacing:35 anchor:center`,`items:[75,157]`(倒计时 / 出牌) |
@@ -1737,17 +1744,20 @@ CARD_SIZE: {
| 状态提示条 · 等待类 | `point` | `x:500 y:373 w:320 h:47`(§2.8) |
| 状态提示条 · 即时反馈类 | `point` | `x:462 y:172 w:363 h:50`;另配 `toastDuration:1500` |
-**数字样式配置 `EQW_Layout.NUM_STYLE`**(2026-08-26 新增,在 `LayoutConstants.js`,与 `CARD_SIZE`/`TEXT_STYLE` 并列)——图集叠绘数字的**唯一数值出处**,业务代码不许内联这些值:
+**多帧图数字样式配置 `EQW_Layout.NUM_STYLE`**(2026-08-26 新增,在 `LayoutConstants.js`,与 `CARD_SIZE`/`TEXT_STYLE` 并列)——`setNumberImage` 所需数值的**唯一出处**,业务代码不许内联:
-| 键 | `res`(资源键名) | `charWidth × charHeight` | `spacing` | `align` | `width`(绘制区宽) | 说明 |
-| --- | --- | --- | --- | --- | --- | --- |
-| `COUNTDOWN` | `NUM_COUNTDOWN` | 44 × 60 | 0 | center | 88 | 秒数最多两位 |
-| `RESULT_WIN` | `NUM_RESULT_WIN` | 46 × 62 | 0 | center | 230 | 符号 + 最多四位 |
-| `RESULT_LOSE` | `NUM_RESULT_LOSE` | 46 × 62 | 0 | center | 230 | 同上,仅配色不同 |
-| `CALL_SCORE` | `NUM_CALL_SCORE` | 30 × 40 | 0 | center | 74 | = 档位按钮宽,居中压在按钮上 |
-| `MAIN_SUIT_COUNT` | `NUM_MAIN_SUIT_COUNT` | 24 × 32 | 0 | center | 48 | 单花色最多 26 张 |
+| 键 | `res`(资源键名) | `charWidth` | `charHeight` | `suffix`(第16帧后缀) | 典型显示 |
+| --- | --- | --- | --- | --- | --- |
+| `COUNTDOWN` | `NUM_COUNTDOWN` | 44 | 60 | `''`(无) | `15` |
+| `RESULT_WIN` | `NUM_RESULT_WIN` | 46 | 62 | `''`(第16帧待确认) | `+120` |
+| `RESULT_LOSE` | `NUM_RESULT_LOSE` | 46 | 62 | `''`(同上) | `-120` |
+| `CALL_SCORE` | `NUM_CALL_SCORE` | 30 | 40 | `''`(第16帧待确认) | `70` |
+| `MAIN_SUIT_COUNT` | `NUM_MAIN_SUIT_COUNT` | 24 | 32 | `张` | `26张` |
-> `res` 存的是**资源键名**而非 ID(与 `CARD_SIZE.res` 同一约定):布局文件是纯数据、不引用其他模块,调用方用 `EQW_Images[style.res]` 取真实 ID 再传给 `NumberRenderer.bind`。
+> - `charWidth` 是 `setNumberImage` 的入参,**精灵宽度 = 字符数 × `charWidth`**(该接口自动设),所以布局节点的 `w` 是运行时值、不写死在配置里。
+> - `charHeight` 供布局节点定 `h`(`setNumberImage` 不改高度)。
+> - `suffix` 只能是 `''` / `分` / `倍` / `张`——接口内部把这三个字统一编码到第 16 帧,写别的字不会被识别。
+> - `res` 存的是**资源键名**而非 ID(与 `CARD_SIZE.res` 同一约定):布局文件是纯数据、不引用其他模块,调用方用 `EQW_Images[style.res]` 取真实 ID。
### 6.9 结算区配置