ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Cocos Creator 3.8制作3D合成大西瓜:物理引擎与TypeScript实战

用Cocos Creator 3.8制作3D合成大西瓜:物理引擎与TypeScript实战 这次我们来看一个非常典型的小游戏项目用Cocos Creator 3.8制作 3D 版合成大西瓜。玩法大家都很熟点击或触摸投放水果相同等级的水果碰到一起就合并成下一等级一路合成到“大西瓜”。区别在于这次不是 2D 平面逻辑而是完整走 3D 物理引擎刚体下落、球形碰撞、碰撞合并、警戒线失败判定全套在 TypeScript 里实现。对新手来说这个项目最大的价值不是“能做一个游戏”而是把 Cocos Creator 3.8 里最常用的 3D 开发链路完整走了一遍场景搭建、Prefab 实例化、RigidBody 刚体配置、Collider 碰撞监听、触摸坐标转 3D 世界坐标、UI 分数更新、微信小游戏构建发布。这些能力学会了后面做 3D 跳一跳、3D 消消乐、物理沙盒原型都能直接复用。整篇文章会按“核心能力、适用场景、环境准备、场景搭建、物理配置、TS 脚本、功能测试、发布扩展、问题排查、最佳实践”的顺序展开。代码会给出可复制版本但引擎 API 细节要以你本机安装的 3.8.x 版本为准版本之间存在少量差异下面开始。1. 核心能力速览能力项说明引擎版本Cocos Creator 3.8.x开发语言TypeScriptTS项目类型3D 物理休闲小游戏核心玩法点击/触摸投放水果相同等级碰撞合并合成大西瓜学习重点3D 场景搭建、RigidBody 刚体、SphereCollider 碰撞、Prefab 实例化、触摸转世界坐标运行平台编辑器预览窗口、Web 浏览器、微信小游戏等构建平台电脑门槛普通 PC 即可能流畅运行 Cocos Creator 3.8 就行3D 物理合并不需要高配独立显卡输入交互鼠标点击 / 手机触摸扩展方向微信小游戏激励广告、排行榜、每日任务、对象池优化这个项目不需要联网不依赖第三方后端所有核心玩法都在本地模拟。你只需要装好 Cocos Creator 3.8新建一个 3D 项目就能按本文步骤跑起来。2. 适用场景与使用边界这个项目非常适合三类人刚接触 Cocos Creator 的新手、想从 2D 转向 3D 的开发者、需要交游戏课设或做微信小游戏原型验证的团队。它能解决的实际问题包括理解 3D 游戏里“静态刚体”和“动态刚体”的区别理解碰撞回调为什么不是在所有情况下都触发理解触摸屏幕坐标怎么映射到 3D 场景理解相同类型节点碰撞后如何避免重复合并。它不擅长解决的问题也要说清楚如果要做完善的联网对战、实时排行榜、复杂三消关卡这个项目还需要大量扩展。它的定位是“核心玩法原型”不是完整商业项目。合规层面必须注意几点第一水果素材、背景图、音效如果来自网络商用前要确认授权第二如果后续接入微信小游戏激励广告必须使用微信官方广告组件并且在小游戏后台申请广告位不能绕过平台私自弹广告第三不涉及用户隐私数据采集不需要额外隐私协议但一旦加入账号系统、好友排行就必须补全个人信息保护和平台审核要求。3. 环境准备与前置条件3.1 安装 Cocos DashboardCocos Creator 现在的安装方式统一走 Cocos Dashboard。去 Cocos 官网下载对应系统的 Dashboard 安装包安装完成后登录 Cocos 账号在 Dashboard 的“编辑器”页面选择 3.8.x 版本下载。建议下载 3.8 LTS 版本稳定性和文档匹配度都更好。3.2 新建 3D 项目打开 Dashboard点击“新建项目”项目模板选择“3D”。创建后会进入 Cocos Creator 3.8 编辑器。项目里默认会有一个场景文件后续的 3D 物体、相机、灯光都在这个场景中搭建。创建项目时注意项目路径不要包含中文和空格Cocos 对非 ASCII 路径偶尔会有兼容问题。这是新手最容易忽略的点。3.3 确认物理模块可用Cocos Creator 3.8 的 3D 物理不是一个默认就完整可用的状态需要确认两件事第一在“项目设置 - 功能裁剪”里确认 3D 物理相关模块被勾选第二在“项目设置 - 物理”里选择一个可用的 3D 物理后端。不同物理后端在碰撞精度和性能上有差异但对合成大西瓜这种简单刚体场景默认配置就够用。如果不确认模块开启情况最直接的方法是跑一个最简单的物理测试创建一个 Box 节点挂上 RigidBody 和 BoxCollider点击预览看它是否正常下落到地面。能下落说明物理环境没问题。3.4 目录结构规划建议在 assets 下按功能建目录assets/ scenes/ 场景文件 scripts/ TS 脚本 prefabs/ 水果 Prefab materials/ 材质 textures/ 图片资源 audio/ 音效资源这样做的原因是后面做微信小游戏构建时资源查找、分包加载、异常排查都会清晰很多。不要把所有资源都放在 assets 根目录尤其是水果 Prefab 和材质一多起来根目录会非常混乱。4. 搭建 3D 场景与基础物体4.1 调整场景相机新建 3D 项目后场景里默认有一个 Main Camera 和一个 Directional Light。合成大西瓜需要一个俯视视角相机放在场景上方偏后的位置向下看。选中 Main Camera在属性检查器中设置position 大约在(0, 15, 14)rotation 大约在(-45, 0, 0)然后微调位置让整个“容器”出现在画面中间。手机竖屏玩家要看到的是整个容器和水果下落区域所以相机视口要保证左右墙和底部都在画面内。4.2 创建地面和墙体合成大西瓜需要一个开放式的“碗”底部有地面左右有墙后侧有墙前方不封口这样玩家才能看到水果。如果四周完全封闭会挡住视线。在场景中创建 Box 节点分别命名 Ground、LeftWall、RightWall、BackWall。Box 默认是 1×1×1直接改 scale 和 position 来拼出容器尺寸。一个可以参考的配置节点ScalePositionGround(12, 0.5, 10)(0, -0.25, 0)LeftWall(0.5, 8, 10)(-6, 4, 0)RightWall(0.5, 8, 10)(6, 4, 0)BackWall(13, 8, 0.5)(0, 4, -5)每个墙体节点都要添加 BoxCollider 组件并添加 RigidBody 组件刚体类型选择STATIC。因为墙和地面不运动不需要动态模拟静态刚体性能更好也不会被水果撞飞。这里最容易犯的错误是只加 Collider 不加 RigidBody或者刚体类型保持默认动态然后发现整个容器被水果撞得乱跑。4.3 添加基础材质为区分地面、墙体和水果建议创建几个不同颜色的材质。在 assets 下新建材质选择 Surface 类型调整 Base Color 后拖到对应节点上。不需要太复杂颜色区分清楚就行。主要看效果不需要一上来就调 PBR 参数。5. 物理与碰撞配置5.1 给水果创建 Prefab水果不能每个都手动创建节点。正确做法是先做一个基础水果 Prefab后续通过instantiate实例化。新建一个 Sphere 节点命名为 Fruit添加 SphereCollider 和 RigidBody。RigidBody 类型保持DYNAMIC这样水果才能受重力下落。然后把这个节点拖到 assets/prefabs 目录下生成 Fruit.prefab。如果要做多种等级水果可以复制多个 Prefab每个用不同颜色和大小也可以只用一个 Prefab在运行时通过setScale修改大小、通过材质实例修改颜色。对新手来说多个 Prefab 更直观代码量也更少。5.2 设置物理材质水果之间会有弹跳但弹跳太大会导致合并不稳定。可以在 assets 下创建一个 PhysicsMaterial把摩擦系数调低恢复系数调到 0.2 左右然后拖到水果 Prefab 的 SphereCollider 材质属性里。这样水果掉落到地面后不会一直弹跳物理模拟会更接近真实“合成大西瓜”的感觉。5.3 碰撞分组Cocos Creator 3D 物理默认分组是DEFAULT。简单项目不需要自定义分组让所有水果和墙体的碰撞分组都处于默认分组并且碰撞矩阵中该分组的“与自己碰撞”和“与其他默认分组碰撞”都开启即可。复杂项目再考虑把水果、墙、警戒线分成不同 group。5.4 警戒线节点合成大西瓜的失败条件是“水果堆到警戒线以上”。实现方式有两种一种是普通碰撞传感器另一种是逻辑判断。这里推荐逻辑判断不需要额外创建不可见碰撞体直接在代码里设置一个loseY数值每帧检查活跃水果是否有静止在警戒线上方的情况。6. TS 脚本实现核心玩法6.1 FruitItem.ts 水果节点脚本创建一个脚本FruitItem.ts挂在水果 Prefab 上。这个脚本负责记录水果等级、监听碰撞、触发合并事件。import { _decorator, Collider, Component, ICollisionEvent, Vec3 } from cc; const { ccclass, property } _decorator; ccclass(FruitItem) export class FruitItem extends Component { // 水果等级0 最小逐级递增 property level 0; private merged false; onLoad() { const collider this.getComponent(Collider); if (collider) { collider.on(onCollisionEnter, this.onCollisionEnter, this); } this.merged false; } private onCollisionEnter(event: ICollisionEvent) { if (this.merged) return; const other event.otherCollider; if (!other) return; const otherFruit other.getComponent(FruitItem); if (!otherFruit) return; if (otherFruit.merged) return; if (otherFruit.level ! this.level) return; // 两个节点的碰撞回调都会触发只让其中一个节点执行合并 if (this.node.uuid otherFruit.node.uuid) return; // 再检查一次节点有效性避免销毁后继续处理 if (this.node.destroyed || otherFruit.node.destroyed) return; this.merged true; otherFruit.merged true; const mergePos new Vec3( (this.node.position.x otherFruit.node.position.x) / 2, (this.node.position.y otherFruit.node.position.y) / 2, (this.node.position.z otherFruit.node.position.z) / 2 ); // 向外层发送合并事件由 GameManager 生成新的水果 this.node.emit(fruit-merge, { level: this.level 1, position: mergePos }); this.node.destroy(); otherFruit.node.destroy(); } }核心思路每个水果都有单独的level。碰撞发生时判断对方是不是水果、等级是否相同、是不是 already 合并过。三个条件都满足才执行合并。特别注意碰撞双方都各自注册了回调如果不加merged标记同一个碰撞对会被处理两次导致生成两个大水果。这里的this.node.uuid otherFruit.node.uuid是一种简单但可靠的“单边处理”策略。6.2 GameManager.ts 游戏管理脚本创建一个空节点 GameManager挂GameManager.ts。这个脚本负责监听触摸、投放水果、随机等级、接收合并事件、更新分数、判断游戏结束。import { _decorator, Camera, Component, EventTouch, input, Input, instantiate, Label, Node, Prefab, RigidBody, Vec3 } from cc; import { FruitItem } from ./FruitItem; const { ccclass, property } _decorator; ccclass(GameManager) export class GameManager extends Component { // 水果 Prefab 数组按等级排列 property({ type: Prefab }) fruitPrefabs: Prefab[] []; // 水果节点统一挂载的父节点 property({ type: Node }) spawnRoot: Node null!; // 分数 UI property({ type: Label }) scoreLabel: Label null!; // 投放高度 property dropY 12; // 警戒线高度 property loseY 8; private score 0; private fruits: Node[] []; private isOver false; onLoad() { input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); } onDestroy() { input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); } private onTouchStart(event: EventTouch) { if (this.isOver) return; const camera Camera.main; if (!camera) return; // 获取触摸在屏幕上的位置 const uiPos event.getUILocation(); const screenPos new Vec3(uiPos.x, uiPos.y, 10); const worldPos camera.screenToWorld(screenPos); // 只使用点击得到的 X 坐标Y 固定为投放高度Z 固定为 0 const spawnPos new Vec3(worldPos.x, this.dropY, 0); this.spawnFruit(spawnPos); } private spawnFruit(pos: Vec3) { const level this.randomLevel(); const prefab this.fruitPrefabs[level]; if (!prefab) return; const fruitNode instantiate(prefab); fruitNode.setParent(this.spawnRoot); fruitNode.setPosition(pos); const fruit fruitNode.getComponent(FruitItem); if (fruit) { fruit.level level; } const scale this.getScaleByLevel(level); fruitNode.setScale(scale, scale, scale); fruitNode.on(fruit-merge, this.onFruitMerge, this); this.fruits.push(fruitNode); } private randomLevel(): number { // 用概率表控制随机等级开局尽量出小水果 const table [0, 0, 0, 0, 1, 1, 1, 2, 2, 3]; const idx Math.floor(Math.random() * table.length); return table[idx] ?? 0; } private getScaleByLevel(level: number): number { return 0.5 level * 0.15; } private onFruitMerge(data: { level: number; position: Vec3 }) { // 如果已经超过最大等级只加分不再生成新水果 if (data.level this.fruitPrefabs.length) { this.addScore(50); return; } const prefab this.fruitPrefabs[data.level]; if (!prefab) return; const fruitNode instantiate(prefab); fruitNode.setParent(this.spawnRoot); fruitNode.setPosition(data.position); const fruit fruitNode.getComponent(FruitItem); if (fruit) { fruit.level data.level; } const scale this.getScaleByLevel(data.level); fruitNode.setScale(scale, scale, scale); fruitNode.on(fruit-merge, this.onFruitMerge, this); this.fruits.push(fruitNode); this.addScore(data.level * 10); // 清理已被销毁的节点引用 this.fruits this.fruits.filter((n) n.isValid); } private addScore(value: number) { this.score value; if (this.scoreLabel) { this.scoreLabel.string 分数${this.score}; } } update() { if (this.isOver) return; for (const node of this.fruits) { if (!node || !node.isValid) continue; if (node.position.y this.loseY) continue; const rigidBody node.getComponent(RigidBody); if (rigidBody rigidBody.linearVelocity.length() 0.5) { this.gameOver(); return; } } } private gameOver() { this.isOver true; if (this.scoreLabel) { this.scoreLabel.string 游戏结束最终分数${this.score}; } // 这里可以继续扩展重新开始按钮、广告复活、上传分数等 } }这个脚本有几个点值得单独说明。第一randomLevel用了固定概率表前几级出现概率高。直接Math.random()会偶尔连续出大水果新手测试时体验会很差。概率表实现简单也能让游戏前期更平滑。第二camera.screenToWorld需要传入一个带深度信息的屏幕坐标。这里深度取 10是因为相机距离场景中心较远取一个合适的前方深度能算出场景内的点击位置。实际项目中要根据相机位置调整这个深度参数。如果发现点击生成的水果 X 坐标偏了优先检查这里。第三linearVelocity.length()用来判断水果是否接近静止。警戒线判断不能只看位置因为刚掉落到警戒线附近的动态水果速度很大应该允许它继续下落。只有当水果停在警戒线上方且速度很小才判定游戏结束。这是一种比“碰撞传感器”更容易控制的失败判定方式。6.3 在编辑器中关联属性写完脚本后回到编辑器把 GameManager 脚本挂到场景中的 GameManager 节点。创建 Canvas 下的 Score 节点添加 Label 组件绑定到 GameManager 的 scoreLabel 属性。创建 SpawnRoot 空节点设置为水果的统一父节点。把多个水果 Prefab 按等级顺序拖到 fruitPrefabs 数组里。调整 dropY 和 loseY 的值让投放高度和警戒线高度匹配你的场景尺寸。没有在编辑器中正确关联 Prefab 数组是新手最容易遇到的空引用报错。运行时如果看到 “fruitPrefabs[level] is null” 或 “Cannot read property” 这类错误先回到编辑器检查属性是否绑定成功。7. 功能测试与效果验证7.1 基础投放测试点击编辑器上方的预览按钮浏览器打开游戏后点击场景任意位置观察是否有一个水果从顶部掉下来。判断标准水果受重力下落而不是卡在原位。水果落在底部后不会无限弹跳。连续点击多个水果它们会堆叠而不是重叠穿透。穿透通常是因为 Collider 尺寸不对或物理步长过大。水果是球形推荐使用 SphereCollider 并勾选“跟随节点缩放”相关配置。7.2 合并测试投放两个相同等级的水果让它们碰撞。观察它们是否消失并生成一个等级更高的新水果。判断标准相同等级碰撞且只生成一个大水果。不同等级碰撞不合并。同一个碰撞对不会生成两个新水果。如果生成了两个大水果说明merged标记或 uuid 单边处理逻辑没生效。检查 FruitItem 里是否在onCollisionEnter开头就 return 了已经合并过的节点。7.3 分数测试每次合并后分数应该增加。到最高等级时会触发加分但不生成新水果的逻辑此时调试日志或分数面板应该能看到对应变化。7.4 游戏结束测试连续投放大量水果让水果堆到警戒线上方并静止。观察是否弹出“游戏结束”。判断标准水果运动过程中即使超过警戒线也不会立刻判定失败。水果静止在警戒线上方时才会触发失败。游戏结束后再点击不再生成新水果。如果游戏刚开始就判定失败说明失败判定没有判断速度或者loseY设置过低、新水果生成时已经处于警戒线上方。把投放高度dropY和警戒线loseY拉开差距即可。7.5 性能观察在编辑器预览窗口左侧打开 Profiler重点关注节点数和物理耗时。合成大西瓜的水果数量完全由玩家投放和合并产生游戏越到后期场景中水果越多DrawCall 和物理计算压力越大。如果出现明显卡顿常见的优化顺序是关闭阴影、减少粒子、给水果节点做对象池而不是频繁 instantiate/destroy、把墙体碰撞体合并成更少的静态碰撞体。8. 发布构建与接口扩展8.1 构建 Web 版本在 Cocos Creator 编辑器顶部菜单点击“项目 - 构建发布”平台选择 Web Desktop 或 Web Mobile。构建完成后在输出目录里会生成可以直接部署到静态服务器上的 Web 文件。Web 版本适合快速分享和真机预览。构建后建议用本地静态服务器打开不要直接用 file 协议双击 index.html避免浏览器安全策略导致资源加载异常。8.2 构建微信小游戏版本在构建发布面板中平台选择“微信小游戏”填入小游戏的 AppID点击构建。构建完成后用微信开发者工具打开构建输出目录。这里有一个重要判断微信小游戏运行环境对 WebGL 的支持和桌面浏览器不完全一致。如果真机出现黑屏、水果不显示优先检查物理后端和渲染后端是否在目标平台可用。8.3 微信小游戏激励广告扩展方向合成大西瓜常见的商业化玩法是“看广告复活”或“看广告加分”。实现上使用的是微信小游戏官方接口和小程序广告基本一致。// 仅在微信小游戏环境调用 if (typeof wx ! undefined wx.createRewardedVideoAd) { const ad wx.createRewardedVideoAd({ adUnitId: 测试广告位ID }); ad.onClose((res: any) { // res.isEnded 为 true 表示完整播放 if (res res.isEnded) { // 发放复活或加分奖励 } else { // 中途退出不发奖励 } }); ad.show().catch(() { ad.load().then(() ad.show()); }); }注意三件事广告位 ID 必须在微信小游戏后台申请开发时可以使用测试广告位广告播放前要在游戏 UI 中明确提示玩家“观看视频可获得奖励”不能诱导点击广告相关代码要做好平台判断浏览器预览环境下没有wx对象不能直接调用。8.4 排行榜与后端接口如果要做排行榜可以接微信小游戏开放数据域或者自建后端 HTTP 接口。Cocos Creator 里发请求可以用fetch或XMLHttpRequest逻辑和普通 Web 开发一致。核心注意点不要在客户端直接信任分数值最终排名数据必须由服务端校验如果只是学习演示本地用云开发数据库即可。9. 常见问题与排查方法问题现象可能原因排查方式解决方案水果直接穿过地面地面缺少 RigidBody 或 Collider检查地面节点的物理组件给地面添加静态刚体和 BoxCollider水果被碰撞后弹飞物理材质恢复系数过高查看 PhysicsMaterial 参数把 restitution 调到 0.2 左右两个水果同时生成大水果合并事件被重复处理检查 FruitItem 事件回调添加上面提到的 merged 标记和 uuid 单边处理点击位置和水果 X 坐标偏差大screenToWorld 深度参数不对打印 worldPos 和 spawnPos调整屏幕坐标的 z 值或按相机距离计算水果卡在半空不动刚体类型不是 DYNAMIC选中水果查看 RigidBody把刚体类型改为 DYNAMIC预览时有物理但无重力物理后端配置异常检查项目设置里的物理选项切换可用后端或创建一个测试 Box 验证微信小游戏打开白屏构建平台设置或渲染后端问题打开微信开发者工具控制台切换 WebGL 版本确认功能裁剪模块包含 3D 物理游戏结束时点击仍有水果生成isOver 逻辑没生效检查 onTouchStart 开头判断确认 gameOver 里 isOver 置为 true分数更新不及时Label 引用未绑定检查 GameManager 属性面板重新绑定 scoreLabel 节点组件水果节点销毁后回调报错碰撞回调中访问了已销毁节点在回调中打印 node.isValid增加 destroyed 和 isValid 判断10. 最佳实践与使用建议10.1 先跑通最小闭环不要一上来就做十几种水果 Prefab 和复杂 UI。先创建一个 Sphere 水果一个地面一个 GameManager 脚本。跑通“点击 - 投放 - 下落 - 碰撞 - 销毁”这五步再逐步加等级、分数、警戒线、音效。最小闭环可以帮你把“物理环境是否正常”和“脚本事件链路是否正常”两个最容易出问题的部分先解决掉。10.2 使用对象池管理水果当前代码为了演示清晰直接instantiate和destroy。项目进入中后期后建议写一个对象池创建水果时从对象池取节点取不到才instantiate。销毁水果时把节点回收而不是直接destroy。对象池统一管理避免频繁创建销毁导致 GC 压力。对象池改造对移动端性能提升非常明显尤其是微信小游戏里小游戏包运行内存受限对象池几乎是必须的优化项。10.3 配置表驱动水果数据水果等级、半径、分数、出现概率、颜色这些数据不要全部散落在脚本里。建议使用 JSON 或 TS 配置对象统一维护const FRUIT_CONFIG [ { level: 0, score: 1, probability: 30, scale: 0.5 }, { level: 1, score: 3, probability: 25, scale: 0.65 }, { level: 2, score: 5, probability: 15, scale: 0.8 } // 后续等级继续补充 ];需要调整数值时只改配置不改逻辑。这对调游戏手感和平衡性非常重要。10.4 打开物理调试线框Cocos Creator 的 3D 物理调试线框很实用。项目设置里开启物理调试后场景中会直接显示所有 Collider 的实际范围。如果水果看起来碰到了墙却没反弹基本就是 Collider 位置或尺寸和模型错位通过线框一眼就能看出来。10.5 真机测试要提前不要在开发最后一天才构建到手机。Web 预览正常不代表微信小游戏真机正常物理后端、渲染后端、资源加载方式都可能有差异。建议从初期开始每周至少做一次微信小游戏构建确认核心玩法在目标平台上没有明显问题。10.6 资源和素材合规水果贴图可以自己画也可以用可商用图库。音效同理。如果做公开演示或发布小游戏上线版本必须在项目工程里记录素材来源和授权情况。不要从搜索引擎随便找图就用尤其是水果这种带品牌形象的商品图片存在版权风险。11. 总结与下一步这个项目最值得尝试的点是用最少的代码把 Cocos Creator 3.8 的 3D 物理链路完整跑通。它比 2D 合成大西瓜更有“手感”差异也比单纯跟着文档写“Hello Box”更有驱动力。你最先应该验证的功能是“点击投放 - 水果下落 - 两个同等级水果碰撞合并”这条核心链路。只要这条链路通了分数、失败判定、UI、广告都是水到渠成的增量。最容易踩的坑有三个水果 Prefab 缺少 RigidBody 导致不落下、碰撞回调缺少merged标记导致重复合并、触摸坐标转 3D 世界坐标的深度参数不对。这三个问题在本文第 6 章和第 9 章都有对应说明建议收藏备用。下一步可以按兴趣继续扩展给水果加对象池和音效接入微信小游戏激励广告做复活功能加“重新开始”按钮和排行榜云存储或者把玩法从“投放合成”改成“拖拽瞄准发射”适配更多 3D 物理玩法。3D 合成大西瓜本身是一个很好的学习型项目做完之后你对 Cocos Creator 3.8 的 3D 场景管理、TypeScript 组件通信、物理碰撞和构建发布的理解会比单纯看文档扎实得多。接下来打开编辑器先建一个 Sphere 和地面把最小闭环跑起来。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进