☰
Cocos Creator跑酷源码拆解:无限滚动、对象池与难度曲线
2026/10/30 2:49:20 网站建设 项目流程

简介:面向 Cocos Creator 初学者与独立开发者的 2D 跑酷实战工程,以熊猫跑酷玩法为原型,适合希望借助可运行案例理解横版跑酷核心逻辑、快速搭建自有 Demo 的人群,描述中注明 1.4 版本以上亲测可用。压缩包共 256 个文件,约 3.46MB,涵盖 22 个 prefab 场景与预制体、21 个 js 业务脚本、102 个 json 配置与图集数据、19 张 png 贴图、5 个 plist 与 5 个 map 关卡资源,以及跑、跳、二段跳、金币等 4 个 anim 动画文件和 1 段 mp4 演示视频,工程结构与资源分类相对完整。已有 3277 人学习下载,可作为入门跑酷类小游戏的参考范本。读者可从中梳理角色控制、动画状态切换、关卡数据组织与碰撞判定等实现思路,对照脚本理解跳跃与二段跳的触发条件,并借助场景文件还原工程目录,为二次开发与课程设计提供可复用的基础框架。

1. 跑酷游戏最容易被低估的地方

跑酷这个品类看起来最"没技术含量"——一直往右跑,按一下跳,滑铲躲一下,捡金币加分。但真把一份 demo 塞进真机跑满三分钟,帧率曲线、地块接缝、碰撞判定会轮流教你做人。这份 cocos creator 跑酷项目源码的价值不在于它实现了跑和跳,而在于把无限滚动、对象池、难度曲线这三件跑酷的脏活封装成了能直接复用的模块。适合两类人:一类是刚上手 cocos creator、想拿一个完整项目练手的新人;另一类是手里压着跑酷或酷跑需求、需要一份结构看得懂的工程做参照的熟手。下面按我实际拆包的顺序讲,从目录看到手感参数,最后落到五个最容易翻车的点。

2. 源码骨架:目录分层、场景层级与启动链路

拿到一份跑酷项目源码,先别急着点开脚本。目录怎么分层基本就能判断这份工程值不值得留:分层混乱的,后面改一个障碍物类型要动五个文件;分层清楚的,加一种新障碍只需要写一个脚本加一行配置。我一般先看assets/scripts下的划分方式,再看场景节点树的顺序,最后才顺着onLoad → start → update走一遍执行链路。

2.1 资源目录与脚本职责边界

一份能改得动的工程,assets下通常会切成这几块,判断标准只有一条:改一个玩法需求,需要同时打开几个目录。

目录放什么判断标准
scenesLoading、Main 等场景文件场景只负责挂节点,不塞业务逻辑
scripts/core事件总线、对象池、状态机、本地存档不依赖具体玩法,换个项目能整个搬走
scripts/gameplay玩家、地块、障碍、金币、道具只和跑酷玩法绑定
scripts/managers游戏总控、音频、UI 弹窗管理单例,跨场景常驻
prefabs地块、障碍、金币、飘字一个 prefab 对应一个主控脚本
configs难度表、障碍权重、关卡段配置策划改数值不用动代码
textures/audio图集、音效按合批需要提前合并

重点看core和gameplay有没有被混在一起。常见的新手写法是把对象池直接写在生成障碍的脚本里,结果地块、金币、飘字各写一套池子,每一套的回收时机还不一样,最后内存曲线像心电图。合格的拆分是:池子只管借还,不管业务;业务脚本只声明自己需要什么 prefab,池子的创建和回收交给上层。

另一个值得看的是配置表有没有独立出来。跑酷的难度全靠数值驱动,速度、障碍间距、障碍类型权重、金币出现概率,这些如果硬编码在update里,调一次手感就要重新编译一次,来回十几次人就麻了。我见过的做法是扔一个 JSON 到configs,启动时读进来挂在总控上,调试期直接热改文件重进场景。

2.2 场景节点树的层级怎么排

节点树的顺序在 cocos creator 里不只是"看着舒服",它直接决定渲染顺序和坐标换算成本。跑酷项目的节点树我一般排成这样:

Canvas ├─ GameRoot │ ├─ BackgroundLayer // 远景,视差跟随 │ ├─ GroundLayer // 地块,挂滚动脚本 │ ├─ EntityLayer │ │ ├─ Player │ │ ├─ ObstacleRoot │ │ └─ ItemRoot │ └─ FxLayer // 粒子、飘字 └─ UIRoot ├─ HudLayer // 分数、金币、暂停按钮 └─ PopupLayer // 结算、复活弹窗

这样排有三个实际好处。第一,EntityLayer下的玩家、障碍、金币共用同一个父节点坐标系,做 AABB 判定时不用来回convertToNodeSpace,少一层转换就少一类坐标 bug。第二,UI 独立挂在UIRoot下,弹窗加遮罩只影响 UI 层,不会误盖住游戏内容,也不会被地块的层级顺序打乱。第三,FxLayer放在实体之上、UI 之下,飘字和粒子不会盖住按钮,也不会被地块挡住。

有个细节容易被忽略:GroundLayer下挂的地块节点个数是固定的(比如 3 块),滚动靠改position.x,不靠增删节点。这一点在第 3 章会展开,但节点树阶段就要确认——如果看到地块是运行时instantiate再destroy的,基本可以预判真机上会有周期性卡顿。

2.3 从启动到跑起来的执行链路

跑酷的状态不多,但必须显式管理:待机、跑动中、暂停、结束。用布尔量拼出来的状态机在"暂停时按跳跃""结束后还能吃金币"这类边界上必然出问题,所以我一般直接上枚举。

import { _decorator, Component, input, Input, EventKeyboard, KeyCode, game, Game } from 'cc'; const { ccclass } = _decorator; export enum GameState { Ready = 0, // 待机,展示起跑提示 Running = 1, // 跑动中,接受输入、推进难度 Paused = 2, // 暂停,冻结一切位移 Over = 3, // 结束,只允许弹结算 } @ccclass('GameEntry') export class GameEntry extends Component { public static inst: GameEntry | null = null; private _state: GameState = GameState.Ready; get state() { return this._state; } onLoad() { GameEntry.inst = this; // 应用级生命周期:切后台必须暂停,否则回来第一帧 dt 会非常大 game.on(Game.EVENT_HIDE, this.onAppHide, this); game.on(Game.EVENT_SHOW, this.onAppShow, this); input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); } onDestroy() { game.off(Game.EVENT_HIDE, this.onAppHide, this); game.off(Game.EVENT_SHOW, this.onAppShow, this); input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); } private onAppHide() { if (this._state === GameState.Running) this.setState(GameState.Paused); } private onAppShow() { // 这里不自动恢复,交给玩家点继续,避免回来就被障碍撞死 } private onKeyDown(e: EventKeyboard) { if (this._state === GameState.Ready) { this.setState(GameState.Running); return; } if (this._state !== GameState.Running) return; if (e.keyCode === KeyCode.SPACE) { // 转发给玩家控制器,走事件而不是直接引用,方便以后加 AI this.node.emit('cmd-jump'); } } setState(next: GameState) { if (this._state === next) return; const prev = this._state; this._state = next; this.node.emit('game-state-change', prev, next); } }

逻辑上分三层:应用生命周期(EVENT_HIDE/EVENT_SHOW)负责系统级中断,输入层只做状态判定不做实际动作,状态变更通过节点事件广播出去,玩家控制器、难度控制器、UI 各自监听。这样加一个"复活后回到 Running"的功能,只需要在结算弹窗里调一次setState,不用去改玩家脚本。

参数上要注意GameState用数字枚举而不是字符串,是为了方便存本地和打日志;GameEntry.inst用静态引用是为了让 UI 之类的脚本能快速拿到状态,但业务逻辑尽量走事件,避免单例被到处依赖变成第二个全局变量。EVENT_HIDE里只切暂停、EVENT_SHOW里不自动恢复,是血泪经验——移动端切回来时玩家手指还没放上去,自动恢复等于送一条命。

3. 无限滚动:为什么是「地块回收 + 对象池」

跑酷的地图永远不会真的无限长,它只是一个视觉骗局:镜头附近永远有几块地砖,跑到后面的被挪到前面去。实现这个骗局有三条路,选错了后期很难改。

3.1 三种滚动路线的取舍

方案做法优点代价
移动相机玩家和地块都不动,相机往右平移实现最简单,几行代码坐标随游戏时长线性增长,跑十分钟后浮点精度开始抖,粒子和 UI 要单独处理坐标系
移动玩家玩家局部推进,地图跟随视野内玩家位置固定两套坐标,障碍生成、碰撞判定都要做换算,出错难查
地块回收地块整体左移,越界的挪到队尾世界坐标一直在小范围内,渲染和判定都不漂需要处理接缝和回收时机

跑酷项目里我一般选第三种。原因是跑酷的单局时长可以很长(有人能跑二十分钟),方案一的坐标漂移在中后段会表现为"角色抖动""碰撞偶尔不触发",而且这种问题是渐进的,测试阶段跑三十秒根本发现不了。方案二在需要多角色(比如带宠物、带追兵)时坐标换算会翻倍。第三种把世界坐标锁死在一个屏幕宽度的范围内,浮点永远精确。

3.2 地块拼接与滚动实现

地块滚动的核心只有两件事:每帧把所有地块往左推,最左边那块越界后挪到最右边。听起来简单,接缝和时机是两个坑。

import { _decorator, Component, Node } from 'cc'; const { ccclass, property } = _decorator; @ccclass('GroundScroller') export class GroundScroller extends Component { @property({ type: [Node], tooltip: '按从左到右的顺序拖入地块节点' }) tiles: Node[] = []; @property({ tooltip: '单块地块宽度,必须与美术切图和 UITransform 宽度一致' }) tileWidth: number = 750; private _speed = 0; // px/s,由难度控制器写入 private _maxDt = 1 / 30; // dt 上限,防止切后台回来瞬移 setSpeed(v: number) { this._speed = v; } update(dt: number) { if (this._speed <= 0 || this.tiles.length < 2) return; // 关键保护:dt 超过 1/30 就截断,宁可慢一帧也不要穿模 const step = this._speed * Math.min(dt, this._maxDt); for (let i = 0; i < this.tiles.length; i++) { const t = this.tiles[i]; const p = t.position; // 取整到 0.01,避免亚像素采样导致接缝闪烁 t.setPosition(Math.round((p.x - step) * 100) / 100, p.y, p.z); } this.recycleLeftMost(); } private recycleLeftMost() { let minIdx = 0; for (let i = 1; i < this.tiles.length; i++) { if (this.tiles[i].position.x < this.tiles[minIdx].position.x) minIdx = i; } const left = this.tiles[minIdx]; if (left.position.x > -this.tileWidth) return; let maxX = -Infinity; for (const t of this.tiles) { if (t !== left && t.position.x > maxX) maxX = t.position.x; } // 挪到最右块的右侧一整块宽度处,保证严丝合缝 left.setPosition(maxX + this.tileWidth, left.position.y, left.position.z); } }

逻辑说明:update里只做位移,回收逻辑单独抽成方法,方便以后换成"按距离触发"的版本。recycleLeftMost先找最左再找最右,而不是假设数组顺序,是因为回收几次之后数组顺序就乱了,依赖下标一定会错位。

参数上,tileWidth必须和三个地方对齐:美术切图的实际像素宽度、节点UITransform的contentSize.width、脚本里的这个值。三者差一个像素,跑两圈接缝就露白。_maxDt截断到 1/30 秒是必须的,移动端切后台、系统弹通知、来电都会让下一帧的dt变成几秒,不截断的话地块会直接跳过一整块,视觉上就是"地面闪了一下"。位置取整到 0.01 也是踩过的坑,浮点累加会让相邻地块出现 0.003 像素的缝,某些机型上表现为一条竖着的亮线。

3.3 对象池:借还分离,只隐藏不销毁

障碍和金币是高频增删的对象,instantiate加destroy在低端机上一定抖。池子本身不难,难在回收时把状态清干净。

import { instantiate, Node, Prefab } from 'cc'; export class SimplePool<T extends Node> { private _prefab: Prefab; private _parent: Node; private _free: T[] = []; constructor(prefab: Prefab, parent: Node, prewarm = 0) { this._prefab = prefab; this._parent = parent; // 预热:进场前就创建好,避免游戏中期集中创建造成掉帧 for (let i = 0; i < prewarm; i++) { this._free.push(this.createNew()); } } private createNew(): T { const n = instantiate(this._prefab) as T; n.active = false; this._parent.addChild(n); return n; } get(): T { const n = this._free.pop() ?? this.createNew(); n.active = true; // 交给业务侧重置状态,池子不猜业务 n.emit('pool-reuse'); return n; } put(n: T) { if (!n.isValid) return; n.active = false; this._free.push(n); } get freeCount() { return this._free.length; } }

逻辑说明:get里先emit('pool-reuse')再返回,是为了让障碍脚本自己重置位置、动画、碰撞标记,池子完全不关心业务字段。put里先判isValid,因为障碍有可能在回收前被别的逻辑销毁,直接塞进池子会在下次取出时报空引用。

参数上,prewarm一般按"单屏最大同时出现数量 × 2"给。障碍同屏最多 4 个、金币最多 20 个,那预热 8 和 40 就够了,预热太多会拖慢进场加载。还有个常被忽略的点:回收时如果节点上还挂着tween或者schedule,必须先停掉,否则下次取出时动画从一半开始播,或者定时器重复叠加,表现出来就是"金币旋转速度越来越快"。

4. 手感参数:跳跃曲线、速度与难度曲线怎么调

跑酷能不能留住人,八成看手感。手感不是玄学,它就是几个能算出来的数:跳跃高度、滞空时间、下落倍率、障碍反应窗口。这几个参数之间是有约束关系的,改一个必然影响另一个。

4.1 角色状态机与输入缓冲

玩家控制器至少要有四个状态:地面、上升、下落、滑铲。用_onGround一个布尔量硬扛,会在"土狼时间"和"二段跳"上翻车。

import { _decorator, Component } from 'cc'; const { ccclass, property } = _decorator; enum PlayerState { Ground, Rise, Fall, Slide } @ccclass('PlayerController') export class PlayerController extends Component { @property gravity = -2400; // px/s² @property jumpSpeed = 900; // px/s @property fallMultiplier = 1.4; // 下落时重力倍率 @property doubleJumpSpeed = 750; @property bufferTime = 0.1; // 输入缓冲窗口,秒 private _vY = 0; private _state = PlayerState.Ground; private _jumpsLeft = 2; private _bufferTimer = 0; onEnable() { this.node.on('cmd-jump', this.onJumpCmd, this); } onDisable() { this.node.off('cmd-jump', this.onJumpCmd, this); } private onJumpCmd() { // 不立即执行,先记时间戳,落地时补发 this._bufferTimer = this.bufferTime; } update(dt: number) { dt = Math.min(dt, 1 / 30); this._bufferTimer = Math.max(0, this._bufferTimer - dt); if (this._bufferTimer > 0 && this._jumpsLeft > 0) { this._bufferTimer = 0; this._jumpsLeft--; this._vY = this._jumpsLeft === 1 ? this.jumpSpeed : this.doubleJumpSpeed; this._state = PlayerState.Rise; } if (this._state !== PlayerState.Ground) { const g = this._vY > 0 ? this.gravity : this.gravity * this.fallMultiplier; this._vY += g * dt; const p = this.node.position; this.node.setPosition(p.x, p.y + this._vY * dt, p.z); if (this.node.position.y <= 0 && this._vY < 0) { this.node.setPosition(p.x, 0, p.z); this._vY = 0; this._jumpsLeft = 2; this._state = PlayerState.Ground; } else { this._state = this._vY > 0 ? PlayerState.Rise : PlayerState.Fall; } } } }

逻辑说明:输入不直接触发跳跃,而是写进_bufferTimer,update里统一消费。这样玩家在落地前 0.1 秒按下跳跃,落地瞬间会自动补跳,主观感受是"操作很跟手"。这一条在移动端尤其重要,触屏误触容错全靠它。

参数上,gravity和jumpSpeed决定曲线形状,fallMultiplier决定手感是"飘"还是"实"。重力取 -2400、初速取 900 时,最大高度约 168 像素(v² / 2g),滞空约 0.75 秒,能跨过一个约 300 像素宽的坑。doubleJumpSpeed要略低于首跳,否则二段跳会越跳越高,视觉上像在爬楼。

4.2 跳跃参数之间的约束关系

调手感之前先记住这张表,改一个参数时用它反推其他参数,比反复试快得多。

参数参考值影响反推公式
重力加速度-2400 px/s²越大越"脆",越小越"飘"—
起跳初速度900 px/s决定高度上限h = v² / (2·g)
最大跳跃高度≈168 px约角色身高的 1.6 倍h = v² / (2·g)
上升时间0.375 s决定跨越障碍的时机t = v / g
完整滞空0.75 s决定能跳过的最宽障碍t = 2v / g
下落重力倍率1.4上升慢、下落快,手感更实—
输入缓冲窗口0.08~0.12 s越大越跟手,太大会误跳—
土狼时间0.08~0.1 s刚离开地面仍可起跳—

实战里我会先定"滞空时间",因为它是玩法的硬约束:障碍最宽 300 像素、跑速 480 px/s,通过时间是 0.625 秒,滞空必须显著大于它,留出操作余量。定完滞空再反推初速和重力,最后用下落倍率微调主观感受。反过来先调重力的,通常要试几十次。

4.3 难度曲线:用时间算间距,不要用距离

难度提升最直觉的做法是"每隔一段时间把速度加一点",但只加速度会有一个隐患:障碍间距如果按固定距离走,速度翻倍后玩家反应时间直接减半,后半段变成纯记忆。正确做法是把"反应时间"作为设计参数,间距由速度反推。

import { _decorator, Component } from 'cc'; const { ccclass, property } = _decorator; @ccclass('DifficultyController') export class DifficultyController extends Component { @property baseSpeed = 420; // px/s,开局速度 @property maxSpeed = 900; // px/s,速度上限 @property accel = 12; // px/s²,每秒增加的速度 @property baseReact = 0.95; // s,开局给玩家的反应窗口 @property minReact = 0.55; // s,反应窗口下限,不能再低了 private _elapsed = 0; update(dt: number) { if (this._elapsed < 1e6) this._elapsed += Math.min(dt, 1 / 30); } get speed(): number { return Math.min(this.maxSpeed, this.baseSpeed + this.accel * this._elapsed); } // 下一个障碍该放在多远的位置 get nextGap(): number { const t = Math.max(this.minReact, this.baseReact - this._elapsed * 0.008); return this.speed * t; } }

逻辑说明:speed线性爬升到maxSpeed后封顶,防止后半段快到无法操作。nextGap用"反应时间 × 当前速度"算,速度越快间距越大,玩家的操作窗口反而被保住。反应时间从 0.95 秒缓慢压到 0.55 秒,靠的是系数 0.008,这个数决定了难度爬升的主观体感——0.02 太快,一百秒就到顶;0.004 太慢,玩家跑到三分钟还没感觉。

参数上,baseSpeed和maxSpeed的比值建议控制在 1:2 到 1:2.5 之间,超过 2.5 倍速度差视觉上会显得场景"飞起来",容易晕。minReact不要低于 0.5 秒,低于这个值玩家基本靠背板而不是反应。accel决定从开局到满速要多久,12 的意思是大约 40 秒到顶,一局三分钟里前四十秒是爬坡期。

5. 避坑排查:跑酷项目最容易翻车的五个点

下面五条都是我在真机上真实遇到的,现象、原因、解决按顺序写,遇到类似表现可以直接对照。

5.1 真机跑一两分钟后周期性掉帧

现象:编辑器里满帧,真机开局也顺,跑到一分半左右开始每隔几秒卡一下,越到后面越频繁。

原因:障碍和金币还在走instantiate/destroy,每次销毁都触发一次内存回收,同时新建节点的合批被打断。另一种情况是每帧在update里调getComponent,节点一多就是几千次查找。

解决:所有高频对象走对象池并预热,getComponent结果在onLoad里缓存成成员变量。检查方式很简单,在生成和销毁的地方各打一条日志,跑十秒看条数——超过一百条就说明池子没生效。

5.2 地块接缝出现一条竖亮线或闪黑

现象:地块之间偶尔出现一条 1 像素的缝,快跑时忽隐忽现,截图上特别明显。

原因:三种可能叠加。一是图集里相邻贴图没留 padding,采样时把隔壁像素采进来了;二是节点位置是浮点累加,两块地差了 0.003 像素;三是纹理过滤开了双线性,边缘透明像素被插值成半透明。

解决:图集打包时给每张图留 1~2 像素 padding;位置按 3.2 节的做法取整;地块纹理的 filter 设为 nearest 或关掉 mipmap。三条一起做,接缝基本消失。

5.3 高速时角色穿过障碍物

现象:低速完全正常,速度提上来之后偶尔"穿"过矮障碍,碰撞回调压根没触发。

原因:一帧的位移超过了碰撞体厚度。速度 900 px/s、帧率 30 时单帧位移 30 像素,如果障碍碰撞盒只有 20 像素厚,中间那一瞬间就漏检了。

解决:三个方向同时做——障碍碰撞体厚度不小于"最大速度 ÷ 最低帧率",900/30 = 30 像素,取 40 更保险;给跑速设硬上限;关键障碍用射线或分步检测,把一帧拆成 2~3 次小步长推进。最省事的是第一条,改配置不用改逻辑。

5.4 切后台回来角色直接飞出去

现象:游戏中接个电话再回来,角色瞬间位移到屏幕外,或者直接判定撞死。

原因:引擎在恢复时会给出一个很大的dt,update里如果直接拿它乘速度,位移就是几秒的量。同时物理系统在后台可能还在步进。

解决:所有update的第一行统一 clampdt;在EVENT_HIDE里切暂停并停掉物理步进,EVENT_SHOW里不自动恢复,等玩家点继续。这一条是后悔药,写进基类最省事。

5.5 分数和金币对不上,或者结算扣两次

现象:撞死瞬间金币数跳了两下,结算面板显示的金币比 HUD 上多一个。

原因:2D 碰撞的BEGIN_CONTACT回调在同一帧里可能因为多个碰撞体触发多次;另外结算逻辑同时挂在"碰撞回调"和"状态变更事件"上,两处都执行了一遍。

解决:给每个可收集物加一个_collected标记,回调进来先判标记;结算只保留一个入口,统一由setState(GameState.Over)触发,其他地方只改状态不直接调结算方法。

6. 进阶:把难度和障碍做成可配置段

手感调到能玩之后,下一步是把写死的数值搬出去。我一般按"时间段"切配置,每个段给一组速度、反应窗口和障碍权重,段与段之间做插值,改难度只需要动 JSON。

{ "segments": [ { "endTime": 30, "speed": 420, "react": 0.95, "weights": { "low": 6, "high": 2, "double": 0, "gap": 2 } }, { "endTime": 70, "speed": 560, "react": 0.85, "weights": { "low": 5, "high": 4, "double": 1, "gap": 1 } }, { "endTime": 120, "speed": 720, "react": 0.72, "weights": { "low": 4, "high": 5, "double": 3, "gap": 0 } }, { "endTime": 999, "speed": 900, "react": 0.58, "weights": { "low": 3, "high": 6, "double": 4, "gap": 0 } } ], "rules": { "maxDoubleInRow": 2, "minGapAfterDouble": 1.2 } }

权重表解决的是"障碍类型分布",rules解决的是"组合合法性"。举个实际例子:如果连续摇出三个高障碍,玩家必死无疑,所以要有maxDoubleInRow这类保底规则。写法是在生成器里维护一个计数器,摇到高难度障碍就自增,超过上限强制插一个矮障碍;然后minGapAfterDouble保证组合障碍之后至少留一段安全距离。

验证这套配置有没有调对,靠肉眼看是不准的。我会在 HUD 上挂一个调试条,实时显示当前速度、当前反应窗口、下一次生成的间距、当前段索引,然后开一局跑满三分钟,盯三件事:速度曲线有没有在预期时间点拐弯、反应窗口有没有掉到下限、障碍分布里高难度占比是不是和权重一致。三条都对得上,配置才算生效。

还有个更省事的办法:写一个不渲染的模拟器,把难度控制器单独跑一千次,输出每秒的间距和障碍类型分布,直接在控制台看数据。这个脚本我一般留在工程里不删,后面每次调数值都跑一遍,比进游戏试快十倍。

从那以后我每次拿到一份新的跑酷工程,都会先把update里的dt有没有 clamp、对象池有没有预热、障碍间距是按距离还是按时间算这三件事查一遍,查完才敢谈"手感"。希望这份拆解能帮到你,少走几段弯路。

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

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

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

立即咨询