微信小游戏飞机大战:Cocos Creator中动画资源与对象池的工程实践
2026/9/14 4:04:48 网站建设 项目流程

简介:基于Cocos Creator开发的微信经典飞机大战完整项目源码,面向希望从零接触游戏开发的小白或需要完成毕设、课程设计、工程实训的进阶学习者。项目以数据驱动方式组织敌机生成频率、子弹速度等参数,方便扩展子弹种类与不明飞行物(UFO)类型,并利用对象池管理敌机、子弹和道具,减少运行开销;历史最高分通过本地存储实现,采用自上而下的控制结构,由主控模块统一管理暂停、继续与得分,下设敌机组、子弹组、道具组管理组员生成逻辑。压缩包共133个文件,主要包括脚本、预制体、音效、动画、JSON配置和场景文件,辅以图片资源,整体约1.15MB,结构紧凑、目录清晰,适合对照工程学习。已有227人学习/下载,包含完整项目目录与核心代码,可直接运行调试,便于二次开发,也能参考其对象池、数据驱动和本地存储的实现思路。

1. 为什么微信小游戏版飞机大战要单独剥离 .anim 和对象池

移动端跑飞机大战,最怕的不是飞机画得丑,而是弹幕一多就卡。微信经典飞机大战用 Cocos Creator 重制时,资源文件里出现了一大批散装的 .anim 和 .fire:hero_blowup_ani.anim、enemy1ani.anim 到 enemy3ani.anim、main.fire、end.fire、historyScore.fire。这套工程把动画表现、场景入口、逻辑控制拆得比较开,同时用数据驱动参数表控制生成频率、速度和道具类型,敌机、子弹、UFO 都走了对象池。新手拿它当课程设计,能学到的不只是“写一个飞机大战”,而是资源组织方式和内存回收思路;有经验的人则可以重点看它是怎么用分组和配置降低耦合的。下面先从 .anim 这个最容易忽视的细节讲起。

2. .anim 动画资源与场景拆分:先搞清资源归属再写代码

2.1 .anim 与 .fire 在 Cocos Creator 工程里的角色

main.firestart.fireend.firehistoryScore.fire都是场景文件,而game_loading.animhero.animhero_blowup_ani.animenemy1ani.animenemy2ani.animenemy3ani.anim是动画剪辑资源。把这两类资源分开命名,是为了让策划或美术调整表现时不需要频繁改代码。.fire场景文件负责承载节点树,.anim动画资源负责描述关键帧、曲线和动画事件,两者通过Animation组件挂接。

从这个资源清单能看出原作者的场景规划:启动时播放game_loading.anim,进入main.fire开始游戏,结束时跳到end.fire,历史最高分由historyScore.fire单独承载。这样切场景时不会把整局游戏的对象都带到结果页,微信小游戏的内存压力更小。Cocos Creator 支持把场景放进 Asset Bundle 按需加载,所以这种多场景结构很适合微信小游戏平台,首包可以先只放 start 场景,其余场景等加载动画播放时再预加载。

一个容易踩的坑是:在带.fire后缀的旧式资源工程里,场景文件在构建后会合进脚本包或分包中,不能按文件名在运行时直接访问。需要跳转时统一通过director.loadScene('main')实现,参数通常是.fire文件名去掉扩展名。如果把场景放到assets/resources目录下,虽然也能用,但容易和普通资源混在一起,不推荐。

资源名类型用途
game_loading.anim动画剪辑加载页转圈或进度条动效
hero.anim动画剪辑玩家飞机常规飞行动画
hero_blowup_ani.anim动画剪辑玩家受击爆炸序列
enemy1ani.anim动画剪辑小型敌机飞行/摇摆动画
enemy2ani.anim动画剪辑中型敌机飞行/攻击动画
enemy3ani.anim动画剪辑大型敌机飞行/攻击动画
main.fire场景主游戏场景
start.fire场景开始菜单场景
end.fire场景游戏结束场景
historyScore.fire场景历史最高分展示场景

2.2 在脚本中挂接 .anim 并播放爆炸动画

主角在正常飞行和爆炸时使用不同动画,代码里最直接的做法是获取Animation组件并按名称播放。下面是一段挂在玩家节点上的简写脚本:

import { _decorator, Animation, Component } from 'cc'; const { ccclass } = _decorator; @ccclass('Hero') export class Hero extends Component { private anim: Animation | null = null; onLoad() { // 玩家自身挂着 Animation 组件,clips 里放入 hero.anim 和 hero_blowup_ani.anim this.anim = this.getComponent(Animation); } playFly() { if (this.anim) { this.anim.play('hero.anim'); } } playExplode(callback?: () => void) { if (!this.anim) return; this.anim.play('hero_blowup_ani.anim'); // 监听一次播放结束,用于把节点回收到对象池 this.anim.once(Animation.EventType.FINISHED, () => { callback?.(); }, this); } }

这段代码里有几个关键点。play('hero.anim')里的字符串不是文件路径,而是动画剪辑在Animation组件clips数组里注册的名字,所以资源名和剪辑名保持一致很重要。once(Animation.EventType.FINISHED, ...)只监听一次,避免爆炸动画播放期间回调被多次触发;在微信小游戏环境里,如果动画没播完就执行active = false,帧事件可能不再派发,所以要先等回调再回收。callback参数留给上层使用,播放爆炸后由 main 或对象池决定是removeFromParent还是放进池子。

同一个动画剪辑可以被多个节点共享。比如三架小型敌机都用左右摇摆飞行动画,只要节奏一致,可以直接共用enemy1ani.anim,不需要每架复制一份,这样微信小游戏包体更小。动画事件也可以放在剪辑时间轴上,例如爆炸动画播到第 3 帧时调用某个脚本方法,但事件回调方法必须存在于挂动画的组件脚本上,否则构建后在真机上会报“找不到事件函数”。

还要注意加载时机。如果hero_blowup_ani.anim没有提前放进Animation组件的 Clips 列表,而是运行时才想加载,需要从 Resources 目录动态获取:

import { resources, AnimationClip } from 'cc'; resources.load('anim/hero_blowup_ani', AnimationClip, (err, clip) => { if (err) { console.error('加载爆炸动画失败:', err); return; } this.anim.addClip(clip, 'hero_blowup_ani.anim'); this.anim.play('hero_blowup_ani.anim'); });

resources.load的第一个参数是assets/resources目录下的相对路径,不包含扩展名,第二个参数传AnimationClip才能让资源系统走正确的反序列化路径。动态加载适合把资源拆到微信小游戏分包里,缺点是必须维护加载状态,动画在加载完成前不可播放。所以我个人更推荐:首包放得下时直接把常用动画拖进 Clips 列表,运行时按名字播放,省掉异步等待和失败分支。

3. 数据驱动配表与敌机/子弹/UFO 对象池的落地写法

3.1 用 GameConfig 把生成参数变成一张可改的表

这个项目强调“CC 数据驱动的优势”,指的是把生成频率、移动速度、发弹间隔这类常量集中放到配置结构里,而不是散落在各个节点脚本中。我通常会先定义一份GameConfig.ts,里面包含敌机表、子弹表、UFO 表。以敌机为例:

export interface EnemyConfig { type: number; hp: number; score: number; speed: number; spawnInterval: number; animClip: string; } export const EnemyTable: EnemyConfig[] = [ { type: 1, hp: 1, score: 100, speed: 180, spawnInterval: 0.8, animClip: 'enemy1ani.anim' }, { type: 2, hp: 3, score: 300, speed: 120, spawnInterval: 2.2, animClip: 'enemy2ani.anim' }, { type: 3, hp: 8, score: 800, speed: 80, spawnInterval: 5.0, animClip: 'enemy3ani.anim' } ];

这样做之后,调整某一难度只需要改EnemyTable[1].speed,不需要在EnemyGroup里打补丁。子弹和 UFO 用同样的思路处理:子弹的关键差异在damageflySpeedshootInterval,UFO 的关键差异在效果类型和持续时间。例如增加一种“双发子弹”道具,只需要在BulletConfig里加一个bulletCount字段,发射逻辑读到bulletCount = 2时多生成一颗偏移子弹即可。

3.2 对象池要池住哪几类对象

飞机大战的高频对象是子弹、敌机、拾取道具和爆炸特效。先写一个通用池子,节点类型用泛型约束,所有可复用对象共用同一套逻辑。常见做法如下:

import { instantiate, Node, Prefab } from 'cc'; export class ObjectPool<T extends Node> { private _pool: T[] = []; constructor(private _prefab: Prefab) { } get(parent: Node): T { let node = this._pool.pop(); if (!node) { node = instantiate(this._prefab) as T; } node.parent = parent; node.active = true; return node; } put(node: T) { node.active = false; node.removeFromParent(); this._pool.push(node); } clear() { for (const node of this._pool) { node.destroy(); } this._pool.length = 0; } }

代码里get(parent)parent是把节点挂到对应 Group 下,例如子弹挂到 bulletGroup,敌人挂到 enemyGroup,方便碰撞检测时按组遍历。put(node)先把节点隐藏并从场景树摘掉,再放回数组,这样微信小游戏渲染器不会再更新这个节点。clear()要在离开场景时调用,否则池中节点会残留到下一局。

对象池的误用集中在扩容上限和类型混淆。pop()返回 undefined 时就直接 instantiate,表面看没有上限,但高难度关卡里可能出现对象积压,比如子弹来不及回收,池子越滚越大。我一般会在池子上加maxKeep限制,回收时如果池长度超过maxKeep,多余节点直接destroy()。另一个典型错误是同一个池子塞进不同 prefab 的敌人,导致取出的节点类型不对,所以每个敌机类型最好单独维护一个池,或者用字典 key 来区分。

3.3 对象池与配置表如何配合

生成逻辑并不在对象池里,而是在各 Group 的spawn方法中。下面是一段敌机组生成伪代码:

public spawn(type: number) { const cfg = EnemyTable[type - 1]; const enemy = this._pools[type].get(this.node) as EnemyNode; enemy.init(cfg); enemy.node.setPosition(this.randomX(), this.topY); }

init(cfg)负责把配表里的 hp、speed、animClip 写到节点上,敌人自身不读取全局配置。子弹的init重点接收speeddamage,如果之后要加穿透效果,在BulletConfig里加一个pierce字段即可。UFO 的 effectId 指向一个技能表,main 层只关心它被打中时返回的 effectId,再决定给玩家加分、换子弹还是加护盾。数据驱动结构对“增加 UFO 种类”特别友好,新增配置项后,对象池和分组逻辑几乎不用动。

池名称复用对象回收时机
EnemyPool小/中/大型敌机出屏或 hp<=0
BulletPool玩家子弹、敌方子弹碰撞或超出边界
UfoPool道具机被击毁或飞离屏幕
EffectPool爆炸动画、得分飘字动画播放结束

4. main 控制与敌机组/子弹组/UFO 组:自上而下的任务拆分

4.1 主控节点负责状态,分组节点负责生产

README 里写着“结构上自上而下的控制,main 控制游戏主逻辑”,落成代码就是场景树里有一个Main节点,挂Main.ts;下面有三个分组子节点:EnemyGroupBulletGroupUfoGroup。每个组只管本组资源的生产、移动、越界判断和回收,不直接操作别的组。main 持有的不是具体对象,而是三个组的引用,以及当前分数、暂停状态、游戏结束状态。这样当策划要求“第 3 波 UFO 出现间隔缩短”时,改的只是UfoGroup.updateSpawn(dt)的参数,main 不用动。

我通常在Main.ts里写这样的主循环:

update(deltaTime: number) { if (this.isPaused || this.isGameOver) return; this.enemyGroup.updateSpawn(deltaTime); this.bulletGroup.updateSpawn(deltaTime); this.ufoGroup.updateSpawn(deltaTime); this.collisionManager.check(); }

这里的isPaused是 main 统一的暂停开关。微信小游戏切后台时引擎仍然会调update,如果不判断暂停,敌人和子弹会继续移动,切回来时玩家经常被秒杀。暂停处理一般放在startend按钮回调里,也可以监听Game.EVENT_HIDE事件:切后台自动暂停,切回前台弹继续按钮。

节点职责对外方法
Main状态、分数、暂停、碰撞调度pauseGame / resumeGame / addScore
EnemyGroup敌机生成、移动边界、回收updateSpawn / spawn / recycle
BulletGroup子弹发射、越界回收updateSpawn / fire / recycle
UfoGroupUFO 生成、效果结算updateSpawn / spawnUfo / recycle

4.2 敌机组:配置间隔与对象池结合

EnemyGroup的职责不是理解“该出什么敌机”,而是拿着EnemyTable按定时器生成。核心方法如下:

updateSpawn(dt: number) { this._spawnTimer += dt; const interval = EnemyTable[this._kindIndex].spawnInterval; if (this._spawnTimer >= interval) { this._spawnTimer = 0; this.spawn(this._kindIndex + 1); } }

_spawnTimer是累积时间,每帧叠加 dt,spawnInterval从配表读取。_kindIndex表示后续波次,比如第 30 秒后从第 1 类敌人切到第 2 类。注意spawnTimer不能简单用% interval代替,因为只要一帧掉到 0.1s 以下,取模会直接少生成一次;用累加再清零更稳。每次生成后还要检查对象池状态,如果池里空闲数量不够,生成逻辑会自动 instantiate,这在微信开发者工具里可以看到短暂掉帧,说明池容量设置过小。

4.3 子弹组与 UFO 组的协作方式

子弹组和 UFO 组逻辑类似,但生命周期终点不同。子弹直接检测是否超出屏幕上方,通过view.getVisibleSize()得到可视范围,超出后立即put回池;UFO 除了检测边界,还要被子弹打到。为了降低碰撞检测成本,一般先用分组节点的包围盒粗筛,只有同一帧同时活跃的子弹和敌机再进入精确检测。实现如下:

for (const bullet of this.bullets) { if (!bullet.active) continue; for (const enemy of this.enemies) { if (!enemy.active) continue; if (bullet.getBoundingBox().intersects(enemy.getBoundingBox())) { enemy.takeDamage(bullet.damage); bulletGroup.put(bullet); break; } } }

外层遍历子弹,内层遍历敌人,命中后break避免一颗子弹同时打穿多个敌人。getBoundingBox()返回节点世界包围盒,适合敌机数量不多的情况;当屏幕上子弹超过 100 颗时,矩形相交计算仍然有优化空间,可以把子弹按 Y 轴排序,只与前后两个区间内的敌人做检测。更常见的做法是改用圆形半径判断,给每个节点配置一个radius,用两圆心距离平方小于半径平方判定,省去矩阵计算。

4.4 暂停、继续与得分的外层事件

得分最好通过 main 暴露,不交给子弹或敌机各自存储。敌机被打死时调用main.addScore(score),由 main 统一刷新 UI,这样历史分数记录、成绩提交等后续逻辑都可以从这里接出去:

public addScore(score: number) { this._score += score; this.scoreLabel.string = this._score.toString(); }

暂停继续是对update的守卫。注意在 Cocos Creator 里,Animation组件不会自动跟随isPaused停止,所以暂停时还要遍历三个分组,将正在播放动画的节点临时暂停;否则敌机停住不动,螺旋桨和尾焰还在播,观感上像“时间静止但特效未静止”。微信小游戏平台上常见的做法是把暂停功能封装到GameManager,它同时控制director.pause()或自行记录时间差,不只是置一个布尔变量。

5. historyScore 本地存储:把历史最高分从 main.fire 接到 end.fire

5.1 用 cc.sys.localStorage 而不是裸用 wx 存储

飞机大战的成绩不需要服务端,本地存储足够。historyScore.fire场景专门用来展示历史最高分。存储 key 最好固定一个常量,例如plane_fight_history_score,读取和写入都走同一个工具模块,避免在多个场景里直接调用wx.setStorageSync造成 key 不一致。

const SCORE_KEY = 'plane_fight_history_score'; export function saveHistoryScore(score: number): number { const best = loadHistoryScore(); const newBest = score > best ? score : best; cc.sys.localStorage.setItem(SCORE_KEY, newBest.toString()); return newBest; } export function loadHistoryScore(): number { const value = cc.sys.localStorage.getItem(SCORE_KEY); if (value === null || value === undefined) return 0; const parsed = Number(value); return isNaN(parsed) ? 0 : parsed; }

cc.sys.localStorage在微信小游戏运行时会自动路由到wx存储接口,所以在编辑器预览、浏览器调试、微信开发者工具三种环境里行为一致。不要自己直接判断typeof wx !== 'undefined'wx.setStorageSync,否则以后要发布到原生平台又得改判断。如果真机上出现最高分读不出来,先确认调用的地方是在wx环境初始化完成后,不要在onLoad最早期读取。

5.2 多场景之间的成绩衔接

end.fireEndGame.ts里,游戏结束时调用saveHistoryScore(this.main.score),然后跳转historyScore.fire

director.loadScene('historyScore', () => { const historyNode = director.getScene().getChildByName('HistoryUI'); const label = historyNode.getChildByName('ScoreLabel').getComponent(Label); label.string = loadHistoryScore().toString(); });

注意loadScene回调是在场景初始化后执行的,此时getChildByName能找到节点。为了避免加载新场景时旧场景的节点在对象池中残留,跳转前必须clear()所有对象池。验证时用微信开发者工具的 Storage 面板查看plane_fight_history_score,重新打开游戏依然保留最高分。如果出现每次结束分数都被覆盖,检查saveHistoryScore是否把newBest写回,而不是直接写当前score。真机预览时,微信小游戏读取本地存储有短暂 I/O 延迟,建议在开场场景先调用一次loadHistoryScore()预热,避免从end.fire瞬间跳转到historyScore.fire时读到默认 0。如果跳转后场景点击无响应,多半是 Canvas 摄像机设置不一致,对照main.fire的 Canvas 检查Fit WidthFit Height即可。

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

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

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

立即咨询