☰
Cocos Creator跑酷源码实战:动画状态机与动作系统拆解
2026/10/8 15:33:23 网站建设 项目流程

简介:本资源是Cocos Creator实战教程第二篇的配套源码包,面向具备一定引擎基础、希望深入掌握2D跑酷游戏动画与动作系统的开发者。内容围绕类似“天天酷跑”的项目展开,涵盖角色跑步、跳跃等动画资源的创建与切换、动画控制器与关键帧事件触发,以及行为树驱动的寻路、追逐、躲避等动态行为逻辑,并延伸至源码打包发布与性能优化思路。压缩包共46个文件,约6.98MB,包含anim动画文件、js与ts脚本、fire场景、png与jpg图像资源、json配置、plist图集及meta等工程元数据,结构完整可直接导入编辑器运行调试。目前已有132人学习。读者可借此获得一套可运行的跑酷项目源码,对照理解动画状态机、行为树节点组织与对象池等优化手段,适合作为进阶练习与二次开发参考。

1. 拆开这个酷跑源码包:动画状态机与动作系统到底怎么落地

跑酷游戏最折磨人的地方从来不是画角色,而是让角色在跑、跳、滑铲、落地之间切换时不穿帮。我见过太多人用cc.Animation硬切,结果跳跃到一半按下滑铲,角色直接卡成幻灯片。这个cocos_creator2-cool_running_every_day压缩包,就是一份把「天天酷跑」这类横版跑酷的动画与动作系统拆开给你看的实战工程。它基于 Cocos Creator 2.x,目录里assets放场景与脚本,src是逻辑代码,creator.d.ts提供类型提示,project.json和settings锁定项目配置。适合已经能拖拽 Sprite、写过update但一遇到状态切换就翻车的开发者。你拿到手不是读文档,而是直接跑起来看状态机怎么驱动动画、动作队列怎么排队执行。

2. 动画系统拆解:从 Sprite 序列到状态机切换

2.1 动画资源在工程里怎么组织

打开assets目录,你会看到角色动画不是一张大图,而是按状态拆分的序列帧或图集。跑酷角色的动画通常分四类:循环态(跑步、滑铲)、单次态(跳跃、落地、受伤)、过渡态(起跳预备、落地缓冲)、特效态(冲刺残影)。工程里用cc.SpriteAtlas把同一状态的帧打进一个图集,减少 DrawCall。你需要在编辑器里选中图集,在属性检查器里确认Texture类型为sprite-frame,否则拖到时间轴上会报「找不到 spriteFrame」。

常见做法是给每个状态建一个独立的.anim文件,命名规范为role_run、role_jump、role_slide。不要把所有帧塞进一个动画里用wrapMode区分,那样后期加状态会疯掉。wrapMode在代码里通过cc.AnimationClip.prototype.wrapMode设置,循环态用cc.WrapMode.Loop,单次态用cc.WrapMode.Normal,并在最后一帧挂finished事件。

// 在角色控制脚本里预加载动画组件 const anim = this.node.getComponent(cc.Animation); // 播放跑步循环,第二个参数是起始时间,第三个是速度 anim.play('role_run', 0, 1.0); // 跳跃动画只播一次,播完自动停在最后一帧 const jumpState = anim.play('role_jump'); jumpState.wrapMode = cc.WrapMode.Normal; jumpState.speed = 1.2; // 跳跃节奏加快,手感更跟手

这段代码的关键在play返回的AnimationState对象。很多人只调play不拿返回值,结果没法动态改速度或监听结束。speed参数在跑酷里极其重要——角色速度随距离提升时,跑步动画的speed要同步拉高,否则脚底打滑。我一般把speed和角色水平速度绑定,公式是animSpeed = baseSpeed * (currentSpeed / baseSpeed),但上限卡在 1.8,再高就鬼畜了。

2.2 动画控制器与状态切换的代码实现

Cocos Creator 2.x 没有 Unity 那种 Animator Controller 可视化状态机,你得自己写。工程里src下通常有一个RoleStateMachine.js,核心是一个switch或状态字典。状态切换的黄金法则是:任何切换必须先判断当前状态是否允许被打断。跑步可以被打断成跳跃,但跳跃空中不能再次跳跃,滑铲不能在空中触发。

// 状态枚举,别用字符串硬编码,容易拼错 const State = cc.Enum({ RUN: 0, JUMP: 1, SLIDE: 2, HURT: 3 }); // 状态切换方法,带打断优先级 changeState(nextState) { // 空中不允许再次跳跃或滑铲 if (this.currentState === State.JUMP && (nextState === State.JUMP || nextState === State.SLIDE)) { return; } // 受伤状态优先级最高,直接覆盖 if (nextState === State.HURT) { this.anim.stopAll(); this.anim.play('role_hurt'); this.currentState = State.HURT; return; } // 正常切换,先停当前再播下一个 this.anim.stopAll(); this.anim.play(this.stateToClip[nextState]); this.currentState = nextState; }

stopAll会清掉所有动画状态,避免两个动画同时播放导致帧混合。但stopAll有个坑:它会触发当前动画的stop事件,如果你在stop回调里写了逻辑,会被意外执行。稳妥做法是用anim.stop(clipName)精确停止。另外stateToClip是一个映射表,把枚举映射到动画名,改动画名时只改一处。

触发事件是动画系统的隐藏利器。比如跳跃动画播到第 6 帧时角色应该开始下落,你可以在动画编辑器里插入事件,回调里改this.verticalSpeed = -jumpForce。这样动画和物理同步,不会出现「动画还在上升但人已经掉下去」的割裂感。

2.3 动作系统:用队列管理连续动作

跑酷里的动作不是孤立的,跳跃后可能接二段跳,滑铲后接起身跑。工程里用动作队列(Action Queue)来串。核心思路是维护一个actionQueue数组,每帧检查队首动作是否完成,完成则出队并执行下一个。

// 动作队列,每个元素是 {name, duration, onComplete} this.actionQueue = []; // 每帧检查 update(dt) { if (this.actionQueue.length === 0) return; const action = this.actionQueue[0]; action.elapsed += dt; if (action.elapsed >= action.duration) { this.actionQueue.shift(); if (action.onComplete) action.onComplete(); } } // 入队方法,带优先级插队 enqueueAction(action, priority = 0) { if (priority > 0) { this.actionQueue.unshift(action); // 高优先级插队首 } else { this.actionQueue.push(action); } }

duration要和动画时长对齐,否则动作结束了动画还在播。我一般从anim.getAnimationState(name).duration动态取,而不是手写死数字。priority用于受伤、死亡这类必须立即执行的动作,插到队首并清空后续队列。注意update里不要做复杂计算,队列长度超过 5 就该检查是不是逻辑死循环了。

3. 源码打包与工程配置:从编辑器到可执行文件

3.1 工程目录结构与关键文件作用

拿到压缩包解压后,根目录下这几个文件决定了工程能不能跑起来:

文件/目录作用能不能删
assets场景、脚本、图集、动画不能,核心资源
src业务逻辑脚本不能,但可重构
creator.d.ts引擎 API 类型声明不能,删了编辑器报错
jsconfig.json编辑器代码提示配置可删,但体验下降
project.json项目标识与引擎版本不能,删了打不开
settings项目设置,含物理、渲染参数不能,删了配置丢失
.gitignore版本控制忽略规则可删,不影响运行

project.json里的engine字段锁定了 Cocos Creator 版本,如果你本地版本不一致,打开时会提示升级。不要盲目点升级,2.x 小版本之间 API 有差异,升级可能导致cc.Animation的某些方法签名变化。稳妥做法是装一个同版本编辑器,或者先备份再升级。

3.2 打包 APK 与资源压缩的实操步骤

打包 Android 包是热搜里问得最多的。Cocos Creator 2.x 的构建流程是:菜单栏「项目」→「构建发布」→ 选 Android → 填包名 → 构建。但有几个参数直接决定包能不能装、跑不跑得动。

# 构建后的产物在 build/jsb-link 或 build/android 下 # 用 gradle 打包 APK,进入构建目录 cd build/android/proj # 确保 local.properties 里 sdk.dir 和 ndk.dir 指向正确 ./gradlew assembleDebug # 产物在 app/build/outputs/apk/debug/app-debug.apk

local.properties里的 SDK 路径不能有中文和空格,这是血泪经验。NDK 版本要和project.json里声明的匹配,2.4.x 一般用 NDK r19c。构建选项里「MD5 Cache」建议勾上,资源热更新时能避免缓存问题。「压缩纹理」选 ETC2,兼容性最好,但包体会大 15% 左右。如果只发国内安卓,可以勾「仅构建 Android」,跳过 iOS 相关资源。

资源压缩方面,图集用 TexturePacker 导出时选PNG-8加抖动,体积能降 40%。音频用.mp3而不是.wav,背景音乐码率 96kbps 足够。打包前跑一遍「项目」→「构建发布」里的「自动图集」,把散图合并,DrawCall 能从 80 降到 20 以内。

3.3 性能优化:对象池与 Update 瘦身

跑酷游戏最吃性能的是不断生成销毁的障碍物和金币。工程里如果直接instantiate和destroy,GC 会在你跑出 500 米后准时卡顿。对象池是标配。

// 初始化对象池,prefab 是障碍物预制体 this.obstaclePool = new cc.NodePool('Obstacle'); for (let i = 0; i < 10; i++) { const node = cc.instantiate(this.obstaclePrefab); this.obstaclePool.put(node); // 预生成 10 个放池里 } // 取用 const obstacle = this.obstaclePool.get() || cc.instantiate(this.obstaclePrefab); obstacle.parent = this.node; obstacle.setPosition(cc.v2(800, -200)); // 回收,不要用 destroy this.obstaclePool.put(obstacle);

cc.NodePool的构造函数参数是池名称,用于调试时区分。put之前要重置节点的状态——位置、缩放、透明度、动画,否则复用的节点会带着上次的残影。我习惯在put前调node.stopAllActions()和node.getComponent(cc.Animation).stopAll()。

update里只做必须每帧做的事:输入检测、位置更新、碰撞检测。像「每 3 秒生成一个障碍」这种,用this.schedule(callback, 3)而不是在update里累加计时器。schedule是引擎调度器,精度比手写计时器高,而且能随节点暂停自动停。物理碰撞用cc.Collider的onCollisionEnter回调,别在update里手动算 AABB,那是 2015 年的写法。

4. 避坑与排查:动画不播、打包报错、性能骤降

4.1 动画播放了但角色不动

现象:调了anim.play('role_run'),控制台没报错,但角色停在第一帧。原因:动画剪辑的sample值太低或wrapMode设成了Normal且speed为 0。解决:在编辑器里选中.anim文件,检查sample是否 ≥ 24,speed是否 ≥ 1。代码里打印anim.getAnimationState('role_run').duration,如果是 0 说明剪辑没绑定 spriteFrame。

4.2 打包 APK 时报 NDK not found

现象:./gradlew assembleDebug报NDK not configured。原因:local.properties里没写ndk.dir,或者 NDK 版本与引擎要求不符。解决:在local.properties加ndk.dir=/path/to/ndk/r19c,路径不要有空格。如果还报错,去project.json看engine版本,2.4.x 对应 NDK r19c,2.3.x 对应 r18b,版本错一个都编不过。

4.3 对象池复用后节点位置错乱

现象:从池里取出的障碍物出现在屏幕外或重叠。原因:put时没重置节点位置和父节点,复用时带着旧坐标。解决:封装一个resetNode方法,在put前统一重置position、scale、opacity、angle,并调stopAllActions。我一般把重置逻辑写在put的调用处,而不是池内部,这样不同池可以有不同的重置策略。

4.4 真机上动画卡顿但编辑器流畅

现象:编辑器预览 60 帧,打包到手机掉到 20 帧。原因:真机 GPU 填充率低,大图集或半透明叠加导致 Overdraw。解决:用cc.director.setDisplayStats(true)打开真机性能面板,看 DrawCall 和渲染时间。把不透明图集和半透明图集分开,半透明元素尽量少叠。粒子特效数量砍半,每个粒子系统的maxParticles不超过 30。

4.5 状态切换时动画事件重复触发

现象:跳跃落地事件触发了两次,角色多跳了一下。原因:动画事件在stopAll和play之间被触发,或者事件帧被复制到了两个剪辑里。解决:在事件回调里加状态判断,if (this.currentState !== State.JUMP) return;。另外检查.anim文件里事件帧是否只在一个剪辑里存在,复制动画时容易把事件一起复制。

5. 进阶技巧:用动画事件驱动物理与音效

动画事件不只是用来切状态,它能做更细的同步。比如跑步动画的每一帧脚掌落地时,触发一个footstep事件,回调里播放脚步声并生成一个灰尘粒子。这样脚步声和画面完全同步,比用计时器循环播放自然得多。

// 在动画编辑器里给 role_run 的第 3、9、15 帧插入事件 'footstep' // 脚本里注册回调 this.anim.on('footstep', this.onFootstep, this); onFootstep() { // 随机音调,避免机械重复 const pitch = 0.9 + Math.random() * 0.2; cc.audioEngine.playEffect(this.footstepClip, false, 1, pitch); // 生成灰尘粒子,位置在角色脚底 const dust = this.dustPool.get(); dust.setPosition(this.node.x, this.node.y - 40); dust.getComponent(cc.Animation).play('dust'); // 0.5 秒后回收 this.scheduleOnce(() => this.dustPool.put(dust), 0.5); }

cc.audioEngine.playEffect的第四个参数是pitch,范围 0.5 到 2.0。跑酷脚步声用 0.9 到 1.1 的随机抖动,听感不腻。粒子回收用scheduleOnce而不是在粒子动画结束回调里做,因为粒子可能被提前stopAll,回调不触发就泄漏了。

另一个技巧是用动画的currentTime做精确判定。比如滑铲动画时长 0.6 秒,但角色碰撞体缩小只在第 0.1 到 0.5 秒之间生效。你可以在update里读anim.getAnimationState('role_slide').currentTime,动态改碰撞体大小。

update(dt) { if (this.currentState === State.SLIDE) { const t = this.anim.getAnimationState('role_slide').currentTime; const collider = this.node.getComponent(cc.BoxCollider); // 0.1 到 0.5 秒之间碰撞体高度减半 collider.size.height = (t > 0.1 && t < 0.5) ? 40 : 80; } }

currentTime是只读的,别试图赋值。碰撞体改完要调collider.apply()才生效,2.x 里BoxCollider的size是cc.Size对象,直接改height属性后引擎会在下一帧自动应用,但保险起见手动调一次。

从那以后我每次拿到跑酷源码,第一件事就是打开动画编辑器看事件帧分布,第二件事是把update里所有instantiate换成对象池。这两个习惯帮我省了至少三次通宵排查卡顿。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询