Cocos Creator 跑酷小游戏源码:完整循环与二次开发拆解
简介:面向 Cocos Creator 初学者与独立开发者的熊猫跑酷项目源码,适用于 1.4 及以上版本,可用于学习横版跑酷玩法、角色运动控制与工程目录组织,也适合课程设计、小型休闲游戏原型开发与二次功能扩展。压缩包共 256 个文件、约 3.46MB,包含 JSON 配置、预制体、JS 脚本、PNG 贴图、动画与图集等资源,覆盖角色控制、跳跃、二段跳、动画切换和场景构建等模块,便于按模块检索、复用并保留清晰目录层级。已有 3277 人学习下载,可作为入门参考。读者能据此梳理项目目录结构、组件挂载与脚本协作关系,对照动画状态和预制体配置理解跑酷逻辑,并在源码基础上替换美术资源、调整关卡参数与角色手感,快速形成可运行原型,同时排查资源引用、场景加载等相关常见问题。
1. 跑酷小游戏的门槛不在画面:一个能跑通完整循环的工程长什么样
很多人找 cocos creator 跑酷项目源码,是想直接拿一个“能跑”的 Demo 改成自己的小游戏。结果下载下来跑了一遍发现:角色确实在往前跑,金币确实能吃,但想加个道具、改个难度曲线,一动手就全乱了。问题不在代码写得差,而在于跑酷这种玩法看着简单,核心循环却把对象池、无限地图拼接、帧率无关移动、状态机、碰撞分层这几件事全塞进了同一个update里,牵一发动全身。
这份源码是一个基于 Cocos Creator 3.x + TypeScript 的完整跑酷工程:三车道左右切换、跳跃与下滑、无限赛道拼接、金币与分数结算、最高分本地存档、HUD 与音效管理,一局从开始到结束的链路是闭合的。它适合两类人:刚学完引擎基础、想找一个“麻雀虽小五脏俱全”的工程照着读的开发者;以及手里已经有美术资源,想快速搭一个酷跑类玩法原型的小团队。
2. 工程骨架拆解:assets 目录到脚本分层的完整映射
拿到一个工程,我不会先点运行,而是先把assets目录展开看一遍,因为目录结构基本能反映作者有没有把“玩法”和“设施”分开。这份源码分得比较干净,脚本层是四块:core放公共设施,gameplay放跑酷本体,ui放界面,data放配置和存档。这个划分不是强迫症,它直接决定了后面改玩法时你要动几个文件。
2.1 目录结构与资源组织
展开之后大致是这样:
assets/ ├── scenes/ │ ├── Main.scene # 主菜单,只挂 UI 和入口脚本 │ └── Game.scene # 对局场景,跑酷本体的宿主 ├── scripts/ │ ├── core/ # 与玩法无关的公共设施 │ │ ├── GameManager.ts │ │ ├── EventBus.ts │ │ ├── NodePoolManager.ts │ │ └── GameConfig.ts │ ├── gameplay/ # 跑酷玩法本体 │ │ ├── PlayerController.ts │ │ ├── TrackManager.ts │ │ ├── ObstacleSpawner.ts │ │ ├── Coin.ts │ │ └── CollisionChecker.ts │ ├── ui/ │ │ ├── HUD.ts │ │ └── ResultPanel.ts │ └── data/ │ ├── SaveManager.ts │ └── ScoreModel.ts ├── prefabs/ │ ├── segments/ # 赛道段,长度必须统一 │ ├── obstacles/ # 障碍物 │ └── pickups/ # 金币与道具 ├── textures/ # 合过图集的贴图 ├── audio/ └── animations/scenes只有两张:主菜单和对局。有的工程会把 Loading 单独拆一张,这里没必要,因为资源量不大、用不到 Asset Bundle 分包,多一张场景反而多一次切换开销。prefabs/segments是整份工程里最需要守规矩的地方,所有赛道段预制体的长度必须完全一致(这里是 30 个单位),因为TrackManager是按固定长度去算回收位置和下一段坐标的,只要有一段长度不一致,地图迟早会裂开。
textures里的图集是提前合好的,跑酷场景同屏节点不少,金币、障碍、路面碎块如果不合图,drawcall 会飙到几十上百,中低端机直接掉帧。animations放的是角色跑动和下滑的帧动画,用 Animation 组件播,没用骨骼,这也是小体量跑酷项目的常见取舍:帧动画便宜、可控,代价是动作不够细腻。
2.2 技术栈与选型理由
工程用的是 Cocos Creator 3.x 的 3D 渲染管线,但美术资源是 2D 精灵,角色和障碍物挂在 3D 空间里,相机固定角度俯视推进,视觉上就是标准的酷跑视角。这属于典型的伪 3D 做法:位置计算走Vec3,渲染走 Sprite 或 Billboard,好处是三车道、跳跃高度这些逻辑天然是三维向量,写起来比纯 2D 的 y 轴偏移直观得多。
脚本层全部是 TypeScript,开了严格模式。道具、金币、障碍物都是组件化的:一枚金币就是一个挂了Coin组件的节点,不继承任何基类,靠NodePoolManager统一管理生命周期。配置不写死在组件属性面板里,而是集中在GameConfig.ts,这一点在二次开发时非常关键,因为跑酷的数值调整极其频繁,如果速度、跳跃高度、车道宽度散落在五六个预制体上,调一轮数值要点开十几个面板。
存档用sys.localStorage,只存最高分和金币总数两项,没有引入数据库或云存储。音效用AudioSource加playOneShot,背景音乐单独一个常驻AudioSource,音效走一次性播放,避免多个音效实例互相打断。
2.3 脚本分层与事件解耦
core里最关键的不是GameManager,而是EventBus。跑酷项目里 UI 需要知道分数、速度、游戏结束三种状态,如果每个 UI 脚本都去find('GameManager').getComponent(GameManager),那耦合就锁死了,想换个结算面板都得改 gameplay。这里用一个极简的事件总线把两边隔开:
// assets/scripts/core/EventBus.ts type Handler = (...args: any[]) => void; export enum GameEvent { SCORE_CHANGED = 'score_changed', // 分数变化,参数:当前分数 SPEED_CHANGED = 'speed_changed', // 速度变化,参数:当前速度 COIN_PICKED = 'coin_picked', // 吃到金币,参数:金币面值 GAME_OVER = 'game_over', // 游戏结束,参数:结算分数 } class EventBusImpl { private _map = new Map<string, Handler[]>(); on(evt: string, cb: Handler) { const list = this._map.get(evt); if (list) list.push(cb); else this._map.set(evt, [cb]); } off(evt: string, cb: Handler) { const list = this._map.get(evt); if (!list) return; const idx = list.indexOf(cb); if (idx >= 0) list.splice(idx, 1); // 必须按引用精确移除,否则会残留 } emit(evt: string, ...args: any[]) { const list = this._map.get(evt); if (!list) return; // 拷贝一份再遍历,防止回调里 off 导致的遍历错位 for (const cb of list.slice()) cb(...args); } } export const EventBus = new EventBusImpl();这里有两个细节值得说。第一,off必须按函数引用精确移除,不能按事件名整个清空,否则同一个事件有多个监听者时会误伤。第二,emit里对数组做了slice()拷贝,因为实际项目里经常出现“收到 GAME_OVER 后立刻 off 自己”的写法,直接在原数组上遍历会漏掉后面的监听者。
事件注册约定在onEnable里订阅、onDisable里退订,而不是start和onDestroy。因为跑酷会在游戏结束和重开之间反复切换场景或重置节点池,如果只在onDestroy退订,节点被复用时会出现同一份回调被注册两次,分数直接翻倍,这种 bug 查起来很费时间。
配套的配置表长这样:
// assets/scripts/core/GameConfig.ts export const GameConfig = { baseSpeed: 8, // 开局前进速度,单位/秒 maxSpeed: 24, // 速度上限,防止后期不可玩 accel: 0.4, // 每秒加速度 laneWidth: 2.4, // 三条车道之间的横向间距 laneLerp: 14, // 换道插值系数,越大越跟手 jumpHeight: 2.2, // 跳跃最高点高度 gravity: -22, // 重力加速度,和 jumpHeight 一起决定滞空时间 slideDuration: 0.55, // 下滑持续时间,单位秒 scorePerMeter: 1, // 每米折算多少分 coinScore: 10, // 单枚金币面值 } as const;as const不是装饰,它是把整个对象变成只读,任何脚本里写GameConfig.baseSpeed = 20都会编译报错。跑酷这类项目最常见的事故就是某个脚本图省事,直接改了全局配置里的速度,导致后面所有计算全错,加了只读之后这种改动只能通过正式接口走,问题就收敛了。
3. 一局游戏的完整链路:状态机、赛道拼接与节点池
从点“开始”到弹出结算,中间其实只有三件事在跑:一个状态机管生死,一个管理器管地图,一个池子管节点。这三块如果各写各的、互相getComponent,代码量一上去就没法维护。这份工程的做法是GameManager做单一数据源,其余模块只读它暴露的只读属性,变化通过事件往外抛。
3.1 GameManager:一个枚举管完所有状态
状态机没有用第三方库,就一个枚举加一个私有字段:
// assets/scripts/core/GameManager.ts import { _decorator, Component, director } from 'cc'; import { EventBus, GameEvent } from './EventBus'; import { GameConfig } from './GameConfig'; const { ccclass } = _decorator; export enum GameState { Ready = 0, // 待机,场景已加载但还没开始跑 Running = 1, // 跑动中 GameOver = 2, // 已结束,等待结算面板 } @ccclass('GameManager') export class GameManager extends Component { private static _inst: GameManager | null = null; static get inst(): GameManager { return this._inst!; } private _state: GameState = GameState.Ready; private _speed = GameConfig.baseSpeed; private _distance = 0; private _score = 0; get state() { return this._state; } get speed() { return this._speed; } get distance() { return this._distance; } get score() { return this._score; } onLoad() { GameManager._inst = this; director.addPersistRootNode(this.node); // 跨场景常驻 } startRun() { this._state = GameState.Running; this._speed = GameConfig.baseSpeed; this._distance = 0; this._score = 0; EventBus.emit(GameEvent.SCORE_CHANGED, 0); } update(dt: number) { if (this._state !== GameState.Running) return; // 速度线性增长到上限,dt 累加保证不同帧率下一致 this._speed = Math.min(GameConfig.maxSpeed, this._speed + GameConfig.accel * dt); this._distance += this._speed * dt; this._score = Math.floor(this._distance * GameConfig.scorePerMeter); EventBus.emit(GameEvent.SCORE_CHANGED, this._score); } addCoin(value: number) { if (this._state !== GameState.Running) return; this._score += value; EventBus.emit(GameEvent.SCORE_CHANGED, this._score); } gameOver() { if (this._state === GameState.GameOver) return; // 幂等,防止重复结算 this._state = GameState.GameOver; EventBus.emit(GameEvent.GAME_OVER, this._score); } }_speed和_distance的推进都乘了dt,这是跑酷项目里最基础也最容易被忽略的一条。如果写成this._distance += this._speed * 0.016,在 60 帧设备上没问题,到 120 帧设备上速度直接慢一半,玩家第一反应是“这游戏怎么这么肉”。另外gameOver做了幂等判断,因为角色可能在同一帧同时撞到两个障碍物,如果不拦,结算面板会被弹两次,分数也会被存两次。
addPersistRootNode让GameManager挂在常驻节点上,重开一局时不需要重新初始化,只要把_state打回Ready就行。代价是这个节点下的所有子节点都不会随场景销毁,所以它下面别再挂别的业务节点。
3.2 无限赛道的拼接原理
无限跑酷不是真的生成无限地图,而是“用有限的段,循环往前挪”。TrackManager维护一个段队列,开局摆放 N 段,每帧检查队尾的段是不是已经跑到角色身后,如果是就回收,重新摆到队首前面去:
// assets/scripts/gameplay/TrackManager.ts import { _decorator, Component, Node, Prefab, instantiate } from 'cc'; import { GameConfig } from '../core/GameConfig'; import { PlayerController } from './PlayerController'; const { ccclass, property } = _decorator; @ccclass('TrackManager') export class TrackManager extends Component { @property(Prefab) segmentPrefabs: Prefab[] = []; @property segmentLength = 30; // 每段的 z 轴长度,必须与预制体一致 @property keepCount = 5; // 场上保持的段数 private _segments: Node[] = []; private _nextZ = 0; // 下一段应该摆放的位置 start() { for (let i = 0; i < this.keepCount; i++) this.spawnSegment(); } private spawnSegment() { const idx = Math.floor(Math.random() * this.segmentPrefabs.length); const node = instantiate(this.segmentPrefabs[idx]); node.setPosition(0, 0, this._nextZ); this.node.addChild(node); this._segments.push(node); this._nextZ += this.segmentLength; // 累加而不是乘以索引,避免浮点误差累积 } update() { const playerZ = PlayerController.inst.node.position.z; // 队尾段完全跑到玩家身后一个段长以上,就可以回收 const head = this._segments[0]; if (head && head.position.z + this.segmentLength < playerZ - this.segmentLength) { this._segments.shift(); // 不销毁,直接挪到队首继续用,省掉 instantiate 的开销 head.setPosition(0, 0, this._nextZ); this._nextZ += this.segmentLength; this._segments.push(head); head.emit('segment:reuse'); // 通知段内的障碍/金币重新布置 } } }这段代码的核心在最后几行:回收的段不是destroy,而是直接改坐标挪到队首。instantiate在跑酷这种持续生成的场景里是性能大头,尤其是段内还带障碍物和金币,一次实例化几十个节点,卡顿非常明显。改坐标挪位置这个技巧用对了,地图可以无限跑下去而节点数恒定。
head.emit('segment:reuse')是一个约定:每个赛道段预制体上挂一个小组件,监听这个事件后重置自己内部的障碍物和金币分布。如果不发这个事件,玩家会看到同一段地图原封不动地重复出现,非常出戏。
_nextZ用累加而不是index * segmentLength,是因为浮点数乘多了会出现微小误差,跑个几百段之后段与段之间会露出一条缝,玩家能直接看到背景色,这就是典型的“看起来没问题、跑久了才翻车”的问题。
3.3 节点池:复用而不是反复实例化
赛道段可以靠挪位置复用,障碍物和金币就不行了,因为它们要在段内随机分布、随时被吃掉。这类节点走标准的节点池:
// assets/scripts/core/NodePoolManager.ts import { Node, NodePool, Prefab, instantiate } from 'cc'; export class NodePoolManager { private static _pools = new Map<string, NodePool>(); // 预热:开局一次性创建足够数量,避免游戏中第一次生成时卡顿 static warmup(key: string, prefab: Prefab, count: number) { let pool = this._pools.get(key); if (!pool) { pool = new NodePool(); this._pools.set(key, pool); } for (let i = 0; i < count; i++) pool.put(instantiate(prefab)); } static get(key: string, prefab: Prefab): Node { let pool = this._pools.get(key); if (!pool) { pool = new NodePool(); this._pools.set(key, pool); } return pool.size() > 0 ? pool.get()! : instantiate(prefab); } static put(key: string, node: Node) { const pool = this._pools.get(key); if (pool) pool.put(node); else node.destroy(); } }三个方法各管一件事。warmup在进入对局场景时调用,把障碍物预生成 20 个、金币预生成 60 个,这样第一次刷怪不会触发实例化卡顿。get优先从池里取,池空了才新建,保证不阻塞玩法。put回收节点,如果对应 key 的池不存在就直接销毁,防止节点泄漏。
有一个必须配套的约定:任何被回收的节点,状态要在put之前重置干净。金币被吃掉时如果只做了node.active = false,下次从池里取出来还是隐藏状态,玩家会看到金币凭空消失。常见的做法是在节点的onEnable里统一重置:位置归零、旋转归零、active打开、碰撞标记清掉。
4. 角色操控与碰撞判定:三车道、跳跃下滑和命中检测
跑酷的手感八成来自角色控制这一块。同样是三车道换道,插值系数差一点,玩家就会觉得“滑”或者“僵”。这份工程把控制逻辑全压在PlayerController一个脚本里,输入、位移、跳跃、下滑、碰撞标记都在这一处收敛,好处是调试手感的时候只改一个文件。
4.1 帧率无关的位移与相机跟随
角色沿 -Z 方向前进,速度来自GameManager,自身不维护速度值:
// assets/scripts/gameplay/PlayerController.ts import { _decorator, Component, Vec3, math } from 'cc'; import { GameManager, GameState } from '../core/GameManager'; import { GameConfig } from '../core/GameConfig'; const { ccclass, property } = _decorator; @ccclass('PlayerController') export class PlayerController extends Component { private static _inst: PlayerController | null = null; static get inst() { return this._inst!; } @property(Node) cameraNode: Node = null!; @property cameraOffsetZ = 6; // 相机落后角色的距离 @property cameraOffsetY = 4; // 相机高度 private _lane = 1; // 0 左 / 1 中 / 2 右 private _vy = 0; // 垂直速度 private _baseY = 0; private _sliding = false; private _slideTimer = 0; onLoad() { PlayerController._inst = this; this._baseY = this.node.position.y; } update(dt: number) { if (GameManager.inst.state !== GameState.Running) return; // 1. 前进:速度由 GameManager 统一下发 const pos = this.node.position.clone(); pos.z -= GameManager.inst.speed * dt; // 2. 换道:指数插值,帧率无关 const targetX = (this._lane - 1) * GameConfig.laneWidth; const t = 1 - Math.exp(-GameConfig.laneLerp * dt); pos.x = math.lerp(pos.x, targetX, t); // 3. 跳跃:垂直速度积分 + 落地判定 if (pos.y > this._baseY || this._vy > 0) { this._vy += GameConfig.gravity * dt; pos.y += this._vy * dt; if (pos.y <= this._baseY) { pos.y = this._baseY; this._vy = 0; } } // 4. 下滑计时 if (this._sliding) { this._slideTimer -= dt; if (this._slideTimer <= 0) this.endSlide(); } this.node.setPosition(pos); // 5. 相机跟随,只跟 z,不跟 x,避免换道时画面晃 const camPos = this.cameraNode.position.clone(); camPos.z = pos.z + this.cameraOffsetZ; camPos.y = this.cameraOffsetY; this.cameraNode.setPosition(camPos); } }换道那段用1 - Math.exp(-k * dt)而不是直接写lerp(x, target, 0.2),前者是标准的帧率无关插值,不管 30 帧还是 120 帧,到达目标位置的时间曲线完全一致;后者每帧固定走 20%,帧率越高越快,同一个游戏在两台手机上换道手感完全不同,这种问题在测试阶段很难被发现,上线后才有人反馈“我这台手机特别灵敏”。
相机只跟 z 不跟 x,是个手感取舍。如果相机横向跟着角色走,换道时整个画面会平移,视觉上很晕;固定 x 之后,玩家看到的是角色在画面里左右移动,赛道保持稳定,方向感更清楚。相机位置每帧算z + offset,不做插值,因为角色本身的速度是连续变化的,再加一层平滑反而会产生“相机追不上”的拖影。
4.2 输入层:滑动与键盘的统一
输入不写在PlayerController里,而是单独挂一个InputController,把触摸滑动和键盘方向键统一成四个语义动作,再转发给角色:
// assets/scripts/gameplay/InputController.ts import { _decorator, Component, EventTouch, Input, input, KeyCode, EventKeyboard } from 'cc'; import { PlayerController } from './PlayerController'; const { ccclass, property } = _decorator; @ccclass('InputController') export class InputController extends Component { @property minSwipeDistance = 40; // 像素,小于这个距离不算滑动 private _startX = 0; private _startY = 0; onEnable() { input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this); } onDisable() { input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this); } private onTouchStart(e: EventTouch) { const p = e.getUILocation(); this._startX = p.x; this._startY = p.y; } private onTouchEnd(e: EventTouch) { const p = e.getUILocation(); const dx = p.x - this._startX; const dy = p.y - this._startY; if (Math.abs(dx) < this.minSwipeDistance && Math.abs(dy) < this.minSwipeDistance) return; if (Math.abs(dx) > Math.abs(dy)) { dx > 0 ? PlayerController.inst.moveRight() : PlayerController.inst.moveLeft(); } else { dy > 0 ? PlayerController.inst.jump() : PlayerController.inst.slide(); } } private onKeyDown(e: EventKeyboard) { switch (e.keyCode) { case KeyCode.ARROW_LEFT: PlayerController.inst.moveLeft(); break; case KeyCode.ARROW_RIGHT: PlayerController.inst.moveRight(); break; case KeyCode.ARROW_UP: PlayerController.inst.jump(); break; case KeyCode.ARROW_DOWN: PlayerController.inst.slide(); break; } } }minSwipeDistance这个阈值不能省。触摸屏上点一下也会触发TOUCH_START和TOUCH_END,如果不做距离过滤,玩家轻轻一点角色就跳,手感会很怪。40 像素是个经验值,屏幕分辨率差异大的话可以按view.getVisibleSize()的比例换算。
横竖方向用绝对值比大小来判断主轴,避免斜着划一下同时触发换道和跳跃。键盘只做调试用,但保留着很有必要,因为真机上测手感很慢,PC 上方向键能快速验证逻辑。
4.3 碰撞判定:手动 AABB 与触发器的分工
跑酷的碰撞有个特点:大部分是“撞到就死”的致命障碍,少量是“碰到就吃”的金币。这两类需求不一样,工程里用了两套方案。
致命障碍用手动 AABB,不挂物理组件:
// assets/scripts/gameplay/CollisionChecker.ts import { _decorator, Component, Vec3, Node } from 'cc'; import { PlayerController } from './PlayerController'; import { GameManager } from '../core/GameManager'; const { ccclass, property } = _decorator; @ccclass('CollisionChecker') export class CollisionChecker extends Component { @property halfSize = new Vec3(0.5, 1, 0.5); // 角色包围盒半尺寸 @property obstacleRoot: Node = null!; // 所有障碍物的父节点 private _tmp: Vec3 = new Vec3(); update() { const p = PlayerController.inst.node.position; // 下滑时包围盒高度减半,模拟“蹲下” const hy = PlayerController.inst.isSliding ? this.halfSize.y * 0.5 : this.halfSize.y; for (const child of this.obstacleRoot.children) { if (!child.active) continue; Vec3.subtract(this._tmp, child.position, p); if (Math.abs(this._tmp.x) < this.halfSize.x + 0.5 && Math.abs(this._tmp.y) < hy + 0.5 && Math.abs(this._tmp.z) < this.halfSize.z + 0.5) { GameManager.inst.gameOver(); return; } } } }手动 AABB 的好处是完全可控:判定盒尺寸能单独调,下滑时把 y 方向半高减半,角色就能“钻”过低矮障碍,不需要额外做物理层配置。而且跑酷场景同屏障碍物通常不到 20 个,遍历一遍的开销可以忽略,比开物理系统还轻。
金币走BoxCollider加isTrigger,靠Contact2DType.BEGIN_CONTACT回调处理。因为金币数量多、需要精确的“进入即拾取”语义,交给引擎的触发系统更省事。这里要注意的是物理分组:金币的 group 只和角色碰撞,障碍物走手动判定不参与物理,两组互不干扰,避免撞到障碍时也触发金币回调。
5. 跑起来才会暴露的坑:五条现象对应的排查路径
编辑器里跑得顺不代表真机没问题。下面这几条是我在这类工程里反复遇到的,每条都按“看到什么、为什么、怎么改”记一遍。
现象一:高刷手机上游戏明显变慢,低端机反而正常。原因是位移用了固定步长,this.node.position.z -= speed * 0.016。解决方式是把所有位移改成speed * dt,包括角色前进、赛道回收判断、相机跟随,一个都不能漏。查的时候全局搜0.016和0.033,出现一个改一个。
现象二:跑个三五分钟后,赛道段之间出现细缝。原因是段位置用index * segmentLength计算,浮点乘法累积误差。改法是维护一个_nextZ变量累加,同时检查所有segments预制体的实际长度是否真的等于segmentLength,美术改过模型但没同步脚本参数的情况非常常见。
现象三:重开一局后分数翻倍,或者金币吃两次。原因是事件在onEnable订阅,但重开时没有对应的onDisable,节点被复用后回调被注册了两次。解决方式是所有EventBus.on严格配对EventBus.off,并且off传同一个函数引用。
现象四:金币从池里取出来之后看不见。原因是回收时只做了node.active = false,没有重置内部状态,或者金币播放的拾取动画被中断在了中间帧。解决方式是在Coin组件的onEnable里统一重置位置、缩放、透明度,并把动画状态stop()后play(),不要依赖回收时的清理。
现象五:打包到手机后碰撞完全失效。原因是CollisionChecker遍历的obstacleRoot是场景节点引用,打包后赛道段走的是池化复用,障碍物被挂到了池节点下面,不在原来的父节点里了。解决方式是不按父节点遍历,改成维护一个活跃障碍物数组,生成时push、回收时splice,遍历数组比遍历场景树更稳。
提示:真机测试至少覆盖一台 60 帧和一台高刷设备,跑酷的帧率相关问题只在跨设备时暴露,编辑器里永远看不出来。
6. 二次开发:换玩法、调难度和改完怎么验证
真要在这份工程上做二次开发,我建议先动数值,再动结构,最后才动玩法。原因很简单:数值改动出问题一眼能看出来,结构改动出问题要跑很久才能发现,而玩法改动会同时牵动前两者。
调难度只改GameConfig就够:baseSpeed决定开局压力,accel决定多久进入高难度,maxSpeed是安全阀。想把游戏做“短平快”,就把accel拉到 0.8、maxSpeed压到 18,一局两分钟以内结束,适合休闲场景。想做长线挑战就反过来,但要注意maxSpeed不能无限加,超过 30 之后障碍物的可视距离不够,玩家反应不过来,本质上是输入延迟问题不是难度问题。
换玩法的改造点集中在三处。一是加道具,在pickups下建新预制体,挂一个监听GAME_OVER前拦截的组件(比如护盾),在PlayerController里加一个hasShield标志,CollisionChecker判到碰撞时先看有没有护盾。二是换视角,把cameraOffsetY拉高、改成俯视,同时把TrackManager的转向逻辑改成沿 x 轴拼接,逻辑本身不用重写。三是做关卡制,TrackManager不再无限回收,而是读一份关卡配置表,段预制体按表顺序生成,跑到末尾就结算。
改完怎么验证,我自己有一个固定流程。第一步,把GameConfig里所有数值在调试面板上暴露出来,用Slider实时改,边跑边调,比改代码重编译快十倍。第二步,写一个DebugAutoPlay脚本,模拟随机换道和跳跃,挂在角色上跑十分钟,看会不会卡死、节点数会不会涨、分数会不会溢出。第三步,开引擎的stats面板,盯着draw call和节点数,跑酷最怕的就是节点数缓慢上涨,十分钟内从 200 涨到 500 基本可以确定有地方忘了put回池子。
// 调试期专用:跑十分钟,观察节点数是否稳定 update(dt: number) { this._timer += dt; if (this._timer < 0.5) return; this._timer = 0; const r = Math.random(); if (r < 0.4) PlayerController.inst.moveLeft(); else if (r < 0.8) PlayerController.inst.moveRight(); else PlayerController.inst.jump(); }最后提醒一句:改TrackManager的keepCount时,记得同步改预热池的segment数量,两边对不上的话,开局第一秒会在池子和实例化之间来回抖一下。从那以后我每次接跑酷类需求,都会先把GameManager.update里那行dt打上断点走一遍,确认没有任何一处位移绕过了它,再开始动别的。希望帮到你。
本文还有配套的精品资源,点击获取