1. 项目概述:从“玩”到“懂”的跨越
上次我们动手把“合成大西瓜”的架子搭了起来,让水果能掉、能碰、能合成,游戏算是能跑了。但不知道你有没有这种感觉:代码是跑通了,可心里还是有点虚,总觉得只是照猫画虎,里面的门道并没摸清。比如,为什么物理碰撞的代码要那么写?cc.Node和cc.Component到底啥关系?事件监听绑来绑去,到底谁在监听谁?如果我想加个新功能,比如让西瓜合成时有爆炸特效,该从哪儿下手?
这就是我们这次要解决的核心问题。光把游戏做出来不算完,我们得把它“拆开”,看看每个零件是怎么运转的。Cocos Creator 的强大,很大程度上就体现在它这套基于组件的架构上。你把一个复杂的游戏,看成是由一个个功能单一、可以拼装的“组件”构成的,开发思路瞬间就清晰了。今天,我们就聚焦在“合成大西瓜”这个案例里,把几个关键组件——物理碰撞、用户输入、动画状态管理——的原理掰开揉碎了讲。目标不是让你记住 API,而是理解其设计思想,以后无论遇到什么需求,你都能自己设计出合适的组件来应对。
2. 核心组件原理深度拆解
2.1 物理与碰撞组件:不只是“碰一下”
在“合成大西瓜”里,水果下落、相互堆叠、碰撞后合成,这一切的基础都是物理引擎。Cocos Creator 内置了两种物理系统:基于 Box2D 的物理系统和基于 Cannon.js 的物理系统。对于我们的 2D 游戏,默认且最常用的就是 Box2D。
2.1.1 RigidBody 与 Collider 的职责分离
这是理解物理系统的第一个关键点。很多新手会把它们混为一谈,其实它们各司其职:
- RigidBody(刚体组件):它定义了物体的“物理属性”。你可以把它想象成物体的“内在品质”。它负责回答:这个物体有多重(质量)?受到重力影响大吗(重力缩放)?它是动态的会自己动,还是静态的呆在那儿(类型)?摩擦力、弹性怎么样?
- Collider(碰撞体组件):它定义了物体的“形状和边界”。你可以把它想象成物体的“外在轮廓”。它只关心一件事:我的物理形状是什么样?(矩形、圆形、多边形等)。碰撞检测是基于这个形状来计算的。
在我们的游戏里,每个水果节点上,都同时挂载了RigidBody2D和CircleCollider2D(因为水果近似圆形)。RigidBody2D让水果具有质量、受重力下落;CircleCollider2D则告诉物理引擎:“请按一个圆形来检测我与其他物体的碰撞”。
2.1.2 物理世界的同步与更新
这里有一个至关重要的概念:物理世界独立于渲染世界。游戏画面每帧渲染一次(比如60帧每秒),但物理计算可以以不同的频率进行(通常也是60次每秒,但可以设置)。RigidBody组件的位置和旋转,是由物理引擎计算出来的。为了让画面能正确显示,Cocos Creator 在每一帧渲染前,会自动将物理引擎中刚体的位置、旋转数据,“同步”到对应节点的position和angle属性上。
你可能会在代码里看到rigidBody.syncPositionToNode()或rigidBody.syncRotationToNode(),这是在手动触发同步。但在大多数情况下,引擎的自动同步就足够了。理解这一点,你就不会困惑为什么直接修改node.position有时无法影响物理运动了。
2.1.3 碰撞检测与回调流程
当两个带有Collider的物体接触时,物理引擎会检测到碰撞。但检测到之后,如何通知我们的游戏逻辑呢?这就是碰撞回调函数的作用。流程如下:
- 物理引擎计算:Box2D 检测到两个碰撞体(A和B)发生接触。
- 生成碰撞信息:引擎生成一个包含碰撞点、法向量等信息的
IPhysics2DContact对象。 - 回调触发:Cocos Creator 会依次触发挂载在对应节点上的碰撞回调函数。触发顺序是:
onBeginContact:碰撞开始时,触发一次。onPreSolve:在碰撞反应被计算前触发,每帧都可能触发,你可以在这里修改碰撞参数(如摩擦力)。onPostSolve:在碰撞反应被计算后触发,可以获取碰撞冲量等信息。onEndContact:碰撞结束时,触发一次。
在我们的合成逻辑里,主要用到onBeginContact。当两个水果碰撞时,我们在这个回调里判断它们是否是同一种水果,如果是,则销毁当前两个,在合适位置生成一个更高级的水果。
实操心得:在
onBeginContact里进行合成判断时,一定要小心“重复触发”问题。因为两个水果可能在一段时间内持续接触,物理引擎可能会在连续几帧都报告“开始接触”。一个常见的做法是,在合成发生后,立即给其中一个或两个水果添加一个“已标记待销毁”的标签,或者在组件里设置一个_isProcessing布尔值,防止同一对水果在单次接触中被多次处理。
2.2 输入与控制组件:事件驱动的游戏交互
用户怎么控制水果的生成?这涉及到输入事件的处理。Cocos Creator 提供了全局的输入系统input。
2.2.1 鼠标/触摸事件的分发
对于鼠标点击或触摸屏幕,最常见的是使用node.on(cc.Node.EventType.TOUCH_START, callback, this)。这里有一个关键原理:事件冒泡。
当你在屏幕上点击时,事件会从最底层的节点(可能是个UI按钮)开始,逐级向上层父节点“冒泡”,直到根节点。每个节点都有机会处理这个事件。如果你在回调函数中调用了event.propagationStopped(),那么事件冒泡就会停止,不再传递给父节点。这常用于UI按钮的点击处理,防止点击了按钮还触发后面背景的逻辑。
在我们的案例中,通常是在Canvas节点或者一个专门的游戏控制节点上监听TOUCH_START事件,根据点击的X坐标,计算生成水果的位置。
2.2.2 键盘与重力感应输入
除了触摸,键盘输入也是调试和扩展功能的好帮手。通过cc.systemEvent.on(cc.SystemEvent.EventType.KEY_DOWN, callback, this)可以监听键盘事件。比如,你可以用空格键快速生成一个测试水果。
对于移动设备,还可以利用重力感应(加速度计)。通过cc.systemEvent.setAccelerometerEnabled(true)开启,并在cc.SystemEvent.EventType.DEVICEMOTION事件回调中获取event.acc对象,包含 x, y, z 三个方向的加速度。你可以用这个数据来轻微影响游戏世界(比如让水果堆微微倾斜),增加趣味性。但要注意,处理这类输入时通常需要乘以一个系数并做平滑滤波,防止画面抖动过于剧烈。
2.2.3 输入管理与防抖
在“合成大西瓜”中,玩家连续快速点击可能会意外生成多个水果。从组件设计角度,一个良好的实践是创建一个InputManager单例组件。这个组件专门负责所有输入的监听和预处理。
例如,在InputManager的touchStart回调中,你可以加入一个简单的防抖逻辑:记录上次生成水果的时间戳,如果与当前时间间隔小于某个阈值(如0.5秒),则忽略此次输入。这样,游戏逻辑组件(如GameManager)只需从InputManager获取“干净”的、经过处理的输入指令,职责更加清晰。
// 伪代码示例:InputManager 中的简单防抖 export class InputManager extends cc.Component { private _lastSpawnTime: number = 0; private _spawnInterval: number = 0.5; // 生成间隔0.5秒 start() { this.node.on(cc.Node.EventType.TOUCH_START, this.onTouchStart, this); } onTouchStart(event: cc.Event.EventTouch) { let currentTime = Date.now() / 1000; // 转换为秒 if (currentTime - this._lastSpawnTime < this._spawnInterval) { return; // 间隔太短,忽略输入 } this._lastSpawnTime = currentTime; // 处理有效的触摸事件,例如计算位置并通知游戏管理器 let touchPos = event.getLocation(); // ... 坐标转换逻辑 ... // 通过自定义事件或直接调用方法,通知 GameManager 生成水果 this.gameManager.spawnFruitAt(worldPos); } }2.3 动画与状态组件:让合成更有“感觉”
水果合成时,如果只是“啪”一下旧水果消失、新水果出现,会非常生硬。加入动画和状态管理,体验立刻提升一个档次。
2.3.1 使用 Animation 组件处理合成特效
Cocos Creator 的cc.Animation组件可以播放序列帧动画或属性动画。对于合成特效,一个典型的做法是:
- 创建一个名为
Explosion或MergeEffect的预制体(Prefab)。 - 在这个预制体的节点上添加
cc.Animation组件,并制作一个动画剪辑(AnimationClip)。这个剪辑可以包含缩放(从0变大再变小)、透明度变化(从0到1再到0)、以及可能的一些粒子效果。 - 在合成逻辑中,当两个水果满足条件时,先不立即销毁它们。而是:
- 计算新水果应该出现的位置(通常是两个旧水果的中点)。
- 实例化这个特效预制体
cc.instantiate(effectPrefab),并设置到计算好的位置。 - 播放特效动画
effectNode.getComponent(cc.Animation).play(‘explode’)。 - 监听动画播放完成事件(或使用
cc.tween延时),在回调函数里再执行销毁旧水果、生成新水果的逻辑。
2.3.2 通过自定义组件管理水果状态
水果在整个生命周期中有多种状态:IDLE(待生成)、FALLING(下落中)、SETTLED(已静止)、MERGE_PENDING(等待合成)、MERGE_COMPLETE(已合成)。用一个简单的枚举来管理这些状态,会让逻辑清晰很多。
我们可以创建一个FruitState组件挂载在每个水果上:
// FruitState.ts export enum EFruitState { IDLE = 0, FALLING, SETTLED, MERGE_PENDING, // 已标记要合成,等待特效播放等 MERGE_COMPLETE } @ccclass(‘FruitState’) export class FruitState extends cc.Component { private _currentState: EFruitState = EFruitState.IDLE; get state(): EFruitState { return this._currentState; } set state(newState: EFruitState) { let oldState = this._currentState; this._currentState = newState; // 状态改变时,可以触发一些逻辑,例如更新显示 this.onStateChanged(oldState, newState); } private onStateChanged(oldState: EFruitState, newState: EFruitState) { // 例如,当状态变为 SETTLED 时,可以播放一个轻微的弹跳动画 if (newState === EFruitState.SETTLED && oldState === EFruitState.FALLING) { cc.tween(this.node) .to(0.1, { scale: 1.1 }) .to(0.1, { scale: 1.0 }) .start(); } // 当状态变为 MERGE_PENDING 时,可以改变颜色或透明度提示玩家 if (newState === EFruitState.MERGE_PENDING) { this.node.color = cc.Color.GRAY; } } }然后在GameManager的合成逻辑里,就可以这样写:
let fruitStateA = fruitA.getComponent(FruitState); let fruitStateB = fruitB.getComponent(FruitState); if (fruitStateA.state === EFruitState.SETTLED && fruitStateB.state === EFruitState.SETTLED && fruitA.fruitType === fruitB.fruitType) { // 标记状态,防止重复处理 fruitStateA.state = EFruitState.MERGE_PENDING; fruitStateB.state = EFruitState.MERGE_PENDING; // 播放合成特效... // 特效播放完成后,再执行实际合成,并将新水果状态设为 FALLING }这种状态驱动的方式,使得复杂的交互逻辑变得条理清晰,易于调试和扩展。
3. 游戏逻辑核心:合成与分数系统实现
3.1 合成判定逻辑的优化
基础的合成判定很简单:碰撞的两个水果类型相同,则合成下一个等级的水果。但这里有几个细节需要优化,它们直接影响到游戏体验的“手感”。
3.1.1 位置计算:不仅仅是取中点
最简单的做法是在两个旧水果的中点生成新水果。但这在堆叠紧密时可能有问题:新水果可能“嵌”进其他静止的水果里,导致非预期的连锁合成。更稳健的做法是:
- 计算中点位置。
- 从这个中点位置向上(Y轴正方向)发射一条短的射线(使用物理系统的
PhysicsRayCast)。 - 如果射线没有碰到任何碰撞体,就在中点生成。
- 如果射线碰到了其他静止的水果,则在中点上方一定距离(例如新水果的半径+一点间隙)生成,确保新水果落在堆叠的顶部。
3.1.2 合成链与延迟处理
当一次合成发生后,新生成的水果可能立刻与下方另一个同类型水果接触,触发第二次合成,这就是“合成链”。为了正确且流畅地处理合成链,我们需要引入“延迟处理”机制。
我们不能在onBeginContact回调里立即销毁和生成,因为这会改变物理世界,可能影响同一帧内其他尚未处理的碰撞。一个标准的做法是:
- 在
onBeginContact里,只将满足合成条件的一对水果信息(如它们的节点ID或引用)添加到一个“待合成队列”中。 - 在每帧更新结束时(例如在
lateUpdate函数中),统一处理这个队列。 - 处理时,从队列中取出一对,执行销毁、播放特效、生成新水果的逻辑。
- 生成新水果后,检查它是否与任何现有水果接触(可以通过物理查询
PhysicsCircleCast),如果接触且类型相同,则将新的一对也加入“待合成队列”,等待下一帧处理。
这样,无论多复杂的连锁反应,都能被有序、稳定地处理,避免了同一帧内对物理世界和节点树的频繁、交错修改可能引发的错误。
3.2 分数与连击系统的组件化设计
分数系统看似简单,但设计得好,能极大提升游戏的正反馈。我们将其拆分为几个组件:
3.2.1 ScoreManager:数据核心
这是一个单例组件,负责存储和更新当前分数、连击数、历史最高分等数据。它提供增加分数的方法,并负责将数据变化通过事件通知给UI。
// ScoreManager.ts @ccclass(‘ScoreManager’) export class ScoreManager extends cc.Component { private _currentScore: number = 0; private _combo: number = 0; private _maxCombo: number = 0; // 自定义事件名 public static readonly EventScoreUpdated = ‘score-updated’; public static readonly EventComboUpdated = ‘combo-updated’; public addScore(baseValue: number, isCombo: boolean = false) { let addedScore = baseValue; if (isCombo && this._combo > 0) { // 连击加成,例如每连击一次,额外获得20%分数 addedScore *= (1 + this._combo * 0.2); } this._currentScore += Math.floor(addedScore); // 派发事件,通知UI更新 cc.systemEvent.emit(ScoreManager.EventScoreUpdated, this._currentScore); } public increaseCombo() { this._combo++; if (this._combo > this._maxCombo) { this._maxCombo = this._combo; } cc.systemEvent.emit(ScoreManager.EventComboUpdated, this._combo); } public resetCombo() { this._combo = 0; cc.systemEvent.emit(ScoreManager.EventComboUpdated, this._combo); } }3.2.2 UIScoreDisplay:表现层
这是一个挂在UI分数文本节点上的组件。它只做一件事:监听ScoreManager发出的事件,并更新文本显示。它不关心分数怎么算,只负责展示。
// UIScoreDisplay.ts @ccclass(‘UIScoreDisplay’) export class UIScoreDisplay extends cc.Component { @property(cc.Label) scoreLabel: cc.Label = null; @property(cc.Label) comboLabel: cc.Label = null; onLoad() { // 监听分数更新事件 cc.systemEvent.on(ScoreManager.EventScoreUpdated, this.onScoreUpdated, this); cc.systemEvent.on(ScoreManager.EventComboUpdated, this.onComboUpdated, this); } onScoreUpdated(score: number) { this.scoreLabel.string = `分数:${score}`; // 可以添加一个简单的动画,如缩放一下 cc.tween(this.scoreLabel.node) .to(0.1, { scale: 1.2 }) .to(0.1, { scale: 1.0 }) .start(); } onComboUpdated(combo: number) { if (combo > 1) { this.comboLabel.string = `连击 x${combo}`; this.comboLabel.node.active = true; } else { this.comboLabel.node.active = false; } } }3.2.3 连击的逻辑集成
连击的逻辑与合成判定紧密相关。在GameManager处理一次合成后,它需要通知ScoreManager增加连击数increaseCombo()。同时,需要一个计时器或状态来判断连击是否中断。例如,可以设定:如果3秒内没有新的合成发生,则连击中断resetCombo()。这个计时器可以放在GameManager或ScoreManager中。
这种组件化的设计,将数据(ScoreManager)、逻辑(GameManager中的合成判定)、表现(UIScoreDisplay)清晰地分离,符合“单一职责原则”,使得代码易于维护和扩展。如果你想更换分数显示样式,只需修改UIScoreDisplay组件,完全不影响核心逻辑。
4. 性能优化与高级技巧
当游戏中的水果越来越多,特效越来越复杂时,性能就可能成为问题。以下是一些针对“合成大西瓜”这类游戏的优化思路。
4.1 节点池(cc.NodePool)的深度应用
我们之前提到了用节点池来管理水果的生成和回收,避免频繁的cc.instantiate和destroy。这里再深入几个使用技巧:
4.1.1 为不同类型对象使用多个节点池
不要把所有水果都塞进一个池子。为每种水果预制体单独创建一个节点池。这样在获取get时,你得到的就是确定类型的水果,无需额外的类型判断或重置操作。
export class FruitPoolManager extends cc.Component { private _poolMap: Map<number, cc.NodePool> = new Map(); // key: 水果类型, value: 对应的节点池 initPool(prefab: cc.Prefab, fruitType: number, poolSize: number) { let pool = new cc.NodePool(); for (let i = 0; i < poolSize; i++) { let newNode = cc.instantiate(prefab); // 可以在这里给节点挂上初始组件或设置初始数据 newNode.getComponent(‘Fruit’).fruitType = fruitType; pool.put(newNode); } this._poolMap.set(fruitType, pool); } getFruit(fruitType: number): cc.Node { let pool = this._poolMap.get(fruitType); if (pool && pool.size() > 0) { return pool.get(); } else { // 池为空,动态实例化一个(应尽量避免走到这里) console.warn(`Pool for fruitType ${fruitType} is empty, instantiating new one.`); // ... 根据类型获取预制体并实例化 ... } } putFruit(node: cc.Node, fruitType: number) { let pool = this._poolMap.get(fruitType); if (pool) { // 放回池子前,重置节点状态(非常重要!) node.position = cc.v3(0, 0); node.scale = 1; node.active = false; // 重置物理状态(如果有RigidBody) let rb = node.getComponent(cc.RigidBody2D); if (rb) { rb.linearVelocity = cc.v2(0, 0); rb.angularVelocity = 0; rb.awake = false; // 让刚体“睡眠”,减少物理计算 } pool.put(node); } else { node.destroy(); } } }4.1.2 池化对象的“重置”至关重要
对象放回池子前,必须将其状态重置到初始值。这包括:
- 变换属性:
position,rotation,scale。 - 节点活性:
active = false。 - 组件状态:如
FruitState组件中的状态机重置为IDLE,RigidBody的速度清零并设置为睡眠状态。 - 可视化状态:
color,opacity等恢复默认。
忘记重置是导致节点池复用对象时出现各种诡异 Bug 的最常见原因。
4.2 渲染优化:Draw Call 与合批
Cocos Creator 在渲染时会尝试将使用相同材质和纹理的节点进行“合批”,以减少 GPU 的绘制调用(Draw Call)。Draw Call 越少,渲染效率越高。
4.2.1 检查与优化 Draw Call
在编辑器运行时,可以打开调试 -> 显示 Draw Call来查看当前场景的 Draw Call 数量。对于“合成大西瓜”,所有水果如果使用同一张图集(Texture Atlas),那么它们很可能被合批,只产生1个或很少的 Draw Call。如果每个水果是单独的图片文件,则可能产生大量 Draw Call。
优化建议:
- 使用图集:将所有水果精灵(Sprite)的图片打包到一张或少数几张图集中。这是减少 2D 游戏 Draw Call 最有效的手段。
- 注意渲染顺序:合批通常要求节点在渲染队列中是连续的。如果两个使用相同材质的节点之间,插入了一个使用不同材质的节点(比如一个半透明的遮罩层),可能会打断合批。可以通过调整节点的
zIndex或修改Sprite组件的renderOrder来尝试优化渲染顺序。 - 谨慎使用 Mask 和 Graphics:
cc.Mask和cc.Graphics组件会打断合批。如果非用不可,尽量将其影响范围控制在小区域内。
4.2.2 动态与静态节点的分离
对于已经静止、不会再移动或改变状态的水果(即状态为SETTLED的),可以考虑将其从动态物理世界“冻结”。虽然 Cocos Creator 的物理引擎会对睡眠的刚体进行优化,但我们还可以从渲染层面思考。
一个进阶思路是:将静止的水果“烘焙”到一张动态生成的纹理上,然后用一个大的Sprite节点来显示这张纹理,同时将原来的大量静止水果节点隐藏或移出渲染树。这能极大地减少节点数量和 Draw Call。但这属于比较高级的优化,实现复杂,需要权衡收益和开发成本。对于“合成大西瓜”这个体量的游戏,通常使用图集并确保合批不被意外打断就足够了。
4.3 内存与资源管理
4.3.1 纹理资源的加载与释放
使用cc.resources.load或 Asset Bundle 动态加载资源时,务必注意释放。对于在游戏过程中不再需要的资源(比如低等级水果的纹理,在游戏后期几乎不会出现),可以在合适的时机调用cc.resources.release进行释放。
一个常见的模式是:根据水果的等级,将纹理分组到不同的 Asset Bundle 中。当游戏进行到中后期,可以异步卸载低等级水果所在的 Bundle,释放内存。
4.3.2 避免内存泄漏:事件监听与节点引用
这是 JavaScript/TypeScript 项目的老问题,但在 Cocos 中尤为突出:
- 事件监听:使用
node.on或systemEvent.on监听事件后,必须在组件销毁时(onDestroy生命周期)使用node.off或systemEvent.off取消监听。否则,回调函数中可能持有对组件或节点的引用,导致它们无法被垃圾回收。 - 节点引用:避免在全局对象或长生命周期的对象中持有大量临时节点的引用。当节点不再需要时,确保没有其他地方引用它,以便节点池能正常回收或引擎能销毁它。
养成在onDestroy中进行清理的习惯:
onDestroy() { // 取消事件监听 this.node.off(cc.Node.EventType.TOUCH_START, this.onTouchStart, this); cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.onKeyDown, this); // 如果监听了自定义事件,也要取消 cc.systemEvent.off(ScoreManager.EventScoreUpdated, this.onScoreUpdated, this); // 清空对其它节点的引用(如果是临时引用) this._someReferenceNode = null; }5. 常见问题排查与调试技巧
即使理解了原理,实际开发中还是会遇到各种问题。这里记录一些典型问题的排查思路。
5.1 物理碰撞相关疑难杂症
问题1:水果偶尔会“穿模”或重叠在一起,没有触发碰撞。
- 可能原因1:刚体类型设置错误。确保下落的水果刚体类型是
Dynamic(动态),而静止的边界或地面的刚体类型是Static(静态)。Kinematic(运动学)类型需要代码控制移动,不适合自由落体。 - 可能原因2:碰撞体形状或大小不匹配。检查
CircleCollider2D的radius是否与水果精灵的视觉大小匹配。可以在编辑器中将碰撞体的edit属性勾选上,在场景中查看绿色的碰撞体轮廓线。 - 可能原因3:物理步长问题。如果物体速度过快(比如一帧移动了很长的距离),可能会“穿越”另一个薄薄的碰撞体。可以尝试在
cc.PhysicsManager的工程设置中,增加物理更新的频率(减小fixedTimeStep),或者启用useFixedTimeStep。更根本的解决方法是限制水果生成时的初始速度。 - 排查工具:使用物理调试绘制。在代码中调用
cc.director.getPhysicsManager().enabledDebugDraw = true;,可以在场景中看到所有碰撞体的轮廓和刚体的速度向量,非常直观。
问题2:合成时新水果的位置总是不对,有时会卡进墙里。
- 原因:如3.1.1节所述,简单取中点可能不靠谱。
- 解决方案:实现更健壮的位置计算逻辑,结合射线检测。确保生成位置是“合法”的。
- 调试方法:在生成新水果的代码处,临时绘制一个标记(比如实例化一个小红点)到计算出的目标位置,观察它是否如你所愿。
5.2 动画与特效播放异常
问题:合成特效播放一次后,再次播放时不起作用或显示异常。
- 可能原因1:节点池复用导致的状态残留。特效节点也应用节点池管理。放回池子前,必须重置
cc.Animation组件状态:animation.stop(); animation.setCurrentTime(0);。同时,要确保cc.Sprite或其他渲染组件的属性(如color,opacity)被重置。 - 可能原因2:动画剪辑(AnimationClip)的 WrapMode 设置。确保特效动画的
WrapMode设置为Normal(播放一次)而不是Loop(循环播放)。或者,在播放动画时指定播放次数:animation.play(‘explode’, 0)表示播放一次。 - 排查方法:在特效播放的开始和结束回调中加入
console.log,确认播放流程是否按预期执行。
5.3 性能问题快速定位
问题:游戏运行一段时间后(水果很多时)变得卡顿。
- 第一步:定位瓶颈。使用浏览器的开发者工具(F12)中的Performance或Profiler面板录制一段卡顿时的操作。查看时间线,是 JavaScript 执行时间长(Scripting),还是渲染时间长(Rendering),或者是物理计算(Physics)耗时多。
- 第二步:针对性优化。
- Scripting 耗时高:检查自己的游戏逻辑,尤其是
update函数中是否有复杂的循环或频繁的查找操作。考虑使用空间分区(如网格)来优化“查找周围水果”这类操作。 - Rendering 耗时高:检查 Draw Call 数量(方法见4.2.1)。如果 Draw Call 过高,优先检查图集和渲染顺序。同时,减少不必要的
cc.Sprite节点(比如隐藏已静止且不再变化的水果的阴影效果)。 - Physics 耗时高:检查场景中活跃的
Dynamic刚体数量。确保静止的水果刚体进入了睡眠状态(awake为false)。如果一堆水果已经稳定堆叠,可以考虑将它们“合并”为一个大的静态碰撞体(但这会极大增加逻辑复杂度,需谨慎评估)。
- Scripting 耗时高:检查自己的游戏逻辑,尤其是
5.4 移动端适配要点
问题:在手机上触摸不灵敏,或水果生成位置有偏差。
- 触摸坐标转换:
event.getLocation()获取的是基于屏幕的坐标。必须使用canvas.node.convertToNodeSpaceAR将其转换为游戏世界坐标系下的坐标,才能正确使用。onTouchStart(event: cc.Event.EventTouch) { let touchPos = event.getLocation(); // 屏幕坐标 let worldPos = cc.Camera.main.getScreenToWorldPoint(touchPos); // 世界坐标 let nodePos = this.canvasNode.convertToNodeSpaceAR(worldPos); // 画布节点坐标 // 使用 nodePos.x 来决定在何处生成水果 } - 多点触控:默认情况下,
TOUCH_START会响应所有手指的触摸。如果只需要第一个触点,可以在回调开始时判断event.getID() === 0。 - 高DPI屏幕:确保游戏画布的分辨率策略设置正确(如
Fit Height或Fit Width),UI 使用锚点进行布局,以适应不同尺寸和分辨率的屏幕。
回过头看,把一个“合成大西瓜”游戏做出来,真正的价值不在于复现了这个玩法,而在于通过它,我们像解剖麻雀一样,把 Cocos Creator 的核心工作流程和组件化思想实实在在地走通了一遍。从最基础的节点、组件、预制体,到物理碰撞、输入事件、动画系统,再到性能优化和问题排查,这些知识点是通用的。下次当你再面对一个“让物体动起来、碰起来、响起来”的需求时,脑子里自然会浮现出这套经过验证的组件组合拳。这才是从“照着做”到“想着做”的关键一步。游戏开发里没有银弹,但有了清晰的组件化思维和扎实的原理理解,无论炮弹从哪个方向来,你都知道该用什么姿势去接。