尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cocos Creator跑酷源码实战:动画状态机与动作系统拆解

Cocos Creator跑酷源码实战:动画状态机与动作系统拆解 简介本资源是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.apklocal.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和destroyGC 会在你跑出 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是否 ≥ 24speed是否 ≥ 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 r19c2.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换成对象池。这两个习惯帮我省了至少三次通宵排查卡顿。希望帮到你。本文还有配套的精品资源点击获取
返回列表