
1. 数字块生成在消除类项目中的定位与整体设计数字块生成这个环节放在整个三消或合成类项目里属于数据驱动表现的典型场景。很多刚接触 CocosCreator 的朋友会把它想得特别简单——不就是创建一个带数字的节点吗但真正做过完整项目的人都知道这里面牵扯到的东西远比表面复杂数字块的视觉表现要跟数值绑定、不同数值要有不同的颜色或样式、生成位置要跟棋盘格坐标系统对齐、还要考虑后续的消除、下落、合并等逻辑如何高效地访问这些块。我在多个合成类项目中反复打磨过这套机制踩过的坑包括但不限于数字块用 Label 逐个渲染导致 DrawCall 飙升、数值变化时忘记刷新样式导致显示错乱、生成位置计算用了浮点数导致块与格子对不齐、对象池回收时状态没重置导致下次复用时显示异常。这些问题在项目初期不明显一旦块的数量上到几十上百个性能问题和逻辑 bug 就会集中爆发。所以这篇文章的核心思路是把数字块当作一个独立的数据实体来设计表现层与数据层分离生成过程走对象池样式刷新走统一入口。这样做的优势在于后续无论你要加新的数字类型、改配色方案、还是接入消除逻辑都不需要动生成模块的核心代码只需要扩展配置和回调即可。适合阅读这篇文章的读者包括正在做三消、合成、2048 类玩法的开发者对 CocosCreator 节点管理、对象池、Label 渲染优化感兴趣的朋友以及想了解数据驱动 UI在游戏开发中如何落地的人。即使你之前没接触过 CocosCreator只要理解基本的节点树概念也能跟上思路。2. 核心细节解析与实操要点2.1 数字块的数据结构设计数字块本质上是一个值 位置 状态的载体。我见过不少项目直接把数字存在 Label 的 string 里位置存在 node.position 里状态用 node.active 表示。这种做法在块数量少的时候没问题但一旦需要做批量操作——比如全场数值翻倍、按数值排序、查找某个值的所有块——就会非常痛苦因为你没有统一的数据入口。我的做法是定义一个纯数据类跟节点解耦// NumberBlockData.ts export class NumberBlockData { public id: number; // 唯一标识用于对象池回收时校验 public value: number; // 当前数值 public row: number; // 棋盘行索引 public col: number; // 棋盘列索引 public state: BlockState; // 状态枚举Idle / Moving / Merging / Destroying constructor(id: number, value: number, row: number, col: number) { this.id id; this.value value; this.row row; this.col col; this.state BlockState.Idle; } }这里有几个设计决策值得展开说。为什么用 row/col 而不是直接存 position因为棋盘类玩法的所有逻辑判断——相邻检测、下落、合并——都是基于格子索引的用行列索引做逻辑用行列索引换算成像素坐标做表现两者分离后逻辑层完全不需要关心屏幕分辨率。为什么要有 id对象池回收节点后节点可能被复用来显示另一个数据块如果没有 id 校验异步回调比如动画结束回调可能会作用到错误的块上这是我在实际项目中真实踩过的坑。为什么要有 state防止重复触发。比如一个块正在执行合并动画此时又收到一次合并请求如果没有状态拦截就会出现数值加了两次的 bug。2.2 对象池的接入与节点复用策略数字块在游戏中是高频创建和销毁的对象。一局游戏下来块的生成和回收可能上千次。如果每次都instantiate一个新节点GC 压力会非常大在中低端机上表现为周期性的卡顿。CocosCreator 内置了NodePool但直接用原生 NodePool 有几个细节要注意。首先是预热。对象池刚创建时是空的第一次生成块时仍然会走 instantiate如果开局一次性生成几十个块就会有明显的瞬时卡顿。我的做法是在游戏初始化阶段预创建一批节点放入池中// BlockPool.ts import { Node, NodePool, Prefab, instantiate } from cc; export class BlockPool { private pool: NodePool new NodePool(); private prefab: Prefab; constructor(prefab: Prefab, prewarmCount: number 30) { this.prefab prefab; for (let i 0; i prewarmCount; i) { this.pool.put(instantiate(prefab)); } } public get(): Node { if (this.pool.size() 0) { return this.pool.get(); } return instantiate(this.prefab); } public put(node: Node): void { // 回收前必须重置状态 node.active false; node.setPosition(0, 0); this.pool.put(node); } }预热数量怎么定我的经验值是单屏最大可见块数 × 1.5。比如 6×6 的棋盘最多 36 个块预热 50 个左右比较稳妥。预热太多浪费内存太少则开局仍会 instantiate。其次是回收时的状态重置。这是最容易出问题的地方。节点被回收后它的 Label 文字、颜色、缩放、透明度、旋转都还保留着上次的状态。下次从池里取出来如果不重置就会出现新生成的块显示的是旧数值这种诡异现象。我的做法是封装一个resetBlock方法在get()之后立即调用把所有可变状态归位。注意不要依赖node.destroy()来回收。destroy 是真正的销毁节点会从场景树移除并等待 GC这跟对象池的复用思路是冲突的。对象池的正确用法是pool.put(node)节点只是被移出场景树内存还在。2.3 数值到视觉的映射规则数字块的核心视觉是数值决定外观。2 是浅色、4 是稍深、8 是橙色、16 是红色……这套映射如果写成一堆 if-else后期改配色会非常痛苦。我推荐用配置表驱动// BlockStyleConfig.ts export interface BlockStyle { bgColor: string; // 背景色 textColor: string; // 文字颜色 fontSize: number; // 字号 } export const BLOCK_STYLE_MAP: Recordnumber, BlockStyle { 2: { bgColor: #EEE4DA, textColor: #776E65, fontSize: 48 }, 4: { bgColor: #EDE0C8, textColor: #776E65, fontSize: 48 }, 8: { bgColor: #F2B179, textColor: #FFFFFF, fontSize: 48 }, 16: { bgColor: #F59563, textColor: #FFFFFF, fontSize: 44 }, 32: { bgColor: #F67C5F, textColor: #FFFFFF, fontSize: 44 }, 64: { bgColor: #F65E3B, textColor: #FFFFFF, fontSize: 44 }, 128: { bgColor: #EDCF72, textColor: #FFFFFF, fontSize: 40 }, 256: { bgColor: #EDCC61, textColor: #FFFFFF, fontSize: 40 }, 512: { bgColor: #EDC850, textColor: #FFFFFF, fontSize: 40 }, 1024: { bgColor: #EDC53F, textColor: #FFFFFF, fontSize: 36 }, 2048: { bgColor: #EDC22E, textColor: #FFFFFF, fontSize: 36 }, };字号为什么要随数值递减因为数值位数增加后同样的字号会撑破块的可视区域。2 是 1 位数2048 是 4 位数如果都用 48 号字2048 会溢出。我实测下来每增加一位数字字号减 4 左右比较协调。当然这个值跟你的块尺寸有关块越大可以减得越少。为什么用十六进制字符串而不是 Color 对象因为配置表通常从 JSON 或 Excel 导出字符串更通用运行时再new Color().fromHEX()转换即可。而且字符串在配置表里肉眼可读改配色时不用去查 Color 的数值。对于超出配置表的数值比如玩家玩到了 4096、8192需要一个兜底规则。我的做法是取配置表里最大的那个样式然后按位数动态调整字号public getStyle(value: number): BlockStyle { if (BLOCK_STYLE_MAP[value]) { return BLOCK_STYLE_MAP[value]; } // 兜底用 2048 的配色字号按位数递减 const base BLOCK_STYLE_MAP[2048]; const digits value.toString().length; return { bgColor: base.bgColor, textColor: base.textColor, fontSize: Math.max(24, base.fontSize - (digits - 4) * 4) }; }2.4 坐标系统与格子对齐这是新手最容易翻车的地方。棋盘逻辑用行列索引但节点位置用像素坐标两者之间的换算必须精确否则会出现块看起来在格子里但点击检测判定在隔壁的问题。换算公式本身很简单pixelX originX col * (blockWidth gapX) pixelY originY - row * (blockHeight gapY)其中originX/originY是棋盘左上角第一个格子的中心点坐标blockWidth/blockHeight是块尺寸gapX/gapY是块之间的间距。注意 Y 轴是减号因为 CocosCreator 的坐标系 Y 轴向上而行索引向下递增。关键点在于所有位置计算必须用整数或统一精度。我见过有项目用Math.random()生成初始位置做动画结果动画结束后位置带小数导致后续对齐检测用比较时永远不相等。正确做法是逻辑位置永远用整数行列索引只在最终设置节点位置时做一次换算且换算结果可以保留小数因为像素坐标允许小数但逻辑判断永远基于行列索引。public getPosition(row: number, col: number): Vec3 { const x this.originX col * (this.blockWidth this.gapX); const y this.originY - row * (this.blockHeight this.gapY); return new Vec3(x, y, 0); }提示如果你的棋盘是居中的originX 和 originY 需要根据棋盘总宽高和屏幕尺寸动态计算。我通常会在棋盘容器节点上挂一个脚本在onLoad里根据view.getVisibleSize()算出 origin这样适配不同分辨率时不用改代码。3. 实操过程与核心环节实现3.1 从零搭建数字块预制体先说预制体的结构。我的标准结构是这样的Block (Node) // 根节点挂 BlockView 脚本 ├── Bg (Sprite) // 背景九宫格拉伸 └── Label (Label) // 数值文字为什么背景用 Sprite 而不是 Graphics因为 Sprite 支持九宫格Sliced模式一张小图就能拉伸成任意尺寸的圆角矩形而 Graphics 每次重绘都有 CPU 开销。在块数量多的时候Sprite 的渲染批次合并也更友好。为什么 Label 单独一层而不是挂在根节点因为 Label 需要独立控制位置和缩放挂在根节点上虽然也能用但后续如果要做文字弹跳动画独立节点更方便做锚点和 pivot 调整。预制体做好后在根节点上挂BlockView脚本暴露两个属性给编辑器import { _decorator, Component, Sprite, Label, Color } from cc; const { ccclass, property } _decorator; ccclass(BlockView) export class BlockView extends Component { property(Sprite) public bgSprite: Sprite null; property(Label) public valueLabel: Label null; private _data: NumberBlockData null; public setData(data: NumberBlockData): void { this._data data; this.refreshView(); } public refreshView(): void { if (!this._data) return; const style getStyle(this._data.value); this.valueLabel.string this._data.value.toString(); this.valueLabel.fontSize style.fontSize; this.valueLabel.color new Color().fromHEX(style.textColor); this.bgSprite.color new Color().fromHEX(style.bgColor); } }这里setData和refreshView分离是有意为之。setData负责绑定数据refreshView负责根据当前数据刷新表现。当数值变化时比如合并后只需要改_data.value然后调refreshView不需要重新 setData。这种分离在后续做数值动画时特别有用——你可以先播放动画动画结束再调 refreshView避免数值瞬间跳变。3.2 生成流程的完整实现生成一个数字块的完整流程我拆成五步确定生成位置从棋盘的空闲格子中随机选一个或者按预设规则指定行列。确定生成数值通常是 2 或 4概率可以配置比如 90% 出 210% 出 4。从对象池取节点调用pool.get()。绑定数据并刷新表现创建NumberBlockData调setData。设置位置并加入场景树设置节点位置parent.addChild(node)播放生成动画。代码实现// BlockGenerator.ts import { Node, Vec3, tween } from cc; export class BlockGenerator { private pool: BlockPool; private boardRoot: Node; private board: BoardModel; // 棋盘数据模型管理行列占用情况 public generateAt(row: number, col: number): Node { // 1. 校验位置是否空闲 if (!this.board.isEmpty(row, col)) { console.warn(位置 (${row}, ${col}) 已被占用); return null; } // 2. 确定数值 const value Math.random() 0.9 ? 2 : 4; // 3. 取节点 const node this.pool.get(); node.active true; // 4. 绑定数据 const data new NumberBlockData(this.nextId(), value, row, col); const view node.getComponent(BlockView); view.setData(data); // 5. 设置位置并加入场景 const pos this.board.getPosition(row, col); node.setPosition(pos); this.boardRoot.addChild(node); // 6. 标记棋盘占用 this.board.setOccupied(row, col, data); // 7. 生成动画从 0 缩放弹出 node.setScale(0, 0, 0); tween(node) .to(0.15, { scale: new Vec3(1, 1, 1) }, { easing: backOut }) .start(); return node; } }生成动画为什么用 backOut 缓动因为 backOut 会先冲过头再回弹视觉上有弹出来的感觉比线性缩放生动得多。0.15 秒是我实测下来比较舒服的时长太短看不出动画太长会拖慢游戏节奏。为什么先 setPosition 再 addChild其实顺序无所谓但先设位置再 addChild 可以避免节点在 (0,0) 位置闪现一帧。如果先 addChild节点会先在原点渲染然后才被移到目标位置在低帧率下可能看到闪烁。3.3 批量生成与初始棋盘填充游戏开局通常需要一次性生成多个块。如果逐个调用generateAt每个块都播放动画会显得很乱。我的做法是开局时静默生成不播动画等所有块就位后再统一播一个入场效果。public fillBoard(initialCount: number): void { const emptyCells this.board.getEmptyCells(); // 洗牌随机选 initialCount 个格子 this.shuffle(emptyCells); const targets emptyCells.slice(0, initialCount); for (const cell of targets) { const node this.generateAt(cell.row, cell.col); if (node) { // 取消生成动画直接设为最终状态 node.setScale(1, 1, 1); } } }这里有个细节generateAt内部启动了 tween但我们在外面立即把 scale 设为 1。tween 还在运行会继续修改 scale导致冲突。正确做法是给generateAt加一个animate参数public generateAt(row: number, col: number, animate: boolean true): Node { // ... 前面的逻辑不变 if (animate) { node.setScale(0, 0, 0); tween(node).to(0.15, { scale: new Vec3(1, 1, 1) }, { easing: backOut }).start(); } else { node.setScale(1, 1, 1); } return node; }注意tween 一旦启动除非调用Tween.stopAllByTarget(node)否则不会自动停止。如果你在 tween 运行期间手动改了 scale下一帧 tween 又会覆盖回去。所以要么不启动 tween要么先停止再改。3.4 数值刷新与合并后的表现更新合并是数字块玩法的高频操作。两个值为 2 的块合并成一个值为 4 的块涉及三个动作销毁两个旧块、生成一个新块或者复用一个旧块改数值、播放合并动画。我倾向于复用一个旧块而不是销毁再生成因为这样对象池的周转更少而且动画衔接更自然。具体做法public mergeBlocks(a: NumberBlockData, b: NumberBlockData): void { // 计算合并后的位置取两者中点或目标格 const targetRow b.row; const targetCol b.col; // 复用 b 的节点 const nodeB this.board.getNode(b); const viewB nodeB.getComponent(BlockView); // 更新数值 b.value a.value * 2; viewB.refreshView(); // 播放合并动画先放大再回弹 tween(nodeB) .to(0.1, { scale: new Vec3(1.2, 1.2, 1) }) .to(0.1, { scale: new Vec3(1, 1, 1) }) .start(); // 回收 a 的节点 const nodeA this.board.getNode(a); this.pool.put(nodeA); this.board.clearCell(a.row, a.col); }为什么先放大再回弹这是打击感的经典手法。合并是玩家获得正反馈的时刻一个轻微的放大回弹能让操作更有满足感。0.1 秒的放大 0.1 秒的回弹总共 0.2 秒不会拖慢节奏。合并后数值刷新要注意什么一定要在动画之前刷新数值而不是动画结束后。因为玩家在合并瞬间就期待看到新数值如果等动画结束才变会有延迟感。但如果你想让数值变化也有动画比如数字滚动那就需要另一套机制了这里先不展开。4. 常见问题与排查技巧实录4.1 数字块显示异常问题速查现象可能原因排查方法解决方案新生成的块显示旧数值对象池回收时未重置 Label在pool.put前打印 Label.string回收时重置 string 为空块颜色跟数值不匹配样式配置表 key 类型不匹配打印typeof value和配置表 key统一用 number 类型做 key块位置偏移半格origin 计算时用了块中心而非左上角打印第一个块的 position重新推导 origin 公式块重叠在一起棋盘占用标记未更新打印棋盘二维数组生成后立即 setOccupied块缩放异常tween 冲突或未停止打印 scale 值改 scale 前先 stopAllByTarget大量块时卡顿DrawCall 过高打开 profiler 看 DrawCall合并图集减少材质切换这个表是我在实际项目中遇到过的真实问题汇总。其中块位置偏移半格是最隐蔽的因为视觉上块确实在格子里但点击检测会偏。根源在于 origin 的定义如果你把 origin 定义为棋盘左上角格子的左上角那计算时就要加半个块宽如果定义为中心点就不用加。两种定义都行但必须全项目统一。4.2 对象池复用的三个隐蔽陷阱陷阱一异步回调持有旧引用。假设你有一个延迟 0.3 秒的回调回调里要修改某个块的颜色。如果这个块在 0.3 秒内被回收并复用给了另一个数据回调就会改错块。解决方案是用 id 校验const targetId data.id; this.scheduleOnce(() { if (data.id ! targetId) return; // 数据已变放弃 // 执行操作 }, 0.3);陷阱二事件监听未移除。如果块节点上注册了触摸事件回收时没移除下次复用时事件会叠加导致一次点击触发多次。解决方案是在onDisable里统一移除所有监听。陷阱三粒子或动画组件状态残留。如果块上有 ParticleSystem 或 Animation回收时它们的状态不会自动重置。下次取出时可能从上次的播放位置继续。解决方案是回收时显式调用stop()和clear()。提示我通常会在 BlockView 里加一个reset()方法把所有可变状态集中重置。回收前调一次取出后再调一次双保险。虽然多调一次有微小开销但能避免 90% 的复用 bug。4.3 性能优化的实测数据与经验我在一个 6×6 棋盘、满屏 36 个块的项目里做过对比测试数据如下方案DrawCall帧率中端机内存占用每个块独立 Label 独立 Sprite7245-50 fps较高合并图集 动态合批3-558-60 fps中等上述 对象池3-559-60 fps较低关键优化点是图集合并。所有块的背景图放在同一张图集里CocosCreator 会自动合批DrawCall 从 72 降到个位数。Label 如果用的是系统字体每个 Label 会单独占一个 DrawCall所以如果块数量多建议用 BMFont位图字体所有数字在同一张纹理上也能合批。对象池的收益主要体现在内存和 GC 上对帧率的直接提升不明显但能消除周期性的 GC 卡顿。在长时间游玩的场景下这个收益非常关键。4.4 数值溢出与边界处理数字块玩到后期数值会变得很大。JavaScript 的 Number 类型安全整数上限是 2^53对于 2 的幂次增长到 2^53 需要合并 53 次正常游戏很难达到。但显示上会先出问题位数太多字号再小也放不下。我的处理策略是超过一定位数后用科学计数法或缩写显示。比如 1024 显示 1K1048576 显示 1M。这样既保留了数值信息又不会撑破布局。具体阈值可以配置我一般从 10000 开始启用缩写。public formatValue(value: number): string { if (value 10000) return value.toString(); if (value 1000000) return (value / 1000).toFixed(1) K; return (value / 1000000).toFixed(1) M; }注意显示用格式化字符串但逻辑判断和存储永远用原始数值。不要因为显示成 1K 就把数据也存成 1000那样会丢失精度后续合并计算会出错。5. 扩展思路与进阶玩法数字块生成这套机制搭好之后其实可以延伸出很多玩法。比如生成权重动态调整——前期多出小数值后期逐渐提高大数值的出现概率让游戏节奏更平滑。实现方式是把固定的 90%/10% 概率改成一个随回合数变化的函数。再比如特殊块生成——每隔一定回合生成一个炸弹块或万能块这类块不走常规的数值映射而是有独立的样式和逻辑。我的做法是在NumberBlockData里加一个type字段生成时根据规则决定 type表现层根据 type 走不同的刷新分支。这样扩展不需要改动生成流程的主干只需要在分支里加 case。还有一个实用的扩展是生成预览——在块真正落地前先显示一个半透明的虚影让玩家预判位置。这个功能在需要玩家选择生成位置的玩法里特别有用。实现上就是生成一个临时的半透明节点玩家确认后再替换成真实块。我在实际项目里最深的一点体会是生成模块的复杂度不在于生成本身而在于它跟棋盘状态、对象池、动画系统的耦合。把这几个边界划清楚——棋盘只管占用标记对象池只管节点复用动画只管表现——生成模块就只是一个协调者代码量不大但稳定性极高。反过来如果让生成模块直接操作棋盘数组、直接 instantiate 节点、直接写动画代码后期改任何一处都会牵一发而动全身。最后分享一个小技巧在开发阶段给生成模块加一个调试开关打开后每次生成都在控制台打印(row, col, value)并且可以在编辑器里手动触发指定位置的生成。这个开关在排查某个块为什么出现在这里这类问题时能省下大量时间。上线前记得关掉或者用宏定义包起来避免影响性能。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。