简介这是一份基于 Cocos Creator 开发的微信经典飞机大战完整项目面向希望学习游戏开发的小白与进阶学习者尤其适合作为毕设、课程设计或工程实训的起步项目。项目采用数据驱动设计敌机生成频率、速度、子弹频率等参数均可在配置中灵活调整便于扩展子弹种类与 UFO 类型并运用对象池管理敌机、子弹和 UFO减少运行时开销。历史最高分通过本地存储实现主控模块负责游戏主逻辑、暂停与继续下方拆分为敌机组、子弹组、UFO 组三个分组结构清晰、易读易改。资源包共 133 个文件约 1.15MB含 JavaScript 脚本、Prefab 预制体、场景 fire 文件、动画 anim、音频 mp3 及图片资源从代码到美术资源一应俱全。已有 230 人学习下载适合快速上手 Cocos Creator 小游戏开发并理解对象池与模块化架构。1. 用 Cocos Creator 复刻的微信经典飞机大战先拆数据驱动和对象池这个项目拿到手第一眼看到的是hero.anim、enemy1ani.anim、main.fire这一堆动画和粒子文件很容易当作纯粹的美术素材。但真正把它跑起来之后会发现这套基于 Cocos Creator 开发的微信经典飞机大战代码骨架比画面更有学习价值项目把敌机、子弹、UFO 分别丢给三个分组管理用数据驱动的方式让生成频率、移动速度、子弹间隔全部变成可调的配置历史分数走本地存储。对刚接触游戏循环、组件通信的小白来说它是一个能一次性把“自上而下的结构控制”和“对象池”两个概念落地的最小完整项目对已经写过几个 Demo 的开发者来说对象池回收、动画状态切换这几段代码也能直接拆出来复用。2. 项目结构与资源清单六个 anim 和四个 fire 分别挂在哪里2.1 资源清单先搞清这几个文件是干什么的在 Cocos Creator 工程里.anim文件是 AnimationClip 动画切片.fire文件是旧版粒子系统资源它们分别服务于加载、主流程、结算这三段场景。先把所有资源归位后面的节点设计才不会乱。资源文件类型常见用途建议挂载位置game_loading.anim动画切片加载场景转圈或入场动画加载场景中的节点 Animation 组件hero.anim动画切片玩家飞机常规飞行、左右倾斜玩家节点 Playerhero_blowup_ani.anim动画切片玩家爆炸动画玩家节点或独立爆炸节点enemy1ani.anim动画切片小型敌机飞行动画敌机预制体 Enemy1enemy2ani.anim动画切片中型敌机飞行动画敌机预制体 Enemy2enemy3ani.anim动画切片大型敌机飞行动画敌机预制体 Enemy3start.fire粒子资源开始界面背景特效开始场景 UI 底下的粒子节点main.fire粒子资源主游戏界面引擎火焰或背景拖尾主场景玩家或背景粒子节点historyScore.fire粒子资源历史分数刷新时的高亮反馈结算界面历史分数节点end.fire粒子资源游戏结束爆炸或结算特效结束场景的中心粒子节点这里有一个容易忽略的设计点玩家飞机的动画被拆成了hero.anim和hero_blowup_ani.anim两个切片而不是合成一段长动画。动画拆开的好处一是加载粒度更细二是状态切换更明确——飞行状态播hero死亡状态播hero_blowup_ani互不干扰。如果你在别处看到一个 60 帧的合成长动画改一次爆炸时间就得重新导出整个文件那才是真的折磨。2.2 场景节点设计main 下面为什么要挂三个空节点分组打开主场景后节点树的组织方式是理解整个项目架构的关键。main 是整个游戏的根控制节点它下面挂了三个分组敌机组、子弹组、UFO 组每组都是一个空的容器节点enemyGroup敌机预制体生成后统一 parent 到这里bulletGroup子弹预制体生成后统一 parent 到这里ufoGroupUFO 道具预制体生成后统一 parent 到这里用空节点做分组容器而不是把敌机直接丢到 Canvas 下面有三个直接的好处。第一暂停的时候可以一次性遍历所有子节点去停掉动画和移动第二统计当前敌机数量、子弹数量非常方便直接this.enemyGroup.children.length就能拿结果第三对象池回收时从哪个组借出来的就还回哪个组结构不会乱。main 节点本身也承担了全局状态管理它持有三个分组的引用控制暂停、继续、得分、历史分数。真正的玩法对象——单独一艘玩家飞机、单独一个敌机——都不直接访问 main而是通过组脚本和 main 通信。这种自上而下的单向依赖比到处用全局变量传状态要清晰得多。2.3 加载与播放从 game_loading 到 start.fire 再到 main.fire飞机大战的流程是加载场景播game_loading.anim进入开始界面播start.fire点击开始后切主场景玩家飞机上挂main.fire模拟火焰拖尾。加载和播放的坑通常出在“动画组件还没就绪就调用 play”。Cocos Creator 的 Animation 组件在节点 active 之后才会完成初始化如果 onLoad 里直接play有概率出现播不出来的情况。常见做法是在start生命周期里播放或者在资源加载完成的回调里再播// LoadingScene.js cc.Class({ extends: cc.Component, properties: { loadingAnim: cc.Animation, }, start() { // start 阶段保证 Animation 组件已完成初始化 this.loadingAnim.play(game_loading); this.scheduleOnce(() { this._enterMain(); }, 2.0); }, _enterMain() { cc.director.loadScene(main, () { cc.log(load main scene done); }); }, });这里play(game_loading)里的名字要和动画切片的名称保持一致如果切片在资源里叫别的名字直接传文件名是无效的。scheduleOnce的第二个参数是延迟秒数我一般会拿它模拟一个最短加载时间免得场景切换太快用户连加载动画都没看到就闪过去了。真正发布微信小游戏时这里最好改成读取进度回调用实际加载进度驱动而不是固定两秒。3. 对象池与组管理让敌机、子弹、UFO 共用一套回收逻辑3.1 为什么是对象池反复 create 和 destroy 的代价微信小游戏运行在浏览器环境里频繁创建和销毁节点会触发大量内存分配和垃圾回收。飞机大战里子弹射击频率高、敌机刷新密集如果每一发子弹都是cc.instantiate、每一次消失都destroy帧率很快就会波动——表现就是突然卡一下。对象池的思路是节点用完后不销毁而是隐藏起来放到一个池子里下次需要用的时候直接取出来重新初始化。Cocos Creator 提供了cc.NodePool专门做这件事它内部帮你管理节点缓存取节点用get()还节点用put()比自己维护数组要省心。3.2 一个可复用的 ObjectPool 组件我在做类似项目时习惯在cc.NodePool外面再包一层把初始扩容、池状态打印都封装进去项目里各个分组直接依赖这个封装而不是直接操作 NodePool。下面这段代码可以直接放到工程里当作通用组件// ObjectPool.js const { ccclass } cc._decorator; ccclass export default class ObjectPool { /** * param {cc.Prefab} prefab 要池化的预制体 * param {number} initialSize 初始预创建数量 */ constructor(prefab, initialSize 10) { this.prefab prefab; this.nodePool new cc.NodePool(); for (let i 0; i initialSize; i) { // 预创建节点让后续战斗中减少运行时实例化 this.nodePool.put(cc.instantiate(prefab)); } } get() { // 池里有就直接取池空就临时创建兜底 return this.nodePool.size() 0 ? this.nodePool.get() : cc.instantiate(this.prefab); } put(node) { // 回收前先停止所有动作避免残留运动状态 node.stopAllActions(); node.removeFromParent(); this.nodePool.put(node); } clear() { this.nodePool.clear(); } }逻辑说明构造时先cc.instantiate出 initialSize 个节点放进池里相当于预热get()时如果池空说明瞬时需求超过了预创建数量这时再现场创一个也算兜底不会因为池空导致游戏直接崩。put()里我做了一件很重要的事回收前调用stopAllActions()。敌机被子弹打中时可能正在播放爆炸动画或者正在位移如果不停止动作直接回收下次从池里取出来时它还会继续执行上一次的动作这是对象池最常见的现形坑。参数说明initialSize太小战斗激烈时会频繁走cc.instantiate兜底分支性能优势不明显太大则会增加首屏加载耗时和内存占用。飞机大战这种规模的项目里敌机池预创建 10 个、子弹池预创建 20 到 30 个、UFO 池预创建 2 到 3 个是比较平衡的取值。3.3 三个分组如何共用池并各自扩展项目里敌机组、子弹组、UFO 组虽然逻辑不同但“从池里取、设置参数、回收”这套流程是一致的。我一般会让三个组脚本继承同一个基类把对象池的初始化和回收动作收敛在基类里// BaseGroup.js const { ccclass } cc._decorator; ccclass export default class BaseGroup extends cc.Component { init(prefabDict, sizes {}) { this._poolMap {}; for (const key in prefabDict) { this._poolMap[key] new ObjectPool(prefabDict[key], sizes[key] || 5); } } recycle(node) { const pool this._poolMap[node.name]; if (pool) { pool.put(node); } else { node.destroy(); } } clearAll() { for (const key in this._poolMap) { this._poolMap[key].clear(); } } }关键点在于回收时用node.name作为 key 找到对应的池子。这就要求预制体实例的 name 必须统一比如enemy1预制体实例化出来默认名字就是enemy1不要在运行时改成别的名字。如果某个节点没有对应的池说明它可能是临时创建的场景物体直接 destroy 兜底。敌机组在这个基座上要实现的主要逻辑是“按配置定时生成”生成时决定用哪种敌机、设置多少血量、多少速度// EnemyGroup.js cc.Class({ extends: BaseGroup, properties: { enemy1Prefab: cc.Prefab, enemy2Prefab: cc.Prefab, enemy3Prefab: cc.Prefab, }, start() { this._config gameConfig.enemy; // 从配置读取 this._timer 0; this.init({ enemy1: this.enemy1Prefab, enemy2: this.enemy2Prefab, enemy3: this.enemy3Prefab, }, { enemy1: 10, enemy2: 5, enemy3: 2 }); }, update(dt) { if (gameState.paused) return; this._timer dt; const interval this._getSpawnInterval(); if (this._timer interval) { this._timer 0; this._spawnOne(); } }, _spawnOne() { // 简单加权随机小敌机出现概率最高 const rand Math.random(); const key rand 0.7 ? enemy1 : (rand 0.9 ? enemy2 : enemy3); const node this._poolMap[key].get(); node.parent this.node; node.active true; // 具体初始化交给敌机自己的组件 node.getComponent(Enemy).initWithConfig(this._config[key]); }, _getSpawnInterval() { // 生成间隔由配置驱动后期难度曲线只需要改配置 const rate gameState.currentDiff || 1; return this._config.spawnInterval / rate; }, });这段代码里真正体现“组管理”优势的地方是父节点所有敌机生成后都挂到enemyGroup下。游戏暂停时我只用遍历this.node的子节点把每个子节点的动作暂停即可不需要去场景里到处找敌机。UFO 组的生成逻辑跟敌机组几乎一样只是生成间隔更长、回收条件变成了“吃到道具后消失”。子弹组逻辑略有不同它不自己刷新而是由玩家节点或玩家射击组件调用takeBullet()方法从池里取子弹发射后碰到敌人或飞出屏幕再回收。4. 数据驱动把敌机速度、生成频率、子弹参数从代码里捞出来4.1 配置表该长什么样一份 config.js 起步拿 demo 项目最烦的一件事是“调数值要到处翻代码”。敌机速度写在 Enemy.js 的update里子弹频率写在 Player 的shoot方法里UFO 掉率写在某个 if 判断里——换个难度恨不得改四五个文件。这个项目的做法是数据驱动也就是把和玩法相关的参数全部抽到一个配置对象里。我建议工程里新建一个config/gameConfig.js把散落的数值全部收拢// config/gameConfig.js const gameConfig { player: { speed: 320, fireInterval: 0.22, bulletType: 0, }, bullet: { speed: 700, types: [ { id: 0, damage: 1, count: 1 }, { id: 1, damage: 1, count: 2, spread: 12 }, ], }, enemy: { spawnInterval: 1.4, list: [ { type: 1, hp: 1, speed: 180, score: 100 }, { type: 2, hp: 2, speed: 150, score: 250 }, { type: 3, hp: 5, speed: 110, score: 800 }, ], }, ufo: { interval: 8.0, dropRate: 0.6, }, difficulty: [ { time: 0, intervalRate: 1.0, speedRate: 1.0 }, { time: 60, intervalRate: 0.7, speedRate: 1.1 }, { time: 120, intervalRate: 0.5, speedRate: 1.2 }, ], }; module.exports gameConfig;这份配置设计的要点是类型和数值分开。bullet.types用数组管理多种子弹每种子弹有自己的 id、伤害、数量enemy.list是敌机属性表但生成顺序、权重由逻辑层决定配置只负责“有什么”和“强度多少”。难度曲线放在difficulty数组里以游戏运行时长作为分界点后面加难度只需要往数组里追加一档不用改任何逻辑。4.2 main 帧循环如何消费配置生成、暂停与计分有了配置以后main 节点不再关心具体数值它只负责把配置转发给各个分组。我在项目里会让 main 的update做三件事累计运行时间、计算当前难度档位、向各个组下发倍率// Main.js 摘选 update(dt) { if (this.paused) return; this.runTime dt; const diff this._getDifficultyByTime(this.runTime); // 下发难度倍率各分组自己决定怎么用 this.enemyGroup.setGlobalRate(diff.intervalRate, diff.speedRate); this.ufoGroup.setSpawnRate(diff.intervalRate); // 计分、刷新 UI this._refreshScoreUI(); }, _getDifficultyByTime(time) { const stages gameConfig.difficulty; let current stages[0]; for (let i 0; i stages.length; i) { if (time stages[i].time) { current stages[i]; } } return current; },逻辑说明main 并没有直接修改敌机对象的属性而是把难度倍率发给 enemyGroup让 group 在生成时把倍率合并进初始值里。这样做的原因是避免出现生成一半的敌机属性突然被改掉的脏状态。难度数组的顺序按时间升序遍历时每次取最后一个time小于当前运行时间的档位天然就实现了难度平滑递进。暂停逻辑也放在 main 里更合适。暂停不是简单的停止update因为某些动画和粒子还在播。我的做法是 main 里维护gameState.paused切到后台或点击暂停按钮时除了置位状态还要遍历三个分组把所有子节点的动作暂停一遍pause() { this.paused true; gameState.paused true; this.enemyGroup.pauseAll(); this.bulletGroup.pauseAll(); this.ufoGroup.pauseAll(); },4.3 扩展一个新子弹或新 UFO只动配置和工厂数据驱动带来的最实际收益是加新玩法时不用东一榔头西一棒子地改代码。比如我要加一种“穿透子弹”在原来的单发子弹基础上多一个穿透属性改动只有三处。第一处在gameConfig.bullet.types里追加一个新 id 和penetrate: true第二处在玩家射击组件里根据当前bulletType从配置里取出对应的子弹参数并塞给子弹节点第三处在 Bullet 组件的命中逻辑里判断如果是穿透子弹就不立刻回收只减少剩余穿透次数。UFO 的扩展同理。比如原来的 UFO 只掉一个加速道具现在想加一个加命 UFO只需要在gameConfig.ufo里增加一个types数组每种类型对应一个预制体名和掉率生成时按配置权重随机即可。新加一个配置项比新加一个 if 分支要容易维护得多这也是为什么拿到项目时我会先翻 config 而不是先翻逻辑。5. 微信真机上的避坑清单动画、回收、存储与粒子的五个翻车点5.1 动画播放完还在漂爆炸结束后敌机继续撞玩家现象hero_blowup_ani.anim播完了飞机残骸还留在场景里碰撞体也在继续生效玩家被一个已经炸掉的飞机又撞掉一条命。原因很多人在播放死亡动画后只做了play没有在动画播完的节点上停掉移动逻辑和碰撞体。Animation 组件播完默认会停留在最后一帧不主动处理的话节点就一直处于“看起来死了实际上还活着”的状态。解决播放爆炸动画前先禁用碰撞体再用scheduleOnce延迟回收节点延迟时间取动画的实际时长blowUp() { const anim this.node.getComponent(cc.Animation); const collider this.node.getComponent(cc.BoxCollider); if (collider) collider.enabled false; anim.play(hero_blowup_ani); const duration anim.getAnimationState(hero_blowup_ani).duration; this.scheduleOnce(() { this.node.active false; }, duration); }5.2 对象池回收后节点带着上一轮的残留状态复活现象一个血量多的大敌机被打爆后下一次从池里取出来是满血还是残血完全随机有时候取出来的敌机还带着上一轮挂上的加速道具子节点。原因回收时只调用了NodePool.put没有重置节点上的数值型属性。节点是复用的但组件里的hp、speed、isDead这些变量还停留在上一次战斗结束时的值。解决在回收前统一重置属性。我的做法是在每个敌机组件的initWithConfig里把所有状态强制设默认值并且回收时也做一次基础清理双保险。初始化务必包括位置重置、缩放重置、速度重置、存活标记置为 true、移除可能残留的子节点。5.3 历史分数模拟器正常真机重启变 0现象在 Cocos Creator 模拟器里打到 5000 分关掉重开分数还在发布到微信开发者工具或真机上杀进程再进历史分数变成 0。原因真机上cc.sys.localStorage走的是微信小游戏的存储能力key 的读写如果在代码里写死不统一一次存的是highScore一次读的是historyScore必然读到没值。另外如果直接存数字没事但如果是存对象忘了JSON.stringify读的时候又没有try/catch一个非法 JSON 就能把整个读分流程带崩。解决把 key 固定成常量读写各包一层异常保护const KEY historyScore; saveScore(score) { try { const data JSON.stringify({ score: score, time: Date.now() }); cc.sys.localStorage.setItem(KEY, data); } catch (e) { cc.warn(save score failed, e); } }, loadScore() { try { const data JSON.parse(cc.sys.localStorage.getItem(KEY) || {}); return data.score || 0; } catch (e) { return 0; } },5.4 粒子不显示或者只显示一帧现象开始界面的start.fire正常显示但切到主场景后main.fire挂的火焰拖尾就是不出来偶尔出来了炸了一下就消失。原因.fire粒子资源挂到节点上之后默认是在粒子组件启用时播放但如果这个粒子节点被对象池回收或者粒子组件所在节点在切换场景时被设成 inactive粒子系统就会立刻停止。还有一种是粒子生命周期短默认发射器持续发射但节点上勾了“完成时自动销毁”播放一次后整个节点被销毁后续自然看不见。解决粒子节点不要放进对象池让它和场景同生命周期播放通过代码显式调用particleSystem.play()而不是依赖自动播放如果你希望粒子在界面里循环播放确认勾选循环发射并不要勾选自动销毁节点。5.5 音效和粒子同时爆发真机掉帧现象大敌机死亡时同时播放爆炸音效、爆炸粒子、屏幕震动帧率突然掉到个位数多架敌机同时死时更明显。原因微信小游戏对 WebAudio 的音频通道数量有限制同一时间创建过多音频实例会直接失败或卡顿。粒子叠加也是一个原因多个高发射频率粒子同时运行会让 GPU 填充率超负荷。解决做一个极简的音频管理器限制同时播放的音频数量。比如维护一个当前播放列表超过限制就把最早的音效停掉。粒子层面大爆炸场景不要让每个敌机都挂全屏粒子远距离小敌机死亡时播一个小尺寸爆炸就够。6. 验证与再扩展用 Profiler 确认对象池生效再加一种子弹实战6.1 用 Profiler 确认对象池真的在干活很多项目把对象池代码写上了但没真正生效问题出在某个分支直接cc.instantiate或者直接destroy。验证对象池有没有生效最好的办法是看场景节点数。在 Cocos Creator 编辑器里打开 Profiler或者在游戏里加一个 debug 输出每 3 秒打印一次当前子弹组和敌机组的子节点数量。如果子弹池生效高频率射击场景下子弹节点数会稳定在一个区间而不是无限上涨this.schedule(() { const bulletCount this.bulletGroup.node.children.length; const enemyCount this.enemyGroup.node.children.length; cc.log([pool] bullets${bulletCount} enemies${enemyCount}); }, 3.0);正常使用对象池时节点数会在池容量上下小幅波动如果这个数字一路涨到几百说明有节点取出来之后没有走回收入池逻辑要么是飞出发了没回池要么是被destroy分支提前干掉了。这个输出在微信开发者工具的 console 面板里也能看到是一个廉价但有效的性能核对手段。6.2 给项目加一种穿透子弹三步改动有了数据驱动和对象池加新子弹这件事的流程会变得非常固定。我以“穿透子弹最多穿透两个敌人”为例梳理一次完整改动路径。第一步在gameConfig.bullet.types里追加一个新配置{ id: 2, damage: 1, count: 1, penetrate: 2, speedRate: 1.1 }第二步玩家射击组件在取子弹时把配置里的penetrate字段传给子弹组件const bulletCfg gameConfig.bullet.types[this.bulletType]; const bullet this.bulletPool.get(); bullet.getComponent(Bullet).init(bulletCfg);第三步在 Bullet 组件的命中处理里判断穿透值onHit(target) { if (this.penetrate 0) { this.penetrate--; // 不回收继续飞行但不能再撞同一个目标 target.hurt(this.damage); } else { this.group.recycle(this.node); } }这里有个细节子弹碰撞同一个敌机时碰撞回调可能连续触发两次导致穿透子弹一次把敌机打死。我习惯给目标加一个“最近命中标记”同一帧内同一个 target 只结算一次。这个坑在加穿透子弹时几乎必踩写的时候要一并处理。6.3 真机预览与音量、帧率的关系最后提醒一个微信小游戏特有的检查方法Cocos Creator 编辑器里的预览帧率不能代表真机帧率尤其是粒子密集的场景。我在跑这个飞机大战时习惯在微信开发者工具里先在模拟器过一遍玩法然后连真机预览再打一把注意力放在两个地方关闭按钮后场景是否卡顿爆炸粒子是否把内存推高。真机预览有独立的性能面板能直接看到 CPU 占用。游戏开发里有一个很朴素的经验编辑器里看着流畅不算过关真机跑到 60 帧才算落地。这些年我每次拿到飞机大战、雷电这类资源都会先翻 config 和对象池确认这两个架子搭得正再去看爆炸特效和 UI。只要生成逻辑是数据驱动的、回收逻辑走了对象池后面加子弹、加难度、加道具都会顺很多。希望你也能从这套结构里找到适合自己项目复用的那部分——哪怕只拿走一个 NodePool 封装也值回下载这趟折腾了。本文还有配套的精品资源点击获取