尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微信小游戏飞机大战:Cocos Creator中动画资源与对象池的工程实践

微信小游戏飞机大战:Cocos Creator中动画资源与对象池的工程实践 简介基于Cocos Creator开发的微信经典飞机大战完整项目源码面向希望从零接触游戏开发的小白或需要完成毕设、课程设计、工程实训的进阶学习者。项目以数据驱动方式组织敌机生成频率、子弹速度等参数方便扩展子弹种类与不明飞行物UFO类型并利用对象池管理敌机、子弹和道具减少运行开销历史最高分通过本地存储实现采用自上而下的控制结构由主控模块统一管理暂停、继续与得分下设敌机组、子弹组、道具组管理组员生成逻辑。压缩包共133个文件主要包括脚本、预制体、音效、动画、JSON配置和场景文件辅以图片资源整体约1.15MB结构紧凑、目录清晰适合对照工程学习。已有227人学习/下载包含完整项目目录与核心代码可直接运行调试便于二次开发也能参考其对象池、数据驱动和本地存储的实现思路。1. 为什么微信小游戏版飞机大战要单独剥离 .anim 和对象池移动端跑飞机大战最怕的不是飞机画得丑而是弹幕一多就卡。微信经典飞机大战用 Cocos Creator 重制时资源文件里出现了一大批散装的 .anim 和 .firehero_blowup_ani.anim、enemy1ani.anim 到 enemy3ani.anim、main.fire、end.fire、historyScore.fire。这套工程把动画表现、场景入口、逻辑控制拆得比较开同时用数据驱动参数表控制生成频率、速度和道具类型敌机、子弹、UFO 都走了对象池。新手拿它当课程设计能学到的不只是“写一个飞机大战”而是资源组织方式和内存回收思路有经验的人则可以重点看它是怎么用分组和配置降低耦合的。下面先从 .anim 这个最容易忽视的细节讲起。2. .anim 动画资源与场景拆分先搞清资源归属再写代码2.1 .anim 与 .fire 在 Cocos Creator 工程里的角色main.fire、start.fire、end.fire、historyScore.fire都是场景文件而game_loading.anim、hero.anim、hero_blowup_ani.anim、enemy1ani.anim、enemy2ani.anim、enemy3ani.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 用同样的思路处理子弹的关键差异在damage、flySpeed、shootIntervalUFO 的关键差异在效果类型和持续时间。例如增加一种“双发子弹”道具只需要在BulletConfig里加一个bulletCount字段发射逻辑读到bulletCount 2时多生成一颗偏移子弹即可。3.2 对象池要池住哪几类对象飞机大战的高频对象是子弹、敌机、拾取道具和爆炸特效。先写一个通用池子节点类型用泛型约束所有可复用对象共用同一套逻辑。常见做法如下import { instantiate, Node, Prefab } from cc; export class ObjectPoolT 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重点接收speed和damage如果之后要加穿透效果在BulletConfig里加一个pierce字段即可。UFO 的 effectId 指向一个技能表main 层只关心它被打中时返回的 effectId再决定给玩家加分、换子弹还是加护盾。数据驱动结构对“增加 UFO 种类”特别友好新增配置项后对象池和分组逻辑几乎不用动。池名称复用对象回收时机EnemyPool小/中/大型敌机出屏或 hp0BulletPool玩家子弹、敌方子弹碰撞或超出边界UfoPool道具机被击毁或飞离屏幕EffectPool爆炸动画、得分飘字动画播放结束4. main 控制与敌机组/子弹组/UFO 组自上而下的任务拆分4.1 主控节点负责状态分组节点负责生产README 里写着“结构上自上而下的控制main 控制游戏主逻辑”落成代码就是场景树里有一个Main节点挂Main.ts下面有三个分组子节点EnemyGroup、BulletGroup、UfoGroup。每个组只管本组资源的生产、移动、越界判断和回收不直接操作别的组。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如果不判断暂停敌人和子弹会继续移动切回来时玩家经常被秒杀。暂停处理一般放在start和end按钮回调里也可以监听Game.EVENT_HIDE事件切后台自动暂停切回前台弹继续按钮。节点职责对外方法Main状态、分数、暂停、碰撞调度pauseGame / resumeGame / addScoreEnemyGroup敌机生成、移动边界、回收updateSpawn / spawn / recycleBulletGroup子弹发射、越界回收updateSpawn / fire / recycleUfoGroupUFO 生成、效果结算updateSpawn / spawnUfo / recycle4.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是累积时间每帧叠加 dtspawnInterval从配表读取。_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.fire5.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.fire的EndGame.ts里游戏结束时调用saveHistoryScore(this.main.score)然后跳转historyScore.firedirector.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 Width和Fit Height即可。本文还有配套的精品资源点击获取
返回列表