合成大西瓜的玩法链条很短从顶部投下一颗水果水果在容器内受重力下落两个同类型水果碰到一起就合成更高一级直到堆出整颗西瓜。当任何一只水果越过顶部警戒线并接近静止游戏结束。用 Cocos Creator 3.8 做 3D 版核心不是美术或 UI而是把“物理掉落 同类型合并 边界失败判定”这三件事串在一条可靠的时序里。下面从空工程开始走完一版可玩的最小闭环包含场景结构、水果预制体、碰撞合并代码、分数与状态 UI以及构建 Web 和 Android 包时需要注意的地方。阅读前需要具备最基础的 Cocos Creator 使用经验会创建节点、添加组件、挂载脚本。如果完全没接触过编辑器建议先跟着官方示例跑一个 3D 小场景再来读这篇文章会顺畅很多。1. 先拆玩法合成大西瓜的 3D 版本到底要解决什么1.1 玩法本质是一个“碰撞 合并”状态机合成大西瓜看起来像物理休闲游戏但程序核心是一张“类型升级表”每种水果都有一个类型编号例如葡萄是 0樱桃是 1西瓜是最大值。规则可以收敛成三句话玩家点击或触摸顶部区域生成一颗当前预览类型的水果。水果受重力下落与容器底部、墙壁、其他水果发生碰撞。两颗类型编号相同且都没有合并过的水果碰撞后销毁这两颗在碰撞点中间生成类型编号加一的新水果。注意“都没有合并过”这个条件。如果没有这一层标记两颗正在碰撞的水果会在一次物理接触里触发多次回调导致同一次碰撞生成多个新水果。后面的代码里会用merged布尔标志位来处理这个问题。1.2 2D 转 3D 的设计取舍原版合成大西瓜是 2D 平面玩法水果在二维容器里堆叠。3D 化之后如果完全放开 Z 轴水果会在深度方向上乱跑玩法会失控。实际改造成 3D 时普遍采用“浅箱体”做法底板是长方形宽 5、深 3。左墙、右墙、后墙把三面围住朝向相机的一面敞开。水果只在 X-Y 平面附近活动Z 方向空间很浅视觉上有 3D 厚度但玩法逻辑仍然接近 2D。这样做的好处是物理系统天然处理了水果堆叠不用手工判断两颗水果是否接触。开发量集中在合并逻辑的防重复处理、失败线的稳定判定以及 3D 相机视角是否遮挡操作区域。1.3 为什么选 Cocos Creator 3.8 和 TypeScriptCocos Creator 3.8 内置了 3D 物理系统RigidBody 和 Collider 组件可以在编辑器里直接配置TypeScript 脚本能拿到强类型提示。对合成大西瓜这类小体量游戏来说它不需要复杂服务端Web 预览和移动端构建都能从同一个工程出包。TypeScript 的价值主要体现在合并队列、状态枚举和资源数组的类型约束上写起来比纯 JavaScript 更安全新手养成好习惯以后接大型项目也不吃亏。2. 环境准备与场景搭建2.1 工具版本和运行环境建议先统一版本避免编辑器、物理系统、构建工具之间打架。项目建议值说明Cocos Creator3.8.x本文结构在 3.8.2、3.8.3 上测试过浏览器预览Chrome 112 以上需要 WebGL 2.0 支持构建目标Web Mobile / Android先跑 Web再打 APK显卡驱动开启硬件加速物理调试面板需要 WebGL 支持如果本地编辑器版本低于 3.8建议直接升级到 3.8 最新补丁版本再继续。3.x 内部 API 和 2.x 差异很大旧版项目迁移不是本文范围。2.2 新建 3D 项目打开 Cocos Dashboard选择新建项目模板选“Empty 3D”输入项目名后等待编辑器完成初始化。创建完成后在资源管理器中新建以下目录resources/Prefabs/Fruits水果预制体。scriptsTypeScript 脚本。materials水果材质。预制体使用resources目录只是为了便于后续动态加载本文示例采用编辑器拖拽引用的方式严格来说不一定非要放在resources下。如果后面要做热更新或远程资源再改动态加载方案。2.3 场景层级与容器新建场景后把默认场景整理成下面的结构。这个结构决定了后续代码里fruitRoot、touchRange、GameManager这几个关键节点如何互相查找。Scene ├── GameRoot │ ├── Camera │ ├── Light │ ├── Board │ ├── LeftWall │ ├── RightWall │ ├── BackWall │ ├── FruitRoot │ └── GameManager └── Canvas ├── ScoreLabel ├── StateLabel └── TouchRangeGameRoot是 3D 物体节点Canvas是 UI 节点。FruitRoot是空节点所有动态生成的水果都挂在它下面便于统一遍历、销毁和设置初始坐标。GameManager节点上挂载主脚本。TouchRange是一块铺满操作区域的透明按钮区域拥有UITransform组件用于把触摸坐标转换成世界坐标。2.4 物理系统与关键参数进入项目设置Project Settings - Physics确认物理引擎类型是cannon.js或bullet。Cocos Creator 3.8 的 3D 物理系统默认使用 cannon.js 作为内置物理引擎新增的 bullet 选项在移动端性能更好但需要按项目需要选择。在GameManager的onLoad里显式开启物理系统PhysicsSystem.instance.enable true;重力默认方向是 Y 轴负方向。合成大西瓜需要水果下落速度快、堆叠后尽量稳定重力不能太小也不能大到产生穿模。这里先给一组初值后面会单独用表格解释调参影响PhysicsSystem.instance.gravity new Vec3(0, -40, 0);注意重力在编辑器Physics面板也有对应配置脚本里赋值会覆盖面板值。学习阶段用脚本调参更方便进入生产迭代时建议把物理参数收敛到配置表避免每次改手感都要重新发版。3. 水果类型表和预制体制作3.1 定义合成表程序并不关心水果长什么样只关心类型编号、碰撞半径和合成顺序。这里给出一个 11 级合成表表中的半径是预制体根节点上SphereCollider的半径也是后期调手感最常改的数值。typeId名称半径合成来源得分0葡萄0.18初始掉落11樱桃0.24葡萄 葡萄32橘子0.32樱桃 樱桃63柠檬0.40橘子 橘子104猕猴桃0.50柠檬 柠檬155番茄0.60猕猴桃 猕猴桃216桃子0.70番茄 番茄287菠萝0.80桃子 桃子368椰子0.92菠萝 菠萝459半西瓜1.04椰子 椰子5510西瓜1.16半西瓜 半西瓜66最高等级不要继续合成。实际游戏中时常出现两颗西瓜已经碰在一起如果继续生成更高等级水果游戏逻辑没有出口。代码里会用fruitA.typeId this.fruitPrefabs.length - 1判断并直接返回。3.2 水果预制体结构每个水果预制体建议采用“根节点 子视觉节点”的结构Grape根节点 ├── RigidBodyDYNAMIC ├── SphereColliderradius 0.18 ├── FruitItem脚本 └── Visual子节点 └── Sphere 模型根节点控制物理和逻辑子节点只负责显示。这样可以避免视觉模型缩放比例干扰碰撞半径。创建步骤在层级管理器中右键创建 3D 对象Sphere重命名为Grape。给Grape添加RigidBody组件类型默认是DYNAMIC。给Grape添加SphereCollider把半径改为合成表中对应的 0.18。新建材质颜色不同即可拖到子节点的MeshRenderer上。给根节点挂FruitItem脚本。把节点从层级管理器拖到resources/Prefabs/Fruits下生成预制体。注意子节点的Sphere自带单位尺寸视觉半径是 0.5。如果想让视觉半径匹配物理半径把子节点scale设置为2 * 物理半径即葡萄子节点缩放为(0.36, 0.36, 0.36)。模型和物理半径不一致是后面水果看起来“下沉”或“悬空”的常见原因。3.3 墙壁和底板的碰撞体底板和三面墙是静态碰撞体。在 3D 物理里没有RigidBody的Collider会被当作静态物体参与碰撞但为了语义清晰建议手工给这些节点添加RigidBody并将类型设为STATIC。具体参数BoardBoxCollider尺寸(5, 0.4, 3)放在 Y0 位置顶部露出的 0.2 高度可以作为视觉厚度。LeftWallBoxCollider尺寸(0.4, 8, 3)X-2.7。RightWallBoxCollider尺寸(0.4, 8, 3)X2.7。BackWallBoxCollider尺寸(5.8, 8, 0.4)Z-1.7。水果生成位置建议放在Y8.2失败警戒线放在Y6.8这样玩家有足够操作空间又能明显感受到“快堆到顶了”的压迫感。底板和墙壁上不要加FruitItem因为合并逻辑只关心水果之间的碰撞。4. 核心脚本实现4.1 FruitItem类型与防重复合并标记FruitItem挂在每个水果预制体根节点上负责记录类型编号和合并状态。脚本必须独立存在后面GameManager通过node.getComponent(FruitItem)获取信息。import { _decorator, Component } from cc; const { ccclass } _decorator; ccclass(FruitItem) export class FruitItem extends Component { // 水果类型编号对应合成表里的 typeId public typeId: number 0; // 是否已经参与过合并 // 防止一次物理接触触发多次 onCollisionEnter 导致重复合成 public merged: boolean false; public init(typeId: number) { this.typeId typeId; this.merged false; } }这段脚本不需要property装饰器因为类型编号和合并状态都是在运行时由GameManager写入的不是编辑器配置。4.2 GameManager输入、生成与坐标转换主脚本GameManager.ts负责监听触摸、生成水果、处理合并、更新分数、判定失败。先看输入和生成部分import { _decorator, Component, Node, Prefab, instantiate, Vec3, Vec2, input, Input, EventTouch, UITransform, RigidBody, Collider, ICollisionEvent, Label, PhysicsSystem } from cc; import { FruitItem } from ./FruitItem; const { ccclass, property } _decorator; enum GameState { Waiting, // 游戏中 GameOver, // 已结束 } ccclass(GameManager) export class GameManager extends Component { // 按 typeId 顺序排列的水果预制体数组 property({ type: [Prefab] }) public fruitPrefabs: Prefab[] []; // 每级水果得分索引对应 typeId property({ type: [Number] }) public fruitScores: number[] []; property(Node) public fruitRoot: Node null; property(Node) public touchRange: Node null; property(Label) public scoreLabel: Label null; property(Label) public stateLabel: Label null; // 玩家当前最多投出第几级水果超过这个范围只生成小水果 property public maxDropTypeId: number 5; // 生成高度 property public spawnY: number 8.2; // 失败警戒线 property public boundaryLineY: number 6.8; // 掉落横向范围限制 property public boardHalfWidth: number 2.4; private _currentTypeId: number 0; private _score: number 0; private _gameState: GameState GameState.Waiting; private _checkTimer: number 0; private _mergeQueue: Array{ nodeA: Node; nodeB: Node; typeId: number; } []; onLoad() { input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); PhysicsSystem.instance.enable true; PhysicsSystem.instance.gravity new Vec3(0, -40, 0); this._reset(); } onDestroy() { input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); } private _reset() { this._score 0; this._gameState GameState.Waiting; this._mergeQueue []; this.scoreLabel.string 分数: 0; this.stateLabel.string 点击掉落; this.fruitRoot.destroyAllChildren(); this._currentTypeId this._randomNextTypeId(); } private _randomNextTypeId(): number { return Math.floor(Math.random() * (this.maxDropTypeId 1)); } private onTouchStart(event: EventTouch) { if (this._gameState ! GameState.Waiting) { return; } const worldPos this._uiPosToWorld(event.getUILocation()); const node instantiate(this.fruitPrefabs[this._currentTypeId]); this.fruitRoot.addChild(node); const pos this.fruitRoot.worldPosition.clone(); pos.x Math.max(-this.boardHalfWidth, Math.min(this.boardHalfWidth, worldPos.x)); pos.y this.spawnY; pos.z this.fruitRoot.worldPosition.z; node.setWorldPosition(pos); const fruit node.getComponent(FruitItem); if (fruit) { fruit.init(this._currentTypeId); } const collider node.getComponent(Collider); if (collider) { collider.on(onCollisionEnter, this.onFruitCollision, this); } this._currentTypeId this._randomNextTypeId(); } private _uiPosToWorld(uiPos: Vec2): Vec3 { const ui this.touchRange.getComponent(UITransform); const local ui.convertToNodeSpaceAR(new Vec3(uiPos.x, uiPos.y, 0)); return ui.convertToWorldSpaceAR(local); } }event.getUILocation()返回的是 UI 坐标不能直接把 X 值塞给 3D 节点。这里借助touchRange的UITransform先转节点本地坐标再转世界坐标然后只取 X。Y 和 Z 用手工指定保证所有水果严格从同一高度落下。这里有一个新手最容易忽视的环节onCollisionEnter要挂在新生成水果的Collider上而不是挂在静态墙壁上。如果忘记挂水果之间永远不能触发合并因为碰撞事件只在碰撞回调的接收方注册后才会派发。从编辑器生成的静态碰撞体不需要监听合并事件所以Board和LeftWall上不要挂这段逻辑。4.3 碰撞回调与合并队列碰撞合并最怕两个问题同一对水果重复触发回调以及碰撞回调里直接销毁节点导致物理引擎状态错乱。解决办法是“标记加队列”。private onFruitCollision(event: ICollisionEvent) { const nodeA event.selfCollider.node; const nodeB event.otherCollider.node; const fruitA nodeA.getComponent(FruitItem); const fruitB nodeB.getComponent(FruitItem); if (!fruitA || !fruitB) { return; } if (fruitA.merged || fruitB.merged) { return; } if (fruitA.typeId ! fruitB.typeId) { return; } if (fruitA.typeId this.fruitPrefabs.length - 1) { return; } // 先标记再入队避免同一帧重复触发 fruitA.merged true; fruitB.merged true; this._mergeQueue.push({ nodeA, nodeB, typeId: fruitA.typeId }); } lateUpdate(deltaTime: number) { if (this._mergeQueue.length 0) { this._processMerge(); } this._checkTimer deltaTime; if (this._checkTimer 0.3) { this._checkTimer 0; if (this._isGameOver()) { this._gameOver(); } } } private _processMerge() { const item this._mergeQueue.shift(); if (!item) { return; } const newTypeId item.typeId 1; const posA item.nodeA.worldPosition; const posB item.nodeB.worldPosition; const center new Vec3( (posA.x posB.x) * 0.5, (posA.y posB.y) * 0.5, (posA.z posB.z) * 0.5 ); item.nodeA.destroy(); item.nodeB.destroy(); const newNode instantiate(this.fruitPrefabs[newTypeId]); this.fruitRoot.addChild(newNode); newNode.setWorldPosition(center); const fruit newNode.getComponent(FruitItem); if (fruit) { fruit.init(newTypeId); } const collider newNode.getComponent(Collider); if (collider) { collider.on(onCollisionEnter, this.onFruitCollision, this); } const rigid newNode.getComponent(RigidBody); if (rigid) { rigid.setLinearVelocity(new Vec3(0, 0, 0)); rigid.setAngularVelocity(new Vec3(0, 0, 0)); } this._score this.fruitScores[newTypeId] || 0; this.scoreLabel.string 分数: ${this._score}; }为什么在lateUpdate里处理合并队列而不是在onCollisionEnter里直接销毁节点物理回调发生在物理步进过程中此时销毁刚体关联节点可能触发断言或导致碰撞结果不完整。把合并放到lateUpdate后等物理引擎本轮步进结束再操作节点稳定很多。合并位置取两颗水果世界坐标的中点这样生成的新水果不会偏离接触点太远。新水果刚生成时速度归零避免继承旧水果弹速导致瞬间飞出去。4.4 失败判定与状态切换失败判定的核心是“警戒线 接近静止”不能只判断水果是否越过警戒线。如果水果刚被投出从 Y8.2 自由下落穿过 Y6.8此时它只是路过不应结束游戏。代码里增加 Y 轴速度判断private _isGameOver(): boolean { const children this.fruitRoot.children; for (let i 0; i children.length; i) { const node children[i]; const y node.worldPosition.y; if (y this.boundaryLineY) { const rigid node.getComponent(RigidBody); // 只有接近静止才判定失败避免下落过程中的误判 if (rigid Math.abs(rigid.linearVelocity.y) 0.3) { return true; } } } return false; } private _gameOver() { this._gameState GameState.GameOver; this.stateLabel.string 游戏结束点击重新开始; input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); input.on(Input.EventType.TOUCH_START, this.onRestart, this); } private onRestart() { input.off(Input.EventType.TOUCH_START, this.onRestart, this); input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); this._reset(); }boundaryLineY和速度阈值 0.3 都是需要根据自己场景微调的值。警戒线定得太高堆到顶部的压力感不足定得太低玩家会觉得莫名其妙就结束。速度阈值如果设成 0水果卡在警戒线附近但还有微小速度时不会结束需要多等几帧体验会变钝。5. UI、音效和手感参数5.1 UI 与状态文案ScoreLabel放在 Canvas 左上角实时显示分数。StateLabel放在 Canvas 中央上方显示“点击掉落”和“游戏结束点击重新开始”。touchRange的UITransform尺寸要覆盖屏幕下方操作区域透明度保持 0它只是坐标转换辅助不参与显示。点击事件绑定在全局input上所以不用给touchRange写Button组件。如果要做多端适配建议在竖屏 Canvas 下把touchRange的Anchor定位到屏幕中部以下避免顶部 UI 遮挡。5.2 物理参数对“手感”的影响下面参数都要在真实设备上反复试编辑器里跑通不代表手机手感正确。参数影响建议范围调错表现重力 Y下落速度、堆叠溢出难度-60 到 -20过大穿模过小“飘”线性阻尼水果水平滑动衰减0 到 0.5过大叠放不自然角速度阻尼旋转衰减速度0.1 到 0.5过大水果像被钉住弹性系数落地反弹高度0 到 0.4过高导致水果跳飞摩擦系数堆叠稳定性0.3 到 0.8过小持续滑落求解迭代次数堆叠稳定性8 到 20过小水果互相挤压穿体这些参数分布在RigidBody的Linear Damping、Angular Damping以及Collider关联的PhysicsMaterial上。注意PhysicsMaterial是共享资源多个碰撞体引用同一个材质时改一次会影响所有引用它的水果。5.3 音效和合成功效最小闭环可以没有音效但要提升完成度可以给GameManager增加两个接口合成为新水果时调用AudioSource播放合成音。新水果生成时给它挂一个节点缩放动画从 0.2 到 1 的快速弹跳视觉上更有“变大了”的反馈。import { Tween, tween } from cc;合成音效的播放建议放在_processMerge成功生成新节点之后不要在onCollisionEnter里播放。物理回调一帧内可能触发多次声音会叠到一起。是否继续做连击提示、最高分记录、成就系统都属于扩展项后面会单独说。6. 运行验证、常见问题和打包6.1 从编辑器预览到真机验证先按以下顺序验证最小闭环点击预览确认水果从顶部生成并落到底板。连续投两颗同类型水果确认它们碰撞后生成新类型水果。新水果生成后旧水果从场景中消失分数增加。不断投小水果直到警戒线附近有水果接近静止确认触发失败文案。点击重新开始确认场景中所有旧水果被清除分数归零。编辑器预览时打开物理调试绘制可以更快确认碰撞半径是否匹配视觉模型。在PhysicsSystem相关调试面板开启后场景里会画出碰撞体线框这一步强烈建议在找穿模问题时开启。6.2 常见问题排查表问题现象可能原因检查方式处理建议水果穿出底板底板没有碰撞体或碰撞体分组错误打开物理调试绘制确认底板是否有线框给底板添加BoxCollider或静态RigidBody水果之间不合并没有在生成时监听onCollisionEnter在回调里打印日志在Collider上调用collider.on(onCollisionEnter, ...)同一次碰撞合成了多个新水果没有检查merged标志打印两次回调查看对象碰撞回调入口先判断并标记点击位置和实际生成位置不一致直接用 UI 坐标设置 3D 坐标打断点查看worldPos.x使用UITransform.convertToWorldSpaceAR水果堆叠时不断抖碰撞半径和视觉半径不一致打开物理调试绘制统一SphereCollider.radius与视觉缩放水果刚生成就消失生成点和已有水果重叠查看生成瞬间是否有碰撞调整spawnY或对新水果加 0.1 秒无碰撞期真机上水果堆叠异常cannon.js 与 bullet 物理表现不一致对比编辑器与真机日志在真机端选择合适物理引擎并调整迭代次数6.3 构建 Web 和 APK 时的注意点学习阶段用浏览器预览就够了。要发布时先构建 Web Mobile 版本在手机浏览器访问确认触摸事件正常再把工程构建为 Android APK。构建时重点检查这几项屏幕方向合成大西瓜适合竖屏构建模板里设置为竖屏。物理引擎不同引擎在移动端性能差异明显如果发现水果数量多时明显卡顿尝试切换 bullet。资源压缩水果模型和贴图要控制尺寸预制体数量不多时影响较小。版本号与包名构建 APK 前在项目设置里配置包名、版本号、应用图标。日志开关正式包不要保留调试日志避免影响性能。发布前可以按这个清单快速自查是否在真机上测试过触摸生成的坐标映射。是否设置了失败警戒线和重启逻辑。是否检查了不同分辨率下touchRange的范围。是否选择合适物理引擎并验证过堆叠稳定性。是否关闭调试绘制切换为正式资源。7. 参数调优与扩展方向7.1 调参顺序建议不要一上来同时改十个参数否则出问题不知道是谁引起的。推荐顺序先固定重力让水果下落速度看起来“爽快且不穿模”。再调碰撞半径让水果视觉大小和物理大小一致。再调阻尼和摩擦让水果堆叠后能稳定停住。最后调失败线高度和速度阈值控制游戏难度。每改一个参数至少完整玩三局再判断结果。物理参数存在随机性单局手感可能骗人。7.2 可以继续扩展的方向不同水果掉落概率当前maxDropTypeId之后只投小水果可以加权重中后期提高大水果出现频率。最高分记录使用localStorage保存和展示历史最高分。特效节奏合并时播放粒子效果、震屏、背景颜色渐变。关卡设计规定限时、限定投掷次数、目标分数等让玩法变成关卡制。3D 相机视角加入轻微跟随和缩放增强水果堆积时的立体感。合成大西瓜是一个非常适合练手的小型物理游戏项目。它的技术点不复杂但把碰撞回调、节点销毁、状态切换、坐标转换放在一起足够让新手理解 Cocos Creator 3D 物理游戏从搭建到发布的基本链路。做完这一版后再去尝试更复杂的物理休闲游戏会明显感觉到“底层套路”是相通的。