从《合成大西瓜》到 3D 版本,很多开发者可能以为这只是一个休闲小游戏的简单换皮。但真正动手用 Cocos Creator 复刻时,你会发现这个项目把 3D 物理碰撞、动态合体、UI 数据绑定、资源预加载和性能优化这些游戏开发高频知识点全串起来了。如果你正在找一个小而完整的三维游戏练手项目,又不想一上来就碰大型 RPG 的复杂框架,这篇教程应该能帮你少走不少弯路。
先说结论:用 Cocos Creator 3.8 的 3D 物理系统和 TypeScript 脚本,完全可以在较短时间内做出一版可运行的 3D 合成大西瓜。核心难点不在"拖拽投放"这个交互,而在水果球的碰撞反馈、同类型合体时的坐标计算、以及多球堆叠时的性能控制。本文会用一套完整可行的工程思路,把这个项目从场景搭建到代码实现逐步讲清楚。
坦白说,虽然这类合体玩法看起来是固定的套路——掉落、碰撞、合并、再掉落,但把它放到 3D 空间后,物理材质、碰撞体形状、重力参数、相机视角都会直接影响手感。如果你只在 2D 平面里做过类似的消除合体逻辑,直接套用到 3D 场景大概率会遇到球体乱弹、合体定位不准、运行掉帧等问题。这篇文章会从这些真实的坑出发,带着你把项目跑通。
读完之后,你会得到一个包含 3D 水果球生成、拖拽投放、碰撞合体、分数更新、UI 提示的完整可运行 Demo,同时也会理解每一段核心代码为什么要这么写,而不是只复制一个模板。
1. 这篇文章真正要解决的问题
很多新手看到"3D 版合成大西瓜",第一反应是"这有什么难的,不就是把 2D 图片换成 3D 模型吗"。如果你只是想在视觉上把圆形图片换成球体,那确实不难;但如果你想要的是"玩起来像那回事"的效果,就必须面对三个核心问题。
第一个问题是物理手感。合成大西瓜的灵魂在于水果球掉落后会滚动、碰撞、堆叠,合并时会有明显的"合成"反馈。在 3D 空间里,球体的碰撞体形状(Sphere Collider)、物理材质(Physics Material)的弹力和摩擦力、重力方向,共同决定了水果球滚落时的真实感。这个问题做不好,游戏就会变成"方块式的僵硬堆叠"。
第二个问题是合体逻辑的坐标处理。2D 版合成大西瓜只需要处理 x 和 y 两个轴上的碰撞,但 3D 版需要考虑 z 轴深度。当两个同类型水果球碰撞时,新生成的水果球必须出现在两个旧球碰撞点的正确位置,否则视觉上会出现"凭空瞬移"或者"穿模重叠"的问题。
第三个问题是性能。3D 场景里的每一个水果球都是一个带刚体和碰撞体的节点,当场上水果球数量超过十几二十个时,如果不做资源管理和合体上限控制,移动端很容易产生卡顿。这对习惯于做 2D 游戏的同学来说可能一开始并不敏感,但在 3D 物理场景里掉帧会非常明显。
这篇文章适合以下读者:
- 正在学 Cocos Creator 3.x,想从 2D 转向 3D 的开发者;
- 想做休闲合成类游戏但缺少完整工程参考的独立开发者;
- 需要给团队做 Demo 演示,但没有现成资源可用的游戏开发新人。
如果你只是想快速做个 2D 合成玩法,这篇教程里关于 3D 物理的部分可以跳过,但合体逻辑和 TS 脚本设计思路仍然值得参考。
2. 合成大西瓜的核心玩法拆解
在写任何代码之前,先拆清楚玩法机制。合成大西瓜的规则很简洁:玩家在屏幕底部上方区域选择一个水果球,通过拖拽或点击将其投放到容器中;同类水果球碰撞后合体成更高一级的水果;相邻等级相差较大的水果球不能合并;当容器内水果球堆积超过顶部警戒线时游戏结束。
在 3D 版本中,核心循环没有变化,但实现层面有几个关键差异。
2.1 判定方式:从 AABB 到物理碰撞
2D 版本游戏中,很多实现方式是直接检测两个圆形精灵的 AABB(轴对齐包围盒)是否相交,一旦相交且类型相同,就触发合体。
3D 版本如果还用这种"手动检测"的思路,会让坐标运算变得极其复杂,因为你还要考虑两个球体在不同深度方向上的距离。更合理的做法是依赖 Cocos Creator 3.8 内置的物理碰撞系统。让每个水果球挂上 RigidBody(刚体)和 SphereCollider(球形碰撞体),系统会自动处理碰撞检测,你只需要在碰撞回调里判断"两个碰撞体是否属于同一等级的水果球即可。
2.2 水果数据:用类型表示等级
通常用数字 0 到 10 表示水果等级。数字越大,水果越大。合成规则是相同数字碰撞后生成数字 +1 的新水果。
这里有一个设计细节:合成后的新水果体积,并不是简单地等于两个旧水果体积之和。常见做法是使用一个预先配置的体积列表,每个等级对应一个固定的模型缩放数值。这样做的好处是体积曲线可控,不会出现数学计算导致的"合成后突破容器范围"的问题。
2.3 3D 空间中的深度问题
这是 2D 转 3D 最容易踩坑的地方。
在 2D 版中,所有物体都在同一平面内,合体后的新水果 x、y 坐标可以直接用两个旧水果的中点。
在 3D 版中,球体可能一个在前一个在后(z 轴坐标不同),如果直接取两个球体位置的线性中点,新生成的水果可能会出现在一个错误的深度上,导致它和容器底部、墙壁之间的碰撞异常。
更稳妥的处理方式是:合体后的新水果位置只取两个旧球位置的中点,但 z 轴坐标固定成一个预设值。因为在"合成大西瓜"这类玩法里,玩家操作的始终是屏幕上的一个方向,深度方向的信息并不应该成为合体的主要判断依据。
我把这个思路的取舍说清楚,你在写代码时就可以针对性处理。
3. Cocos Creator 3.8 与 3D 物理基础
3.1 为什么选择 Cocos Creator 3.8
Cocos Creator 3.8 是 Cocos 官方在 3.x 系列中相对稳定的版本。它在 2D 和 3D 混合开发方面表现比较成熟,支持 TypeScript 脚本开发,内置了基于 Bullet 物理引擎的 3D 物理系统,发布到微信小游戏、Android、iOS 等平台都比较方便。
对于合成大西瓜这种项目来说,Cocos Creator 3.8 的特点有三点非常合适:
一是场景编辑器内置了 3D 物理组件,不需要额外引入第三方物理库;二是 TypeScript 支持完善,能写出结构清晰的游戏逻辑;三是资源导入和预制体(Prefab)系统在 3D 场景下同样好用。
3.2 3D 场景中的核心概念
先用通俗的方式理解几个 3D 场景概念。
场景(Scene)是存放所有游戏对象的地方。在合成大西瓜项目里,场景中包含一个相机(Camera)、一个容器区域(用于限制水果球活动范围)、一个生成点(用于创建新的水果球)。
节点(Node)是场景中的基本单位,所有物体都是节点。节点可以有子节点,例如一个水果球节点下包含一个显式模型子节点和一个碰撞体子节点。
组件(Component)是挂在节点上的可复用逻辑。在 Cocos Creator 3.8 中,RigidBody 是刚体组件,SphereCollider 是球形碰撞体组件,Model 或 MeshRenderer 负责渲染模型。
资源(Asset)是工程中的模型、贴图、材质、预制体等文件。对于 3D 水果球,通常直接使用内置的 Sphere 网格模型,配合自定义材质颜色区分不同类型的水果。
刚体(RigidBody)负责物理运动。当刚体的类型为 Dynamic 时,它受重力影响,可以与其他碰撞体发生碰撞反应。
碰撞体(Collider)是参与碰撞检测的体积。在 3D 空间中,碰撞体不需要和模型完全一致,一个球形模型挂球形碰撞体就足够。
3.3 2D 和 3D 物理系统的主要区别
| 维度 | 2D 物理系统 | 3D 物理系统 |
|---|---|---|
| 碰撞体组件 | Collider2D 系列(BoxCollider2D 等) | Collider 系列(BoxCollider、SphereCollider 等) |
| 刚体组件 | RigidBody2D | RigidBody |
| 物理材质 | PhysicsMaterial2D | PhysicsMaterial |
| 重力方向 | 通常为 (0, -9.8) | 通常为 (0, -9.8, 0),需确认 3D 空间 Y 轴朝上 |
| 碰撞回调 | onCollisionEnter2D | onCollisionEnter |
| 坐标维度 | x, y | x, y, z |
如果你的项目是从某个 2D 项目改造而来,修改脚本时最容易忘记的就是把 2D 回调函数名改掉,以及把 Vector2 类型改为 Vector3。
4. 项目结构与 TypeScript 脚本规划
开始动手前,先规划好整个工程的结构。合理的文件划分可以节省大量排错时间。
assets/ scenes/ Main.scene scripts/ GameManager.ts // 游戏主控:状态、分数、生成、结束 Fruit.ts // 水果球组件:等级、类型、碰撞回调 SpawnController.ts // 生成控制器:拖拽预览,投放位置逻辑 UIController.ts // UI 控制器:分数更新,提示信息 prefabs/ FruitPrefab.prefab // 基础水果球预制体 materials/ FruitMaterial.mtl // 水果球基础材质4.1 脚本职责划分
GameManager 是核心单例,负责全局状态管理。它维护当前分数、当前可生成的水果等级范围、当前场上水果球数量、游戏是否结束。
Fruit 是挂在每个水果球预制体上的组件。它保存当前水果等级,并在碰撞回调中判断是否需要合体。
SpawnController 负责玩家交互。它监听触摸事件,让玩家在容器上方左右移动预览球的位置,松开时创建真实的物理球体。
UIController 只负责 UI 刷新。它从 GameManager 获取当前分数并显示到 Label 上。
这样的划分让每个脚本只做一件明确的事,后续调试时思路会很清晰。
4.2 静态配置数据
水果的等级和体积数据建议写在一个独立的配置对象里,不要散落在各脚本的魔法数字中。等级、名称、缩放、颜色、分数值都可以集中管理。
// 文件路径:assets/scripts/FruitConfig.ts export const FruitConfig = [ { level: 0, name: '葡萄', scale: 0.8, color: '#8B5A2B', score: 10 }, { level: 1, name: '橘子', scale: 1.0, color: '#FF8C00', score: 20 }, { level: 2, name: '柠檬', scale: 1.2, color: '#FFF44F', score: 40 }, { level: 3, name: '猕猴桃', scale: 1.4, color: '#7CFC00', score: 80 }, { level: 4, name: '番茄', scale: 1.6, color: '#FF6347', score: 160 }, { level: 5, name: '桃子', scale: 1.8, color: '#FFB6C1', score: 320 }, { level: 6, name: '菠萝', scale: 2.0, color: '#FFD700', score: 640 }, { level: 7, name: '椰子', scale: 2.2, color: '#A0522D', score: 1280 }, { level: 8, name: '半个西瓜', scale: 2.5, color: '#228B22', score: 2560 }, { level: 9, name: '大西瓜', scale: 2.8, color: '#006400', score: 5120 }, ];这份配置的意义在于:你调整游戏难度时,只需要修改 level 0 到 2 的缩放数值或生成概率,不需要改动任何合体逻辑。
5. 环境准备与基础场景搭建
5.1 环境要求
- 引擎:Cocos Creator 3.8 系列(以你安装的 3.8 具体小版本为准,本文演示思路在 3.8.x 通用)
- 开发语言:TypeScript
- 目标平台:浏览器预览(Web Desktop / Web Mobile)优先,后续可发布微信小游戏或 Android APK
- 3D 物理系统:确保在 Project Settings / Physics 中启用 3D 物理
如果创建项目时选择了 3D 模板,通常物理系统已经默认开启。如果是 2D 模板改造,需要手动确认。
5.2 创建 3D 项目并搭建主场景
- 打开 Cocos Dashboard,选择新建项目,模板选择 Empty 3D。
- 打开 Main.scene,删除默认节点(或保留 Main Camera 和 Directional Light)。
- 创建一个空节点,命名为 GameRoot,用于挂载 GameManager 脚本。
- 创建节点 FallArea,表示容器底部区域。在 FallArea 下创建四个碰撞体代表容器的底面和四面墙,或者创建三面墙加上地面。
- 创建节点 SpawnPoint,表示水果球的生成位置。它应该在容器顶部中心偏上的位置。
需要特别注意的一点:3D 场景默认以 Y 轴为上方。容器底面应该是一个放在 Y=0 附近的平面,四面墙垂直向上延伸。水果球从 Y 轴高处下落。
5.3 设置物理材质
在 Assets 面板中创建一个 3D 物理材质(PhysicsMaterial),设置摩擦力和弹力。合成大西瓜的趣味性在于球体滚动,弹力不宜过高,摩擦力不宜过大。
推荐初始值:
- 动态摩擦力:0.4
- 静态摩擦力:0.6
- 弹性:0.2
这几个数值不写死,你可以根据实际手感调整。重点理解:弹力设置太高会导致水果球像弹珠一样弹不停;摩擦力太高会导致水果球很快静止堆叠,缺少滚动过程。
5.4 创建墙壁
在容器边界创建四个长方体(Box),分别放在容器的左右前后四个方向。这四个长方体不需要挂 RigidBody,只需要静态碰撞体即可。
这里有一个新手容易犯的错误:把墙壁设置成 Dynamic 刚体。如果墙壁也是动态刚体,水果球撞到墙壁时墙壁会被击飞,游戏就乱了。静态碰撞体应该是 Static 刚体或只有碰撞体没有刚体。
6. 核心功能实现
6.1 水果球预制体制作
在场景中创建一个 Sphere 节点,命名为 FruitPrefab。给它添加:
- MeshRenderer,使用内置 Sphere 网格
- 自定义材质,设置基础颜色为 FruitConfig[0] 的颜色
- RigidBody,类型为 Dynamic,质量设为 1
- SphereCollider,半径为 0.5(配合节点缩放)
将该节点拖入 Assets 面板,生成预制体。之后所有等级的水果球都从同一个预制体动态生成,生成后通过修改节点缩放和材质颜色来区分等级。
这样做的优点是后续调整模型或碰撞属性只需要改预制体,不需要逐一生成多个预制体。
6.2 GameManager 核心逻辑
GameManager 负责维护游戏状态,动态生成水果球,并处理游戏结束判断。
// 文件路径:assets/scripts/GameManager.ts import { _decorator, Component, Node, Prefab, instantiate, Label, Vec3, input, Input, EventTouch } from 'cc'; import { Fruit } from './Fruit'; import { FruitConfig } from './FruitConfig'; const { ccclass, property } = _decorator; @ccclass('GameManager') export class GameManager extends Component { @property({ type: Prefab }) public fruitPrefab: Prefab = null; @property({ type: Node }) public spawnPoint: Node = null; @property({ type: Node }) public containerArea: Node = null; @property({ type: Label }) public scoreLabel: Label = null; @property({ type: Label }) public messageLabel: Label = null; private currentScore = 0; private gameOver = false; private currentFruitLevel = 0; private fruitCount = 0; private static _instance: GameManager = null; public static get instance(): GameManager { return this._instance; } onLoad() { GameManager._instance = this; input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); // 注册一个事件,当水果球被销毁时减少计数 this.node.on('fruit-destroyed', this.onFruitDestroyed, this); } onDestroy() { input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); GameManager._instance = null; } start() { this.updateScore(); this.showMessage('拖拽到容器上方,松手投放'); this.prepareNextFruit(); } private currentPreviewNode: Node = null; prepareNextFruit() { if (this.gameOver) return; if (this.currentPreviewNode) { this.currentPreviewNode.destroy(); this.currentPreviewNode = null; } this.currentFruitLevel = Math.floor(Math.random() * 3); const fruitNode = instantiate(this.fruitPrefab); fruitNode.name = `Fruit_${this.currentFruitLevel}`; const fruitComp = fruitNode.getComponent(Fruit); if (fruitComp) { fruitComp.initByLevel(this.currentFruitLevel); } const pos = this.spawnPoint.position.clone(); fruitNode.setPosition(pos); this.node.addChild(fruitNode); // 预览阶段禁用刚体,避免提前掉落 const rb = fruitNode.getComponent('RigidBody') as any; if (rb) { rb.type = 2; // RigidBodyType.STATIC } this.currentPreviewNode = fruitNode; } onTouchMove(event: EventTouch) { if (this.gameOver) return; if (!this.currentPreviewNode) return; const uiPos = event.getUILocation(); // 将屏幕坐标转换为容器节点下的世界坐标 const worldPos = this.spawnPoint.parent.getComponent('UITransform') ? this.spawnPoint.parent.getComponent('UITransform').convertToNodeSpaceAR(new Vec3(uiPos.x, uiPos.y, 0)) : this.spawnPoint.position; // 限制 x 轴范围在容器内 const limitedX = this.clamp(worldPos.x, -this.halfWidth, this.halfWidth); const pos = this.currentPreviewNode.position.clone(); pos.x = limitedX; this.currentPreviewNode.setPosition(pos); } onTouchEnd(event: EventTouch) { if (this.gameOver) return; if (!this.currentPreviewNode) return; // 将预览节点切换为动态刚体,使其掉落 const rb = this.currentPreviewNode.getComponent('RigidBody') as any; if (rb) { rb.type = 1; // RigidBodyType.DYNAMIC rb.enabled = true; } this.currentPreviewNode = null; this.fruitCount++; this.scheduleOnce(() => this.prepareNextFruit(), 0.3); } public registerFruit(fruitNode: Node) { this.fruitCount++; fruitNode.on('fruit-destroyed', this.onFruitDestroyed, this); } private onFruitDestroyed() { this.fruitCount--; } public addScore(score: number) { this.currentScore += score; this.updateScore(); } private updateScore() { if (this.scoreLabel) { this.scoreLabel.string = `分数:${this.currentScore}`; } } private showMessage(msg: string) { if (this.messageLabel) { this.messageLabel.string = msg; this.scheduleOnce(() => { if (this.messageLabel) this.messageLabel.string = ''; }, 1.5); } } private clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } }这段代码里有一个核心思路:预览节点在触摸移动阶段是静态刚体,播放到目标位置后并不受重力影响;松手时才切换为动态刚体。这样就避免了"预览时球就掉下去"的问题。
这里把 RigidBody 的 type 直接设置为数字,是因为在代码中引用引擎枚举在某些版本里可能由于命名空间问题报错。实际项目中建议使用ERigidBodyType.DYNAMIC等枚举值,如果编辑器版本支持的话。
6.3 Fruit 组件:碰撞与合体
Fruit 组件是合体逻辑的核心。它的职责是保存水果等级,并在第一次碰撞时判断是否满足合体条件。
// 文件路径:assets/scripts/Fruit.ts import { _decorator, Component, Collider, ITriggerEvent, RigidBody, Vec3, Node, SphereCollider } from 'cc'; import { GameManager } from './GameManager'; import { FruitConfig } from './FruitConfig'; const { ccclass, property } = _decorator; @ccclass('Fruit') export class Fruit extends Component { public level = 0; private isMerging = false; private isInit = false; initByLevel(level: number) { this.level = level; const config = FruitConfig[level]; this.node.setScale(new Vec3(config.scale, config.scale, config.scale)); // 修改模型材质颜色 const renderer = this.node.getComponent('MeshRenderer') as any; if (renderer && renderer.material) { const mat = renderer.material; mat.setProperty('albedo', new Vec3( parseInt(config.color.substring(1, 3), 16) / 255, parseInt(config.color.substring(3, 5), 16) / 255, parseInt(config.color.substring(5, 7), 16) / 255 )); } const collider = this.getComponent(SphereCollider); if (collider) { collider.radius = 0.5; } this.isInit = true; } onLoad() { const collider = this.getComponent(Collider); if (collider) { collider.on('onCollisionEnter', this.onCollisionEnter, this); } } onDestroy() { const collider = this.getComponent(Collider); if (collider) { collider.off('onCollisionEnter', this.onCollisionEnter, this); } } private onCollisionEnter(event: any) { if (this.isMerging) return; const otherCollider = event.otherCollider; if (!otherCollider) return; const otherFruit = otherCollider.node.getComponent(Fruit); if (!otherFruit) return; if (otherFruit.isMerging) return; if (this.level === otherFruit.level && this.level < FruitConfig.length - 1) { this.performMerge(otherFruit); } } private performMerge(otherFruit: Fruit) { if (this.node.isValid && otherFruit.node.isValid) { this.isMerging = true; otherFruit.isMerging = true; const midPos = this.node.position.clone().add(otherFruit.node.position).multiplyScalar(0.5); // z 轴固定为 0,保持在同一个活动平面 midPos.z = 0; const newLevel = this.level + 1; const newNode = this.createFruitNode(newLevel, midPos); // 延迟销毁旧球,给视觉一点反馈时间 this.scheduleOnce(() => { this.node.destroy(); otherFruit.node.destroy(); }, 0.05); GameManager.instance.addScore(FruitConfig[newLevel].score); } } private createFruitNode(level: number, pos: Vec3): Node { const prefab = (GameManager.instance as any).fruitPrefab; const newFruit = prefab ? (GameManager.instance as any).instantiateFruit(level, pos) : null; return newFruit; } }这里最关键的设计点是isMerging标志位。物理碰撞回调中,同一个碰撞事件会被双方各触发一次。如果没有这个标志位,A 和 B 碰撞时,A 会尝试合并 B,B 也会尝试合并 A,最终出现重复生成两个新水果球的 bug。
另外一个值得留意的点是:在onCollisionEnter中直接destroy节点可能导致物理引擎在回调过程中访问已被销毁的碰撞体,产生异常。所以先创建新球,再延迟一帧销毁旧球,是一种更稳妥的顺序。
6.4 在 GameManager 中补充 instantiateFruit 方法
上面的 Fruit 组件调用了GameManager.instance.instantiateFruit,这个方法需要在 GameManager 中补充实现。
// 文件路径:assets/scripts/GameManager.ts 中补充方法 public instantiateFruit(level: number, pos: Vec3): Node { if (this.gameOver) return null; const fruitNode = instantiate(this.fruitPrefab); fruitNode.name = `Fruit_${level}`; const fruitComp = fruitNode.getComponent(Fruit); if (fruitComp) { fruitComp.initByLevel(level); } fruitNode.setPosition(pos); this.node.addChild(fruitNode); this.fruitCount++; return fruitNode; }这样的设计让 Fruit 组件的合体逻辑不直接依赖预制体资源,而是统一走 GameManager 的工厂方法。实际项目中,如果想要扩展更多类型的合成规则,只需要修改 GameManager 中的instantiateFruit方法即可。
6.5 场景节点与脚本挂载
在编辑器中,把 GameRoot 节点挂上 GameManager 脚本,并把 FruitPrefab、SpawnPoint、ContainerArea、ScoreLabel、MessageLabel 拖到对应属性上。
需要确认几个节点坐标:
- SpawnPoint 的 y 坐标应该高于容器顶部。比如容器高度是 10,底部在 y=0,那么 SpawnPoint 的 y 可以设为 8,x 和 z 为 0。
- 容器底部可以用一个 Cube 压扁成薄片,放在 y=0 位置作为地面。
- 四面墙的碰撞体只需要在 y 方向足够高,防止水果球飞出。
6.6 UI 搭建
UI 部分需要两个 Label 节点:
- ScoreLabel 显示当前分数,GameManager 中的脚本会自动刷新。
- MessageLabel 显示操作提示或游戏结束信息。
UI 节点建议放在 Canvas 下,这样缩放和适配更简单。GameManager 中的 Label 属性直接引用这两个节点即可。
7. 完整代码整合与运行验证
7.1 运行项目前的检查清单
在点击编辑器预览之前,按顺序检查以下配置:
- 场景中是否存在 GameRoot 节点,并且 GameManager 脚本已挂载?
- GameManager 的 prefab 属性是否指向 FruitPrefab?
- FruitPrefab 是否包含 RigidBody 组件?预览阶段是否会因为动态刚体直接掉落?
- FruitPrefab 是否包含 SphereCollider 组件?
- Fruit 脚本是否挂载到 FruitPrefab 上?
- 容器墙壁是否设置了碰撞体但没有动态刚体?
这个清单能帮你避免 90% 以上的"球没反应"或"球飞出去"问题。
7.2 运行调试步骤
点击预览按钮后,控制台如果没有任何报错,你应该能看到一个水果球出现在 SpawnPoint 位置。
拖动鼠标或手指,水果球预览会随指针左右移动。移动到容器上方后松手,球会受重力掉落到容器底部并滚动、弹跳,最终静止。
当第二个球掉落到第一个球上时,如果两者等级相同,控制台应能看到两个球合并为一个新等级球的日志(如果你加了 debug 输出的话)。
7.3 模拟真实登录与投球节奏
为了验证手感,建议连续投放 5 到 8 个球,观察以下现象:
- 球下落是否自然,是否出现旋转异常或穿墙;
- 合体后的新球是否出现在合理的坐标,没有穿模;
- 场上球数增多后,帧率是否明显下降。
如果出现穿墙,优先检查墙壁碰撞体的位置和尺寸,以及水果球的运动速度是否过大。如果帧率下降明显,则需要参考下文的最佳实践进行优化。
7.4 验证合体逻辑
一个快速验证方式是:在 GameManager 的 start 方法中临时将随机水果等级固定为 0,然后连续投两个球,看它们是否成功合体为等级 1 的水果。
// 文件路径:assets/scripts/GameManager.ts 中临时修改 // 测试用固定等级 this.currentFruitLevel = 0;跑通后记得改回随机逻辑。
8. 常见问题与排查方法
下面是 Cocos Creator 3.8 3D 合成大西瓜项目中最高频的几类问题。按问题现象、可能原因、排查方式、解决方案排列。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 水果球生成后立即掉落,预览拖拽没有效果 | 预览节点刚体类型是 Dynamic | 检查脚本中设置刚体类型为 Static 的代码是否执行 | 将预览阶段刚体类型改为 Static,松手时才改为 Dynamic |
| 两个同等级水果碰撞后没有合体 | 碰撞回调未触发或等级类型不匹配 | 在 onCollisionEnter 中打印日志,确认两个节点的 Fruit 组件 level 值 | 检查 Fruit 脚本是否挂在预制体根节点,检查等级是否已通过 initByLevel 初始化 |
| 合体后新水果出现在错误位置或穿模 | 合体位置取的是两个旧球的线性中点 | 检查 performMerge 中的坐标计算是否正确,z 轴是否固定 | 将 z 轴固定为容器中心深度,比如 0 |
| 水果球碰撞墙壁时墙壁被撞飞 | 墙壁挂了 Dynamic 刚体 | 检查墙壁节点的 RigidBody 类型 | 将墙壁刚体删除或设为 Static |
| 碰撞时重复生成新水果 | isMerging 标志位未设置 | 检查碰撞回调是否在双方各自触发时重复走逻辑 | 在合体开始前将两个球的 isMerging 都设为 true |
| 场上水果球超过 15 个后帧率明显下降 | 物理计算量过大或节点数量过多 | 打开 Profiler 查看 CPU 消耗 | 限制场上最大水果数量;合体后及时销毁旧节点;降低物理步长更新频率 |
| 预览球横向移动时超出容器范围 | 未限制 x 轴范围 | 检查 onTouchMove 中是否对 x 坐标进行了 clamp | 使用容器半宽限制预览球 x 坐标 |
| 微信小游戏预览报物理模块缺失 | 构建发布时未勾选 3D 物理模块 | 检查构建发布面板的模块设置 | 在项目设置中确认物理模块已勾选,重新构建 |
| 报错提示 TypeScript 无法找到 RigidBody 类型 | 引擎命名空间在特定版本下导入异常 | 检查 import 语句中的路径 | 使用import { RigidBody } from 'cc'或通过getComponent('RigidBody')获取组件 |
8.1 关于物理系统未开启的坑
如果你发现所有球都不受重力,或者碰撞事件完全没有回调,很大概率是物理系统没有开启。在 Cocos Creator 3.8 中,需要到 Project Settings 中确认 Physics 模块被启用,并且在场景中至少有一个刚体和一个碰撞体才能驱动物理模拟。
在部分版本中,创建 2D 项目模板时默认只启用 2D 物理系统,3D 物理系统默认关闭。这种情况下,在脚本里调用RigidBody相关 API 不会立即报错,但物理模拟不会生效。
9. 最佳实践与工程建议
9.1 合理控制场上水果球数量
合成大西瓜的核心玩法是伴随堆叠越来越高的压迫感。但 3D 物理场景中,每增加一个球,刚体之间的碰撞检测复杂度就会上升。一个比较稳妥的设计是:设置场上最大水果球数量,比如 30 个,超过时不再生成新的预览球,而是提示玩家等待合体或在分数上给出惩罚。这个设计也能增加策略深度,让玩家思考"是现在合体还是继续堆叠"。
9.2 使用对象池优化节点生命周期
频繁instantiate和destroy节点会带来内存抖动。对于水果球这类需要反复生成销毁的对象,更适合用对象池管理。Cocos Creator 3.x 提供了NodePool,使用方式是在水果球销毁时回收到池中,而不是直接 destroy,下一次生成时从池中取出复用。
如果只是做 Demo,对象池不是必须的;如果准备发布小游戏或 Android 包,这一步最好尽早加入。
9.3 将物理材质做成可配置资源
不要直接在代码中 Hardcode 物理材质参数。在 Assets 中创建一个 PhysicsMaterial,拖到预制体或场景节点上。这样后续调手感时可以快速改参数,不需要重新编译脚本。
9.4 关注 TypeScript 类型安全
本文示例为了简洁,在某些地方使用了as any来获取 RigidBody 组件。但实际项目中更推荐使用强类型方式:
import { RigidBody, ERigidBodyType } from 'cc'; const rb = this.node.getComponent(RigidBody); if (rb) { rb.type = ERigidBodyType.STATIC; }这样在编译阶段就能暴露拼写错误和类型不匹配问题,而不是等到运行时才发现。
9.5 合体创建新球时先缩放后激活
从预制体实例化新水果后,先设置缩放和位置,再添加到场景树中。如果先添加到场景树再设置坐标,可能会触发一次闪烁或物理时间步中的错误碰撞。
9.6 UI 与场景分离
游戏逻辑节点和 UI 节点最好分开管理:逻辑节点放在 GameRoot 下,UI 节点放在 Canvas 下。GameManager 通过属性引用 UI Label,而不是去场景树中动态查找。这样可以减少字符串名称修改导致的运行时错误。
9.7 关于打包 APK 的补充建议
如果是发布到 Android 端,建议在构建发布前关闭编辑器中的物理预览线框绘制,避免不必要的渲染开销。同时在构建面板中选择合适的纹理压缩格式,减少安装包体积。微信小游戏平台则需要特别关注内存占用,建议限制同时存在的水果球数量,并对大号水果球的模型面数做简化。
10. 后续可以怎么继续深入
到这里,一个具备核心玩法的 3D 合成大西瓜已经跑通了。但如果你想把它做成一个真正可以上线的小游戏,还有几个方向值得继续研究。
首先是合体特效。目前合体只是直接生成一个新球,缺少视觉反馈。可以加入粒子特效、缩放弹跳动画、合体音效。Cocos Creator 3.8 支持 3D 粒子系统,可以在合体位置生成一个短暂的爆炸特效。
其次是游戏难度曲线。当前新球的等级是随机 0 到 2,难度偏低。可以在 GameManager 中根据玩家当前得分动态调整随机范围,让游戏后期出现更多高等水果,增加挑战性。
第三是拖拽手感优化。可以增加一条虚线辅助线,显示当前预览球的下落轨迹,帮助玩家更精准地投放,这也能降低新手误操作带来的挫败感。
还有一个值得尝试的方向是多人同屏或异步排行。借助 Cocos 的云服务或者自建后端,把玩家分数上传到排行榜,这种轻社交功能对休闲小游戏的留存率提升非常明显。
最后也是最重要的:把代码玩出 bug 来。做物理类游戏时,永远不要只在标准场景下测试。尝试快速连点、快速拖动、把球投到墙角、让大量球同时碰撞,这些边缘操作才是手游上线前真正需要解决的问题。
如果你想继续学习 3D 物理游戏的开发,下一步可以试着在这个项目中加入更多类型的物理形状、可交互物体,或者实现一个基于物理的闯关玩法。Cocos Creator 3.8 的物理系统能力远比这个小 Demo 用到的多,值得继续探索。