
简介这份Cocos Creator熊猫跑酷项目源码面向希望学习2D跑酷游戏开发的初学者与进阶者基于1.4以上版本制作亲测可运行可作为课程设计、原型验证与二次开发的参考案例。压缩包共256个文件约3.46MB主要包含json配置、prefab预制体、js脚本、png图像、plist图集、map地图、anim动画以及fire场景等类型分别对应关卡与资源数据、场景对象组装、角色控制逻辑、界面素材、动画状态和地图配置项目结构较为完整。已有3277人学习下载说明该跑酷案例具备一定参考价值。读者可以从中理解熊猫角色的移动、跳跃与二段跳控制动画切换与预制体复用方式以及场景搭建、关卡资源配置和资源目录组织思路便于在现有工程上修改参数、替换素材并快速搭建自己的跑酷玩法框架。1. 从一份 cocos creator 跑酷项目源码说起无限跑酷真正的难点在哪很多人第一次拆 cocos creator 跑酷项目源码注意力全在“角色怎么往前跑”上做出来看着像那么回事真上手玩三十秒就露怯车道切换发飘、障碍物隔三差五穿模、跑了两千米难度还停在原地。酷跑这类玩法的门槛从来不在“让物体动起来”而在节奏——什么时候给障碍、给多密、给完之后玩家还剩多少反应时间。这份跑酷源码面向的正是这个环节把一局游戏拆成角色控制、赛道回收、障碍生成、碰撞判定、分数结算五个可以单独调参、单独验证的部分。做游戏方向课程设计或者毕设的人可以拿它当骨架改想快速搭一个可玩 Demo 验证手感的独立开发者也适用当成能直接动手改的起点而不是只能围观的样品。2. 需求与机制拆解把“跑酷”拆成五条可交付的机制2.1 一局游戏的核心循环六种状态和三条时间线跑酷看着是“一直往前跑”实际上一局游戏由状态机驱动六个状态缺一不可待机等待、倒计时、奔跑中、死亡判定、结算、再来一局。状态机不难写难的是别让状态互相打架——比如死亡动画播到一半玩家点了暂停、结算面板弹出时角色还在往前挪、倒计时没结束就按到了跳跃键。合格的做法是给状态机设一个current所有输入先过状态门禁门禁不通过直接 return别指望按钮的 interactable 属性去兜底。三条时间线也要提前分开。第一条是物理时间也就是update(dt)里累积的那条负责位移和碰撞第二条是生成时间按“距离”而不是“秒”推进跑得越快障碍越密才合理第三条是表现时间粒子、屏幕抖动、音效这些允许晚一两帧可以用 tween 走独立节奏。血泪经验是三条线混在同一个 update 里写改一个参数就能把另外两条带崩调试时连锅都找不到。2.2 五条机制的需求清单与验收口径需求不写验收口径等于没写。下面这张表是我一般会跟人对齐的版本左边是机制右边是“怎么算做完了”做课设时直接抄这张表放进需求分析章节比写一堆“实现流畅的操作体验”有用得多。机制关键需求验收口径角色控制三车道切换、跳跃、下滑操作延迟低于一帧连按三次换道不丢输入单次切换耗时 0.1~0.15 秒赛道回收无限延伸接缝不可见节点数恒定跑 10 分钟内存节点数量不增长障碍生成随距离提升密度但保底留出反应距离任意难度下相邻障碍的最小间隔 ≥ 反应时间 × 当前速度碰撞判定撞障碍即死吃金币加分判定不穿模最高速度下连续 100 次撞击 100 次触发结算存档最高分、累计金币落本地异常不白屏手动写坏存档字符串游戏仍能进主菜单反应距离这一项最容易被忽略。假设当前速度是 18 米/秒人的平均反应时间按 0.25 秒算那么两个障碍之间至少要留 4.5 米才谈得上“有机会躲”。很多抄来的源码跑久了必死原因就在密度上去了但保底距离没跟着算。2.3 技术选型Cocos Creator TypeScript 的取舍选 Cocos Creator 做跑酷核心理由是它的 2D/3D 混合渲染和预制体工作流对这类“大量重复物件”的玩法友好。赛道段、障碍物、金币全是同一个预制体反复实例化编辑期在场景里摆好一个标准段运行期只做位置回收美术改图不用程序跟着改代码。脚本语言选 TypeScript 而不是 JavaScript主要是为了接口约束。跑酷里跨模块传递的数据结构就那么几个GameState、RunResult、SaveData用 interface 定死字段编译器在开发阶段就能拦住“结算面板读了个不存在的字段”这类错误。下面这个接口定义在项目里通常放在scripts/core/Types.ts全项目共享// Types.ts —— 全局数据结构所有模块共用改字段必须同步改调用方 export interface RunResult { distance: number; // 本局跑动距离单位米 coins: number; // 本局吃到金币数 score: number; // 最终得分 距离 金币 × 10 isNewBest: boolean; // 是否刷新最高分 } export interface SaveData { version: number; // 存档版本号改结构必须 1 bestScore: number; totalCoins: number; unlockedSkins: string[]; }工程约束还有两条值得提前定一是所有节点位移统一用米作单位不要有人用像素有人用米混起来难度曲线就没法算二是所有随机都用同一个带种子的伪随机数生成器出问题能复现。用Math.random()打散障碍位置玩家反馈“某一局特别难”时你连复现都做不到这就是典型的黑匣子。3. 总体架构设计三层结构与目录组织3.1 表现层、逻辑层、数据层的职责边界跑酷项目小但边界不划清照样乱。我一般按三层切表现层管节点、动画、粒子、音效逻辑层管状态机、位移计算、生成规则、判定数据层管存档、配置表、全局运行时数据。判断一个模块该放哪层用一句话把这一层整个换掉游戏逻辑还成立吗换掉渲染照样能跑局的是表现层换掉存档只影响记录不影响跑动的是数据层。实践中最大的诱惑是“顺手在表现层改逻辑”——比如在角色动画结束回调里直接判定死亡。这种写法单机看着没问题一旦要做回放、要做排行榜校验、要做成关卡编辑器全都得推倒重来。我的做法是表现层只允许监听事件、不允许发号施令死亡判定只在逻辑层做表现层收到player-die事件后播动画。3.2 目录与预制体组织让美术和程序各改各的目录结构决定了后期改需求时的痛苦程度。跑酷项目的目录我会这么切assets/ ├── scripts/ │ ├── core/ # EventBus、GameState、Types │ ├── player/ # 角色控制、动画状态 │ ├── track/ # 赛道分段、对象池、生成器 │ ├── gameplay/ # 碰撞、计分、难度曲线 │ └── ui/ # 主菜单、HUD、结算面板 ├── prefabs/ │ ├── track/ # 赛道段预制体直道、弯道、跳台 │ ├── obstacle/ # 障碍物预制体 │ └── ui/ # 各面板预制体 └── configs/ # 难度曲线、障碍权重等可调参数预制体组织有个细节赛道段和障碍物必须分目录并且障碍物不要挂在赛道段预制体下面做子节点。原因很实在——赛道段是被回收复用的如果障碍是子节点回收时要么连带销毁下次还得重新实例化要么残留状态。障碍由独立的生成器实例化到场景根节点下赛道段只负责地面和背景两者互不干扰回收逻辑就干净了。3.3 事件总线与主循环调度把 update 收拢到一个地方Cocos Creator 里每个 Component 都有update十个组件十个 update帧率一掉你根本不知道是谁在拖。我的习惯是全项目只保留三个 update角色控制器一个、赛道管理器一个、生成器一个其余模块靠事件驱动。事件总线写起来很简单关键是不能让它变成藏业务逻辑的地方// EventBus.ts —— 只做转发不存任何业务状态 type Handler (...args: any[]) void; class EventBus { private map new Mapstring, Handler[](); on(evt: string, fn: Handler) { const list this.map.get(evt) ?? []; list.push(fn); this.map.set(evt, list); } off(evt: string, fn: Handler) { const list this.map.get(evt); if (!list) return; const i list.indexOf(fn); if (i 0) list.splice(i, 1); // 必须精确移除不能清空整个事件 } emit(evt: string, ...args: any[]) { this.map.get(evt)?.slice().forEach(fn fn(...args)); // slice 防止回调里增删导致跳项 } } export const bus new EventBus();slice()那一行是踩过坑才写的。早期版本直接遍历原数组结果某个回调里调了off数组长度变了后面的监听器被静默跳过表现就是“偶尔少触发一次结算”查了一个下午。事件名建议统一用连字符小写比如run-start、coin-pick、player-die、run-end别一会儿驼峰一会儿下划线。事件参数只传必要数据别把整个节点传出去——传节点等于把表现层的引用漏进逻辑层回头做对象池回收时循环引用会让你内存一直涨。4. 关键模块实现角色、赛道、障碍与结算4.1 三车道角色控制器位移、跳跃与下滑跑酷角色的手感八成来自横向切换的插值方式。用tween做匀速位移会显得僵硬用lerp每帧逼近目标点又会拖尾。实际手感最好的是固定时长插值按下瞬间确定目标 X 坐标和耗时之后每帧按已用时间 / 总时长算比例比例到 1 就停。这样无论帧率怎么变切换耗时都是恒定的 0.12 秒。// PlayerController.ts —— 三车道切换 跳跃 import { _decorator, Component, Vec3, input, Input, EventKeyboard, KeyCode } from cc; const { ccclass, property } _decorator; ccclass(PlayerController) export class PlayerController extends Component { property({ tooltip: 相邻车道中心间距米取赛道总宽的三分之一 }) laneWidth: number 2.4; property({ tooltip: 单次换道耗时秒0.1~0.15 手感最好 }) switchTime: number 0.12; property({ tooltip: 跳跃初速度与重力配合决定滞空时间 }) jumpSpeed: number 12; private lane 1; // 0 左 / 1 中 / 2 右 private fromX 0; private toX 0; private switching false; private elapsed 0; onEnable() { input.on(Input.EventType.KEY_DOWN, this.onKey, this); } onDisable() { input.off(Input.EventType.KEY_DOWN, this.onKey, this); // 成对注销少这一行就是输入重复 } private onKey(e: EventKeyboard) { if (e.keyCode KeyCode.KEY_A || e.keyCode KeyCode.ARROW_LEFT) this.moveLane(-1); if (e.keyCode KeyCode.KEY_D || e.keyCode KeyCode.ARROW_RIGHT) this.moveLane(1); } private moveLane(dir: number) { const next Math.min(2, Math.max(0, this.lane dir)); if (next this.lane) return; // 已经在最边上不做无效位移 this.lane next; this.fromX this.node.position.x; this.toX (next - 1) * this.laneWidth; this.elapsed 0; this.switching true; } update(dt: number) { if (!this.switching) return; this.elapsed dt; const t Math.min(this.elapsed / this.switchTime, 1); const x this.fromX (this.toX - this.fromX) * t; this.node.setPosition(x, this.node.position.y, this.node.position.z); if (t 1) this.switching false; } }三个参数各有讲究。laneWidth必须和赛道段的宽度对齐否则角色会站在边缘外switchTime低于 0.08 秒会显得瞬移、高于 0.2 秒会显得笨重jumpSpeed要跟场景重力一起调重力在项目设置里改一次所有跳跃手感全变别在代码里写死 -9.8。跳跃期间的换道我一般直接禁用落地再允许否则玩家能在空中横向穿过障碍物这在验收时会被一眼看出来。4.2 无限赛道分段预制体与对象池无限赛道的本质是“看起来无限”节点数量必须恒定。做法是准备若干段 30 米长的赛道预制体玩家前进时把跑过头的段移到最前面形成循环。坑点在于接缝如果段长和美术出图长度差了几厘米接缝处的花纹会闪一下跑起来像画面在抽搐。// TrackManager.ts —— 赛道分段循环 import { _decorator, Component, Node, Prefab, NodePool, instantiate } from cc; const { ccclass, property } _decorator; ccclass(TrackManager) export class TrackManager extends Component { property(Prefab) segmentPrefab: Prefab null!; property(Node) player: Node null!; property({ tooltip: 同时在场的段数5~7 段即可太多只是浪费 }) segmentCount: number 6; property({ tooltip: 单段长度米必须与美术出图长度完全一致 }) segmentLength: number 30; private segments: Node[] []; start() { for (let i 0; i this.segmentCount; i) { const seg instantiate(this.segmentPrefab); seg.setPosition(0, 0, this.segmentLength * (2 - i)); // 角色身后留 2 段缓冲 this.node.addChild(seg); this.segments.push(seg); } } update() { const headZ this.player.position.z; for (const seg of this.segments) { // 段整体被玩家甩在身后一段距离就搬到队尾 if (seg.position.z - this.segmentLength * 1.5 headZ) { const minZ Math.min(...this.segments.map(s s.position.z)); seg.setPosition(0, 0, minZ - this.segmentLength); } } } }这里的核心是“谁最落后就把谁搬到最后面”而不是每段各自算目标位置。用后者写帧率一抖动就会出现两段重叠或者一段之间露空白。另外回收搬运只改位置、不销毁不重建段上的装饰物路灯、栏杆都跟着走省掉大量实例化开销。障碍物和金币则用NodePool管理取出来设位置、用完put回去。对象池的关键是回收时必须重置状态这一点放在第 5 章细说。4.3 障碍生成与难度曲线让前 30 秒留住人难度曲线是跑酷里最玄学的部分。用线性增长前两百米太无聊后一千米根本过不去用指数增长第一分钟就把人劝退。我一般用平方根做缓入前期涨得快给出新鲜感后期逐渐放缓避免劝退。// Spawner.ts —— 按距离决定生成间隔 private getSpawnGap(distance: number): number { const level Math.min(Math.sqrt(distance / 100), 5); // 难度档位封顶 5 const gap 16 - level * 1.6; // 间隔从 16 米压到 8 米 return Math.max(gap, 8); // 8 米是保底反应距离 }Math.sqrt(distance / 100)里的 100 是“多少米开始明显变难”按赛道长度调这个数就行1.6是每档压缩的米数调大更难Math.max(gap, 8)这一行是不许省的安全阀速度越快越要靠它保底。速度也走同一条曲线通常从 12 米/秒涨到 24 米/秒封顶最高速度配合 8 米间隔正好卡在人类反应极限附近。生成器还要处理“同时刷出同一车道三个障碍”这种必死局。做法是每次生成前记录上一次占用的车道位掩码左右侧至少留一条空道三连障碍只允许出现在难度较高的档位并且中间必须夹一个金币作为引导。4.4 碰撞判定从 BoxCollider 到 Z 轴区间跑酷的碰撞不需要物理引擎完整介入全是轴向的X 轴比车道Z 轴比前后Y 轴比高度。用 Cocos Creator 的BoxCollider加分组掩码足够关键是分组要提前规划好不然改一次要动所有预制体。分组定义项目设置 → 物理 → 碰撞矩阵 1 Player 玩家 2 Obstacle 障碍物低障碍、高障碍、全阻挡 4 Coin 金币 8 Trigger 触发器加速带、传送点 矩阵勾选Player × Obstacle、Player × Coin、Player × Trigger其余全部不勾掩码之外还有两点一是障碍物的碰撞体要略厚于模型厚度取当前速度下“一帧位移”的 1.5 倍速度 24 米/秒、60 帧下每帧 0.4 米碰撞体 z 轴厚度就得给到 0.6 米以上否则高速时会从障碍里穿过去二是判断死因时不能只看碰撞事件还要比对当前位置防止角色从障碍下方钻过去也被判死。这两条是防护性设计不做的话上线第一天就会被玩家截图挂出来。4.5 分数结算与本地存档一局跑完要留下什么分数公式简单但不能随手写。距离取整加分、金币每个 10 分这是玩家能口算的规则别搞复杂的连击倍率结算面板解释不清玩家就不认。结算时一次性算出RunResult然后写存档。// Save.ts —— 带版本号的本地存档 import { sys } from cc; import { SaveData } from ../core/Types; const KEY runner_save_v1; export function loadSave(): SaveData { const fallback: SaveData { version: 1, bestScore: 0, totalCoins: 0, unlockedSkins: [default] }; try { const raw sys.localStorage.getItem(KEY); if (!raw) return fallback; const data JSON.parse(raw) as SaveData; if (data.version ! 1) return fallback; // 版本不匹配走兜底不强行读 return { ...fallback, ...data }; // 用兜底填缺字段老档缺项也能用 } catch { return fallback; // 存档损坏绝不能让游戏白屏 } } export function saveGame(data: SaveData) { try { sys.localStorage.setItem(KEY, JSON.stringify(data)); } catch { // 存储空间满或隐私模式直接吞掉不影响本局继续 } }{ ...fallback, ...data }这一行的作用是向前兼容将来加了新字段老玩家的存档里没有补齐默认值就能直接跑。version ! 1时返回兜底而不是抛错是因为跑酷这种轻量游戏丢一份进度远比开不了游戏好接受。写档失败也必须吞掉异常本地存储在某些环境下会直接抛错不 try 住就是主菜单白屏。5. 常见问题与避坑五个最容易翻车的点5.1 高速穿模明明撞上了却没判死现象速度低于 15 米/秒时一切正常跑过一千米提速之后玩家偶尔能从障碍物中间穿过去血条不掉。原因位移写在update里按dt累加碰撞检测是离散的两帧之间角色已经跨过了障碍物整个厚度。帧率越低、速度越快跨越距离越大穿模概率越高。解决三件事一起做。把障碍物碰撞体的 Z 轴厚度加到“当前最大速度 × 单帧时间 × 1.5”给角色挂一个细长的BoxCollider做前后两端各补一个判定点最高速档位下把物理步长固定为 1/60 而不是跟随帧率。我一般会写个自动化用例以最高速直冲一个障碍重复 100 次必须 100 次触发死亡一次不中就是没过。5.2 对象池复用后的“幽灵状态”现象金币吃到之后回收再刷出来的金币不发光、不旋转或者障碍物回收后带着上一局的破碎特效出场。原因pool.put(node)只负责把节点挂回池子不负责重置节点上任何组件状态。动画停在哪一帧、材质球换了哪个、Tween 是不是还在跑全都原样保留。解决给所有可池化对象定一个统一的reset()约定回收前必须调用。// 回收前统一重置缺一样就会出幽灵表现 function recycle(node: Node, pool: NodePool) { const coin node.getComponent(Coin); coin?.reset(); // 停 Tween、恢复缩放、重设可见性 node.active true; pool.put(node); }reset()里至少要做停止所有 Tween、恢复初始缩放和旋转、把碰撞体重新启用、清空自定义状态字段。这条规矩建议写成团队约定新加的池化类型不实现reset()就不允许进池。5.3 输入重复注册一次点击换两条道现象在结算面板点“再来一局”进入游戏后按一次方向键角色直接横跨两个车道退出重进次数越多一次按键跨的车道越多。原因监听写在start()或构造函数里面板反复打开关闭监听器越挂越多。每次按键被 N 个监听器响应每个都改一次车道索引。解决所有input.on必须和input.off成对出现在onEnable/onDisable里绝不放start。事件总线同理UI 面板在onDestroy里要把自己注册的事件全部注销。调试阶段可以在emit里打印监听器数量某个事件超过 5 个监听基本就是漏了注销。5.4 难度按帧计数低端机和高刷屏跑出两种游戏现象同一份代码在测试机上跑两千米难度还很温和换台高刷设备五百米就开始密集轰炸低端机上反而永远很简单。原因难度用帧数或者update调用次数推进而update的调用频率跟设备刷新率绑定60 帧设备一秒调 60 次120 帧设备一秒调 120 次同一段时间内累积的难度值差一倍。解决所有与时间相关的累积一律用dt所有与进度相关的推进一律用距离。上面生成器里的getSpawnGap(distance)就是按距离走天然免疫帧率差异。另外记得给dt加个上限比如Math.min(dt, 1/30)切后台再切回来时那一帧的dt可能是好几秒不夹住的话角色会瞬移穿墙。5.5 存档没有版本号改个字段就白屏现象加了新功能、给存档加了个unlockedSkins字段已经玩过的测试机上游戏直接卡在加载页日志里是一句 JSON 解析后的undefined报错。原因读档时直接JSON.parse然后当新结构用老档里没有新字段后续代码访问data.unlockedSkins.length就炸了。解决存档结构里永远带version读档三步走——解析、比版本、合并默认值。合并用扩展运算符让默认值兜底比版本不一致就走迁移函数或者直接返回初始档。这个后悔药成本极低加一行version: 1的事不加就要面对删档重来。6. 验收与答辩怎么证明这套源码真的能跑验收不是“能进游戏就算完”得有一份能逐条勾的清单。我一般会给这样的表左边是检查项右边是判定方法答辩前自己走一遍被打断提问也不慌。检查项判定方法合格标准帧率稳定性开启性能面板跑满 3 分钟稳定 55 帧以上无长卡顿节点数量打印场景节点总数跑前跑后对比10 分钟内数量不增长高速碰撞最高速度直冲障碍 100 次100 次全部判死难度一致性60 帧与 120 帧设备各跑一次同距离下障碍密度差异 5%存档容错手动把存档改成非法字符串能进主菜单最高分归零输入幂等反复进出结算面板 10 次后按键一次按键只切换一个车道最后一行的“输入幂等”是最容易被问到的。答辩时评委经常会说“你退出重进试试”如果监听的注册注销没做干净现场就会很尴尬。提前跑一遍这个用例比背十页 PPT 有用。再补一个冒烟脚本答辩前跑一次五分钟就能把主要路径覆盖掉# smoke-test.sh —— 启动前静态检查防止低级错误上台 # 1. 检查是否有未注销的 input.on grep -rn input.on assets/scripts | grep -v onEnable | grep -v onLoad # 2. 检查存档是否都带了版本字段 grep -rn JSON.parse assets/scripts # 3. 检查是否有裸 Math.random 用在生成器里应该全部走种子随机 grep -rn Math.random assets/scripts/gameplay三条命令分别对应前面章节提过的三个坑输入泄漏、存档结构、随机不可复现。输出为空说明没有裸用有条目就人工确认一遍。这个脚本不复杂但每次上线前跑一遍能挡掉八成“演示时好好的、拉出去就出问题”的情况。答辩讲的时候别从代码开始讲从一局游戏开始讲给评委跑三十秒说明这三十分钟里发生了什么——角色切换怎么算、障碍按什么节奏出、撞上之后判定走哪条路径、分数怎么落地。讲完再看代码评委的关注点会自然落在难度曲线和碰撞判定这两个有讨论价值的地方而不是“你这行代码为什么这么写”。从那以后我每次交这类项目都强制走一遍“最高速冲撞 反复进出面板 手写坏存档”这三件事跑不过就不提交哪怕其它功能再漂亮。希望帮到你。本文还有配套的精品资源点击获取