简介:这是一份基于Cocos Creator引擎与TypeScript开发的抓娃娃小游戏完整示例,面向游戏开发初学者及Cocos Creator进阶学习者,帮助理解2D物理交互、动画控制、UI事件响应与单机逻辑架构等核心实践问题。资源包共992个文件,涵盖225个PNG图像资源(含角色、背景、UI素材)、43个Prefab预制体(定义爪子、娃娃、场景节点等可复用对象)、321个meta元数据文件(支撑Cocos资源管理)、289个JSON配置(如动画参数、关卡数据),以及6个TS脚本文件(承载主控逻辑、碰撞判定与状态管理),整体压缩包仅7.72MB,轻量易学。已有1121人学习下载。读者可直接运行调试,深入掌握Cocos Creator中Anim动画系统驱动爪子运动、物理刚体模拟抓取反馈、TS强类型定义游戏实体、本地存档与后端接口预留设计等关键能力,是理论结合实操的优质入门级项目范例。
1. 抓娃娃机逻辑跑通只要 3 分钟:Cocos Creator + TypeScript 实现物理抓取、奖品掉落、状态反馈全链路
你有没有试过在 Cocos Creator 里写一个「看起来像真机器」的抓娃娃 Demo?不是只放个动画按钮,而是让爪子真的受力下落、碰到娃娃会反弹、夹住后悬停 1.2 秒再判定是否成功、失败时娃娃弹跳复位、成功时从出口滑出——这些细节,90% 的新手卡在物理刚体配置和状态机切换上,反复改RigidBody2D的gravityScale和linearDamping却始终手感发飘。这个项目就是为解决这个问题而生:它用纯 TypeScript 在 Cocos Creator 3.8+ 环境下实现了完整抓娃娃机交互闭环,包含爪子运动控制、碰撞检测、奖品物理模拟、状态提示(“夹住了!”/“差一点!”)、以及可扩展的奖品池管理。适合刚学完 Cocos 官方物理文档、想拿一个有真实反馈的小项目练手的开发者;也适合需要快速嵌入小游戏模块的中台团队——它不依赖任何第三方插件,所有逻辑集中在ClawMachineController.ts和PrizeItem.ts两个脚本里,删掉资源文件夹就能直接塞进你的主工程。
2. 工程结构与核心模块拆解:从场景搭建到脚本职责划分
2.1 场景层级与关键节点命名规范
整个 Demo 基于 Cocos Creator 3.8.2 构建(兼容 3.7+),使用 2D 物理系统(BuiltinPhysics2D),场景结构极简但职责清晰:
Canvas ├── Background (Sprite) ├── MachineBody (Sprite + BoxCollider2D) ├── ClawArm (Node) │ ├── ClawBase (Sprite + RigidBody2D: Static) │ └── ClawJaw (Sprite + RigidBody2D: Dynamic, gravityScale=0) ├── PrizeArea (Node) │ ├── PrizePool (Node) // 所有奖品预制体实例的父节点 │ └── ExitChute (Sprite + BoxCollider2D: Trigger) ├── UI │ ├── BtnStart (Button) │ ├── TxtStatus (Label) │ └── PanelResult (Node + Sprite) └── GameController (Empty Node + ClawMachineController.ts)提示:
ClawJaw必须设为Dynamic刚体且gravityScale = 0,否则下落过程会被重力持续加速,失去“电机驱动”的可控感;ClawBase设为Static是为了固定旋转轴心,避免爪臂整体漂移。
2.2 核心脚本职责与通信方式
项目仅含 3 个自定义脚本,全部使用 TypeScript 编写,无任何any类型,类型定义完整:
| 脚本名 | 所属节点 | 核心职责 | 通信方式 |
|---|---|---|---|
ClawMachineController.ts | GameController | 主控状态机:空闲→启动→下落→夹紧→提升→判定→复位;监听按钮、管理爪子运动、触发碰撞逻辑、更新 UI | 直接引用ClawJaw、PrizePool、TxtStatus节点;通过emit('claw:grabbed')发布事件 |
PrizeItem.ts | 每个奖品预制体根节点 | 奖品个体行为:响应碰撞、播放掉落动画、进入出口后销毁、失败时复位 | 监听claw:grabbed事件;调用this.node.emit('prize:reset')触发自身复位 |
ExitChuteTrigger.ts | ExitChute节点 | 出口区域触发器:检测奖品进入,触发成功逻辑 | onTriggerEnter2D回调中判断otherCollider.node.name === 'PrizeItem' |
所有状态流转均通过this.state枚举管理,而非布尔变量堆砌,避免状态歧义:
// ClawMachineController.ts enum ClawState { IDLE = 'idle', DROPPING = 'dropping', GRIPPING = 'gripping', LIFTING = 'lifting', JUDGING = 'judging', RESET = 'reset' } @ccclass('ClawMachineController') export class ClawMachineController extends Component { @property({ type: Node }) clawJaw: Node = null; @property({ type: Node }) prizePool: Node = null; @property({ type: Label }) txtStatus: Label = null; private state: ClawState = ClawState.IDLE; private rigidBody: RigidBody2D = null; private gripTimer: number = 0; start() { this.rigidBody = this.clawJaw.getComponent(RigidBody2D); // 绑定按钮点击 this.node.parent.getChildByName('UI').getChildByName('BtnStart').getComponent(Button).clickEvents.push({ target: this.node, component: 'ClawMachineController', handler: 'onStartClick' }); } onStartClick() { if (this.state !== ClawState.IDLE) return; this.setState(ClawState.DROPPING); } setState(newState: ClawState) { this.state = newState; switch (newState) { case ClawState.DROPPING: this.startDropping(); break; case ClawState.GRIPPING: this.startGripping(); break; case ClawState.LIFTING: this.startLifting(); break; case ClawState.JUDGING: this.startJudging(); break; case ClawState.RESET: this.resetClaw(); break; } } }这段代码的关键在于:状态变更必须走setState()统一入口,而不是散落在各处this.state = ...。我当年在一个线上项目里因为漏改某处状态赋值,导致爪子在GRIPPING状态下还能被再次点击启动,最终物理刚体叠加上千次力导致崩溃——从此养成了强制封装状态变更的习惯。
2.3 物理参数配置表:为什么这些数值能“手感对”
抓娃娃机的真实感,70% 来自物理参数的微调。本项目已验证有效的参数组合如下(基于Pixels per Unit = 100的默认设置):
| 参数项 | 节点 | 推荐值 | 作用说明 | 修改影响 |
|---|---|---|---|---|
gravityScale | ClawJaw刚体 | 0 | 关闭重力,由代码控制线性速度下落 | 设为1→ 爪子自由落体,无法精准停在目标高度 |
linearDamping | ClawJaw刚体 | 5 | 阻尼系数,抑制下落末段抖动 | <3→ 爪子触底反弹明显;>8→ 下落迟滞,像在糖浆里移动 |
angularDamping | ClawJaw刚体 | 10 | 抑制旋转抖动,防止爪子歪斜 | 不设 → 夹取时易因碰撞扭矩发生不可控翻转 |
density | 所有PrizeItem刚体 | 0.3 | 奖品质量,影响碰撞响应强度 | >0.5→ 爪子夹住后易被拖拽偏移;<0.1→ 碰撞像打棉花,无反馈 |
friction | PrizeItem刚体 | 0.4 | 奖品与底板摩擦力 | 0→ 奖品滑行过远,难进出口;>0.6→ 卡在角落不动 |
注意:所有奖品预制体必须启用
Auto Simulation,否则onCollisionEnter2D不会触发;ClawJaw的碰撞体建议用PolygonCollider2D包裹爪形,比BoxCollider2D更准。
3. 核心交互逻辑实现:从按下按钮到奖品滑出的完整链路
3.1 爪子下落与夹紧:用LinearVelocity替代applyForce
很多新手习惯用applyForce给爪子加力下落,结果发现速度越来越快、停不准。本项目采用更可控的linearVelocity直接赋值法,配合Time.deltaTime实现匀速下落:
// ClawMachineController.ts private startDropping() { const targetY = -120; // 目标下落 Y 坐标(像素) const speed = 80; // 像素/秒 this.schedule(() => { const currentY = this.clawJaw.position.y; if (currentY <= targetY) { this.clawJaw.setPosition(this.clawJaw.position.x, targetY); this.setState(ClawState.GRIPPING); this.unscheduleAllCallbacks(); return; } // 计算本次位移:speed * deltaTime const deltaMove = speed * dt; this.clawJaw.setPosition( this.clawJaw.position.x, currentY - deltaMove ); }, 0, macro.REPEAT_FOREVER, 0); }这里dt是macro.REPEAT_FOREVER循环中每次执行的deltaTime,等价于cc.game.step的时间增量。不用applyForce的根本原因:力是加速度的积分,而我们只需要精确控制位置和速度;用linearVelocity反而容易因帧率波动导致位移跳变。
3.2 夹紧判定与碰撞过滤:只响应“爪子-奖品”有效碰撞
爪子夹紧时,需检测是否真正接触到奖品。但onCollisionEnter2D会同时触发爪子与机器外壳、底板等无效碰撞,必须过滤:
// ClawMachineController.ts onCollisionEnter2D(event: CollisionEvent2D) { const selfCollider = event.selfCollider; const otherCollider = event.otherCollider; // 只处理爪子(ClawJaw)与奖品(PrizeItem)的碰撞 if ( selfCollider.node.name === 'ClawJaw' && otherCollider.node.name === 'PrizeItem' && this.state === ClawState.GRIPPING ) { // 获取奖品脚本 const prizeScript = otherCollider.node.getComponent(PrizeItem); if (prizeScript && !prizeScript.isGrabbed) { prizeScript.onGrabbed(); // 触发奖品被抓逻辑 this.txtStatus.string = '夹住了!'; this.setState(ClawState.LIFTING); } } }PrizeItem.ts中的isGrabbed是关键标志位,防止同一奖品被多次夹取:
// PrizeItem.ts @property({ type: Boolean }) isGrabbed: boolean = false; onGrabbed() { this.isGrabbed = true; this.node.getComponent(RigidBody2D).linearVelocity = v2(0, 0); // 立即停止运动 this.node.getComponent(RigidBody2D).gravityScale = 0; // 关闭重力,随爪子上升 }3.3 提升与判定:用计时器替代帧数硬编码
提升阶段需保持爪子夹住奖品上升 1.2 秒,然后松开并判定是否进入出口。绝不能写for (let i = 0; i < 72; i++)这种帧数循环——不同设备帧率不同,会导致时长偏差。正确做法是用scheduleOnce配合dt累加:
// ClawMachineController.ts private startLifting() { const liftSpeed = 60; // 像素/秒 const liftDuration = 1.2; // 秒 let elapsed = 0; this.schedule(() => { elapsed += dt; const currentY = this.clawJaw.position.y; const newY = currentY + liftSpeed * dt; this.clawJaw.setPosition(this.clawJaw.position.x, newY); if (elapsed >= liftDuration) { this.clawJaw.setPosition(this.clawJaw.position.x, currentY); // 精确停在当前Y this.setState(ClawState.JUDGING); this.unscheduleAllCallbacks(); } }, 0, macro.REPEAT_FOREVER, 0); } private startJudging() { // 松开爪子:恢复奖品重力,允许其自由下落 const prizes = this.prizePool.children; for (const prize of prizes) { const prizeScript = prize.getComponent(PrizeItem); if (prizeScript?.isGrabbed) { prizeScript.release(); // PrizeItem.ts 中定义 prizeScript.isGrabbed = false; } } this.setState(ClawState.RESET); }PrizeItem.release()内部会重置gravityScale并施加一个向下的初速度,模拟“松开瞬间的惯性下坠”。
4. 常见问题排查:5 个真实踩坑记录与血泪修复方案
4.1 现象:爪子下落时抖动严重,像在抽搐
原因:ClawJaw刚体的linearDamping过低(<3),且未关闭allowSleep,导致物理引擎频繁唤醒/休眠。
解决:将ClawJaw刚体的linearDamping设为5,allowSleep设为false;下落逻辑改用setPosition而非applyForce。
4.2 现象:点击开始按钮没反应,控制台无报错
原因:BtnStart按钮的clickEvents未正确绑定到GameController节点,或GameController脚本未挂载到场景节点。
解决:检查GameController节点是否在Canvas下;在start()中添加console.log('Claw controller initialized')确认脚本加载;手动在编辑器中重新拖拽Button.clickEvents的target到GameController节点。
4.3 现象:奖品被夹住后,爪子提升时奖品脱离爪子飞出去
原因:PrizeItem的RigidBody2DgravityScale未在onGrabbed()中设为0,导致提升过程中重力持续向下拉。
解决:在PrizeItem.onGrabbed()中增加this.getComponent(RigidBody2D).gravityScale = 0;,并在release()中恢复为1。
4.4 现象:多个奖品堆叠时,爪子只夹到最上面一个,下面的没响应
原因:PrizeItem预制体的Collider2D层级(Layer)与ClawJaw不在同一物理层,或PhysicsSystem2D.instance.enable = false。
解决:确认PhysicsSystem2D.instance.enable为true;在Project Settings > Physics中确保ClawJaw和PrizeItem的 Collider 层级勾选了相互碰撞(Collision Matrix)。
4.5 现象:奖品滑入ExitChute后没触发成功逻辑,控制台报otherCollider.node is null
原因:ExitChuteTrigger.ts的onTriggerEnter2D回调中,otherCollider.node在某些帧已被销毁(如奖品刚进入就触发销毁逻辑)。
解决:在回调开头加防御性判断:
onTriggerEnter2D(event: TriggerEvent2D) { if (!event.otherCollider || !event.otherCollider.node) return; if (event.otherCollider.node.name !== 'PrizeItem') return; // 后续逻辑... }5. 二次开发实战:3 种低成本扩展方案与验证技巧
5.1 方案一:接入真实奖品池(JSON 配置驱动)
当前奖品是静态预制体,实际项目需动态加载。只需两步改造:
第一步:定义奖品配置 JSON
// resources/prizes.json { "items": [ { "id": "bear_001", "prefabPath": "prefabs/PrizeBear", "weight": 85, "name": "毛绒熊" }, { "id": "robot_002", "prefabPath": "prefabs/PrizeRobot", "weight": 10, "name": "机械臂模型" } ] }第二步:修改ClawMachineController.start()加载逻辑
start() { // ...原有初始化 this.loadPrizeConfig(); } private async loadPrizeConfig() { const config = await resources.load('prizes', JsonAsset); const prizeData = config.json.items; // 按 weight 权重随机选取一个奖品 const totalWeight = prizeData.reduce((sum, p) => sum + p.weight, 0); let rand = Math.random() * totalWeight; let selected: any = null; for (const item of prizeData) { rand -= item.weight; if (rand <= 0) { selected = item; break; } } if (selected) { const prefab = await resources.load(selected.prefabPath, Prefab); const node = instantiate(prefab); node.setParent(this.prizePool); node.setPosition(v3(0, 0, 0)); } }验证技巧:在
loadPrizeConfig()后加console.table(prizeData),运行时打开浏览器控制台,一眼看清加载了哪些奖品及权重分布。
5.2 方案二:增加“夹取力度”可视化反馈
用户需要感知操作强度。可在 UI 上加一个力度条,随按钮按压时长增长:
// ClawMachineController.ts @property({ type: ProgressBar }) pbPower: ProgressBar = null; private powerLevel: number = 0; private isHolding: boolean = false; onStartClick() { if (this.state !== ClawState.IDLE) return; this.isHolding = true; this.powerLevel = 0; this.pbPower.progress = 0; this.schedule(() => { if (!this.isHolding) return; this.powerLevel = Math.min(1, this.powerLevel + 0.02); this.pbPower.progress = this.powerLevel; }, 0.05); } // Button 的 press events 中绑定 onButtonPress,release events 中绑定 onButtonRelease onButtonRelease() { this.isHolding = false; this.unscheduleAllCallbacks(); // 根据 powerLevel 决定夹紧力度:powerLevel 越高,grip duration 越长 const gripDuration = 0.8 + (this.powerLevel * 0.6); // 0.8~1.4 秒 this.gripTimer = gripDuration; this.setState(ClawState.GRIPPING); }血泪经验:力度条必须用
ProgressBar而非Sprite.fillRange,后者在 WebGL 下存在渲染闪烁;schedule间隔设为0.05是为兼顾低端机帧率(20fps)与反馈灵敏度。
5.3 方案三:对接后台统计(埋点上报)
上线前必加埋点。在ClawMachineController中注入轻量上报方法:
// ClawMachineController.ts private reportEvent(eventType: string, data: Record<string, any> = {}) { // 使用原生 fetch,不引入 axios 等依赖 fetch('https://your-api.com/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ eventType, timestamp: Date.now(), scene: 'claw-machine', ...data }) }).catch(err => console.warn('Log failed:', err)); } // 在关键节点调用 setState(newState: ClawState) { this.reportEvent('state_change', { from: this.state, to: newState }); // ...原有逻辑 } onGrabbed() { this.reportEvent('prize_grabbed', { prizeId: this.node.name, gripDuration: this.gripTimer }); }验证技巧:本地调试时把
fetch替换为console.log,上线前全局搜索reportEvent确保无遗漏;所有上报字段必须小驼峰命名,与后台协议对齐。
从那以后我每次做交互类 Demo,都会在start()里加一行console.time('init'),在onLoad()结尾加console.timeEnd('init'),再配合performance.now()测关键路径耗时——不是为了炫技,是怕哪天用户反馈“点一下要等两秒”,而你连瓶颈在哪都不知道。希望帮到你。
本文还有配套的精品资源,点击获取