news 2026/9/14 2:50:10

Cocos Creator打造微信飞机大战:从构建到性能优化全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cocos Creator打造微信飞机大战:从构建到性能优化全解析

简介:基于CocosCreator开发的微信经典飞机大战完整工程包,面向希望入门微信小游戏或学习Cocos Creator的小白与进阶学习者,可作毕设、课程设计或初期项目立项参考。项目采用数据驱动设计,敌机生成频率、移速、子弹频率等参数集中可调,扩展子弹或UFO种类只需少量改动;敌机、子弹、UFO均使用对象池复用,历史最高分通过本地存储保存。代码结构自上而下,main控制主逻辑与暂停、继续、计分,下分敌机组、子弹组、UFO组分别管理各自生成逻辑,便于理解游戏整体架构。压缩包共133个文件,约1.15MB,涵盖14个JS脚本、13个Prefab预制体、13个MP3音效、6个PNG贴图及6个Anim动画、4个Fire场景文件等,目录清晰可直接运行调试。已有227人学习下载,适合动手复刻经典玩法并梳理对象池与模块化分组设计思路。

1. 为什么把微信经典飞机大战放在 Cocos Creator 里做

“微信经典飞机大战”玩过的人多,见过实现的人少。它最值钱的地方不是敌机美术,而是把游戏循环、触控、碰撞、计分和微信环境约束放在同一个工程里跑通。Cocos Creator 做这种事有先天优势:组件化脚本管理飞机、子弹和敌机,构建面板直接导出微信小游戏格式,省去手工桥接微信 API,生命周期和触摸事件又被引擎封装得和浏览器差异不大。这篇文章从一个空白项目讲起,一直跑到能真机玩的飞机大战,中途把微信小游戏生命周期、触控事件、包体控制和渲染排查这些容易踩坑的点全摊开,适合已经能写 TypeScript、不想把时间耗在微信底层上的开发者。读完你可以按照同样的套路搭出小游戏完整链路,而不是只会摆场景。

2. 搭好 Cocos Creator 微信小游戏构建基线

2.1 新建 2D 项目并配置微信开发者工具

常见做法是 Cocos Creator 3.x 新建一个 2D 项目,模板选空工程。太花哨的模板会带一堆无关组件,后边删起来麻烦。项目创建后第一件事不是写脚本,而是把微信开发者工具的路径配给 Creator:进入偏好设置里的外部程序,选择微信开发者工具安装目录下的可执行文件。这样构建完成后点“运行预览”就能直接拉起微信开发者工具,省去每次手动导入的重复操作。

微信小游戏需要一个 AppID。没有正式 AppID 时,可以在微信公众平台申请测试号,也可以先在开发者工具里选择测试号模式。Cocos Creator 构建面板中有appid输入框,填错了会在开发者工具导入环节直接报错。另一个容易忽略的选项是deviceOrientation,飞机大战通常竖屏,如果选成横屏,真机上画面会被旋转,UI 适配全部乱掉。相机、按钮、碰撞盒都会跟着方向变化,后面排查起来很花时间。

在构建发布面板选择微信小游戏平台后,能看到一批影响运行表现的参数。下表是飞机大战项目里最常见的几项:

构建参数推荐设置作用
appid你的微信小游戏 AppID决定开发者工具是否识别成正式项目
deviceOrientationportrait锁定竖屏,避免 UI 布局偏移
md5Cache开启资源文件名带 MD5,CDN 刷新时不会被浏览器缓存卡住
startScenemain.scene指定启动场景,首包之外的场景按需加载
分离主包开启官方分包机制,保证业务代码在主包 4MB 以内

md5Cache一般建议开,尤其后边把资源放到远程服务器时。它会让构建产物里每个资源名后面多一段哈希,文件名变了,CDN 和微信缓存策略就不会把旧资源返回给你。startScene一定要指向用来做启动入口的场景,飞机大战里通常指向主菜单场景,而不是游戏主场景,这样首屏加载更快,玩家进入到战斗场景时再加载子弹、音效和其他资源。

2.2 用命令行构建并在微信开发者工具里跑通首屏

Cocos Creator 的图形界面构建适合手动调试,但多次验证时我更乐意写一个构建脚本,把平台参数固化下来。命令行构建的方式在不同 3.x 版本里略有差异,常见形态是给 Creator 主程序传--project--build两个参数:

#!/usr/bin/env bash # 请把路径换成你本机实际安装路径 CREATOR="/Applications/CocosCreator/Creator/3.8.0/CocosCreator.app/Contents/MacOS/CocosCreator" PROJECT="/Users/you/plane-war" BUILD_PARAM="platform=wechatgame;debug=true;md5Cache=true" "$CREATOR" --project "$PROJECT" --build "$BUILD_PARAM" # 构建完成后用微信开发者工具打开产物目录 open -a wechatwebdevtools "$PROJECT/build/wechatgame"

这段脚本先把平台锁成wechatgame,debug 模式开启,资源 MD5 打开,然后把构建产物直接交给微信开发者工具。open -a wechatwebdevtools是 macOS 的命令行打开方式,Windows 上换成start 路径,或者手动导入都行。参数里没写 appid,因为 appid 属于项目级配置,通常已经写在本地的 project.json 或 settings 中,命令行构建只管平台相关参数。

构建完成后注意看build/wechatgame目录,里面会有game.jsapplication.jssrc等文件,这是 Cocos Creator 生成的微信小游戏入口。微信开发者工具导入时选整个目录,而不是选里面的某个 js 文件。首次打开能看到模拟器跑起主场景。这里有个新手常踩的坑:界面一直停在微信原生加载状态,不一定是代码问题,可能是主包资源太大。微信对加载阶段有严格限制,首屏只放启动必需资源,其他场景、音效、大图全部放分包或远程资源。

2.3 首屏加载由微信控制,Cocos 只负责缩短等待

很多人在微信小游戏里看到加载界面很久,就去找 Cocos Creator 的设置,其实加载样式是微信客户端原生绘制的,Cocos 不直接控制。能在项目侧做的是把启动场景依赖的资源压缩、分包、用远程 Bundle。微信开发者工具打开构建产物后,性能面板里的内存占用和耗时两栏,会直接反映出包体优化效果。如果发现首屏加载时间超过三秒,优先检查build/wechatgame里的assets目录是否躺着几十张未合图的 PNG。把敌机和子弹做成同一张图集,是降低启动加载时间成本最低的办法。

3. 飞机大战核心玩法:预制体、碰撞与对象池

3.1 用场景结构把玩家、子弹、敌机分层

飞机大战的场景结构不宜全部堆在 Canvas 下。常见布局是 Canvas 下挂四个节点:GameManagerPlayerBulletLayerEnemyLayerGameManager挂全局脚本,负责生成敌人、计分和控制游戏状态;Player挂玩家控制脚本和碰撞组件;BulletLayerEnemyLayer分别充当子弹和敌机的容器。这样分的理由是:UI 节点层级决定渲染顺序,敌人要在子弹下层,否则子弹打到敌机时会画在敌机上面,视觉上像穿过身体。节点分层明确后,对象池和碰撞回调解耦也容易,不会在删除节点时把其他数据一并删掉。

创建子弹和敌机前,先准备 Prefab。子弹预制体上挂SpriteRigidbody2DBoxCollider2D,脚本用Bullet;敌机预制体上挂同样组件,脚本用Enemy。Rigidbody2D 类型建议选 Kinematic,飞行方向由代码控制,不受重力影响,也不会被别的碰撞弹飞,碰撞检测本身仍由物理引擎负责。物理碰撞还需要打开 2D 物理系统,PhysicsSystem2D.instance.enable = true可以放在 GameManager 的start()里,也可以在 Project Settings 里直接勾选。

接下来是碰撞矩阵,飞机大战里只有三类物体需要互相作用:

碰撞组 A碰撞组 B允许碰撞说明
PLAYERENEMY玩家被撞,扣血或游戏结束
BULLETENEMY子弹击中敌机,加分并消灭
BULLETPLAYER避免误伤
ENEMYENEMY敌机之间不互相阻挡

碰撞矩阵配置错了最典型的症状是子弹打不到敌机,或者玩家飞机路过敌机时直接穿透。只要发现某一对碰撞没生效,先去矩阵里看对应分组有没有勾选,而不是改物理参数。

3.2 子弹与敌机的组件化脚本怎么写

先写子弹脚本。飞机大战里子弹是高频生成对象,所以脚本里最需要注意的是开关监听和销毁时机。onEnable注册碰撞回调,onDisable注销,避免预制体被对象池回收后还响应旧的碰撞事件:

// Bullet.ts import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc'; const { ccclass } = _decorator; @ccclass('Bullet') export class Bullet extends Component { private speed = 900; onEnable() { const collider = this.getComponent(Collider2D); collider?.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } onDisable() { const collider = this.getComponent(Collider2D); collider?.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } update(dt: number) { this.node.setPosition( this.node.position.x, this.node.position.y + this.speed * dt ); // 飞出屏幕后让管理层回收,而不是直接 destroy if (this.node.position.y > 800) { this.node.active = false; } } private onBeginContact(self: Collider2D, other: Collider2D, contact: IPhysics2DContact) { GameManager.instance.addScore(10); this.node.active = false; } }

这段代码有两个关键点:update(dt)里速度乘以dt,保证不同帧率下移动距离一致;onBeginContact不直接destroy,而是把节点隐藏,等待对象池复用。隐藏比销毁省性能,尤其微信小游戏在低端机上频繁实例化会明显卡顿。位移用setPosition而不是node.position +=position属性每次读取会做值拷贝,高频更新时损耗能感觉到。

敌机脚本对称,但移动方向相反,同时要把碰撞结果交给 GameManager 统一处理:

// Enemy.ts import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc'; const { ccclass, property } = _decorator; @ccclass('Enemy') export class Enemy extends Component { @property({ tooltip: '敌机下落速度' }) speed = 160; onEnable() { const collider = this.getComponent(Collider2D); collider?.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } onDisable() { const collider = this.getComponent(Collider2D); collider?.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this); } update(dt: number) { this.node.setPosition(this.node.position.x, this.node.position.y - this.speed * dt); if (this.node.position.y < -800) { this.node.active = false; } } private onBeginContact(self: Collider2D, other: Collider2D, contact: IPhysics2DContact) { // 撞到玩家由 GameManager 统一扣血,这里不写逻辑 this.node.active = false; } }

两个脚本都依赖预制体身上挂了Collider2D,否则getComponent(Collider2D)拿到 null,监听注册失败,碰撞事件不会触发。遇到“子弹从敌机里穿过去”的 bug,先检查这两个组件,再看碰撞矩阵里的组别。

3.3 用 GameManager 接收碰撞结果并刷新分数

碰撞只在子弹和敌机脚本里各自隐藏自己,分数、血量这类全局状态应当收口到一个地方。常见做法是写一个 GameManager 单例,初始化时保存对 UI Label 的引用,其他脚本通过静态实例调用:

// GameManager.ts 片段 import { _decorator, Component, Label } from 'cc'; const { ccclass, property } = _decorator; @ccclass('GameManager') export class GameManager extends Component { static instance: GameManager = null!; @property(Label) scoreLabel: Label = null!; private score = 0; onLoad() { GameManager.instance = this; } addScore(n: number) { this.score += n; this.scoreLabel.string = `得分 ${this.score}`; } }

这样做的边界很清晰:Bullet管不了敌人和分数,Enemy也管不了玩家血量,所有跨对象操作都通过 GameManager 转发。后续如果要加连击、关卡速度、最高分存档,只需要扩展这一个类,不需要再去翻子弹和敌机脚本。

3.4 用简单对象池扛住敌机和子弹的刷新频率

飞机大战满屏子弹时,单纯靠instantiatedestroy会频繁触发节点创建和销毁,微信小游戏里这类操作抽帧很严重。对象池的写法不复杂,维护一个数组,生成时先看池里有没有闲置节点,有就激活复用,没有再实例化;回收时不是真删,而是隐藏并推回数组:

// ObjectPool.ts import { Node, Prefab, instantiate } from 'cc'; export class ObjectPool { private pool: Node[] = []; constructor(private prefab: Prefab) {} get(parent: Node): Node { const node = this.pool.pop(); if (node) { node.setParent(parent); node.active = true; } else { const n = instantiate(this.prefab); n.setParent(parent); return n; } return node!; } put(node: Node) { node.removeFromParent(); node.active = false; this.pool.push(node); } }

get方法里对复用节点重新setParent,是因为put已经把节点移出了场景树。active = false会让节点脱离渲染和 update,但对象仍然留在内存里,下次用的时候直接复活。飞机大战里上千次发射也不需要频繁扩容,池子里节点数会稳定在峰值附近。使用对象池后,UI 分数计数和音效播放要绕开被隐藏的节点,不要在这些组件里持有已隐藏节点的引用,否则容易出现空引用错误。

4. 微信小游戏生命周期、触控与性能适配

4.1 从浏览器惯性里跳出来,处理微信的 show 和 hide

做网页游戏时,页面隐藏和恢复由浏览器 Tab 管理,Cocos Creator 的引擎循环跟着visibilitychange自动处理。微信小游戏不一样,切后台再回来可能存在两种路径:一次“show/hide”事件,或者进程被系统回收后整个小游戏重新启动。飞机大战如果没有任何存档逻辑,重启会直接丢分数。所以必须监听微信小游戏的生命周期,不能指望引擎自动把状态存好。

Cocos Creator 3.x 会把微信的相关事件抽象成Game.EVENT_HIDEGame.EVENT_SHOW。典型做法是在游戏管理脚本中注册这两个事件,隐藏时暂停director,显示时恢复并让玩家选择续命或重新开始:

import { director, Game, game, _decorator, Component } from 'cc'; const { ccclass } = _decorator; @ccclass('LifecycleHandler') export class LifecycleHandler extends Component { onEnable() { game.on(Game.EVENT_HIDE, this.onHide, this); game.on(Game.EVENT_SHOW, this.onShow, this); } onDisable() { game.off(Game.EVENT_HIDE, this.onHide, this); game.off(Game.EVENT_SHOW, this.onShow, this); } private onHide() { director.pause(); wx.setStorageSync('planeWarScore', GameManager.instance.getScore()); } private onShow() { director.resume(); director.getScene()?.emit('game-resume'); } }

director.pause()会把整个场景更新停住,触控和音频异步回调还会继续,但主循环不会跑。注意不要在onHide里弹微信原生对话框,微信对这类 API 有次数限制,弹窗时机不好会被系统直接吞掉。如果小游戏耗时较长,还应该在onHide时调用wx.setStorageSync保存分数,避免进程被杀后存档丢失。

4.2 触控绑定在 Canvas 上,用 UICoord 做位置换算

微信小游戏的触摸事件本质上是经过微信层转发的一批 touch 事件,Cocos Creator 封装成了Node.EventType.TOUCH_STARTTOUCH_MOVE。飞机大战最自然的操控是手指按到哪里,飞机就移动到哪里,只监听TOUCH_MOVE也行,但第一次触到屏幕时也要更新飞机位置,否则手指已经按住屏幕但飞机还停着:

import { _decorator, Component, Node, EventTouch } from 'cc'; const { ccclass, property } = _decorator; @ccclass('TouchMove') export class TouchMove extends Component { @property(Node) player: Node = null!; onEnable() { this.node.on(Node.EventType.TOUCH_START, this.onTouch, this); this.node.on(Node.EventType.TOUCH_MOVE, this.onTouch, this); } onDisable() { this.node.off(Node.EventType.TOUCH_START, this.onTouch, this); this.node.off(Node.EventType.TOUCH_MOVE, this.onTouch, this); } private onTouch(e: EventTouch) { const uiPos = e.getUILocation(); // UI 坐标原点在左下角,y 向上,和 Canvas 2D 排版一致 this.player.setPosition(uiPos.x, uiPos.y); } }

这段代码挂在 Canvas 节点或一个铺满全屏的空白节点上,不要挂在飞机上。挂在飞机上会导致移动事件被飞机自己消费,拖动时事件目标频繁切换,位置跳变。getUILocation()返回的是 UI 坐标,不是屏幕物理坐标;微信开发者工具模拟器里两者一致,但到了真机有刘海屏和安全区,一定要用 UI 坐标,不要直接用getLocation()。给玩家飞机加刚体后,如果每帧用setPosition移动它,同时物理引擎也在同步刚体变换,两者会打架,表现为飞机抖动。飞机大战里玩家飞机可以不参与物理碰撞,视觉上手动移动,碰撞检测单独用一个挂在玩家节点下的BoxCollider2D并标记为 Sensor,手感更稳。

4.3 包体与渲染性能:主包做小,DrawCall 做少

微信小游戏普通包限制远小于独立 App,Cocos Creator 构建后如果直接把美术资源全部塞进去,很容易超限。飞机大战里敌机精灵图可以用图集工具合成一张,减少文件数,也减少 DrawCall。音乐优先用短音效的 m4a 格式,背景音乐如果超过几百 KB,放到远程资源服务器按需下载。

优化项具体做法效果
合图把子弹、敌机、玩家飞机打成一个图集DrawCall 从 10+ 降到 2~3
纹理格式微信小游戏按机型选择纹理压缩格式减少 GPU 显存占用
对象池子弹和敌机复用节点避免 GC 卡顿,稳定帧率
分包非核心场景挂到 subpackage 目录启动包小于 4MB,首屏更快
音频音频文件放远程 Bundle,按需加载减少包体,加快启动

性能调优时先在微信开发者工具的“性能”面板看主循环耗时和内存曲线,不要凭感觉改。飞机大战里最容易出问题的反而是背景滚动:大尺寸背景图直接铺在 UI 层会占满带宽,常见做法是把背景切成上下两段,循环往下滚,用代码判断位置超出屏幕后重新排到另一段下面,这样只需要两张 Sprite,DrawCall 增加不大,视觉上也没有断裂。

4.4 音频在微信里不能一进场景就播

微信小游戏对音频有自动播放限制,用户没有触摸屏幕前,音频播放会被系统拦截。飞机大战的 BGM 不应该放在场景onLoad里直接播,常见做法是等玩家第一次触发TOUCH_START之后,再调用AudioSource.play()。这也是为什么很多模板会把“点击任意位置开始”做成真正的首屏交互,而不只是视觉装饰。代码可以放在 TouchMove 的回调里,用标志位保证只执行一次。如果还想处理手机静音键,可以调用微信的wx.setInnerAudioOptionobeyMuteSwitch设为 false,但这会让游戏声音在用户静音时仍播放,具体取舍看需求。

5. 用 Camera TargetTexture 排查飞机大战真机渲染问题

真机上飞机、子弹都看不见,在微信开发者工具里却一切正常,这类问题最让人头疼。常见原因是资源加载失败或渲染管线没同步,看控制台只有一堆无意义的 404。我一般会加一个“渲染回显”开关,用相机把实际渲染内容写进一张 RenderTexture,再贴到屏幕角落的 Sprite 上。真机上直接对比回显图,能快速分辨是节点没生成还是视觉层被遮住,这套思路在飞机大战这种大量 Sprite 的场景里尤其管用。

在 Cocos Creator 里操作分三步:先在资源管理器中新建一个 RenderTexture,再创建一个独立调试相机,让相机的targetTexture指向它,最后把这个 RenderTexture 赋给一个 Sprite 的spriteFrame。代码里改动也就几行:

import { Camera, RenderTexture, Sprite, Texture2D, _decorator, Component } from 'cc'; const { ccclass, property } = _decorator; @ccclass('DebugMirror') export class DebugMirror extends Component { @property(Camera) mirrorCamera: Camera = null!; @property(RenderTexture) rt: RenderTexture = null!; @property(Sprite) mirrorSprite: Sprite = null!; start() { if (!this.mirrorCamera) return; this.rt = new RenderTexture(); this.rt.reset({ width: 256, height: 512, format: Texture2D.PixelFormat.RGBA8888 }); this.mirrorCamera.targetTexture = this.rt; const sf = this.rt.createSpriteFrame(); this.mirrorSprite.spriteFrame = sf; } }

注意new RenderTexture()之后要手动调用reset指定尺寸,否则默认是 1x1,贴出来全是糊的。createSpriteFrame()每次调用会生成一个新的 SpriteFrame 实例,调用太多次会叠加内存,只初始化一次,后续复用同一个sf就好。在飞机大战里,这个调试相机可以专门拍BulletLayerEnemyLayer两个层,把碰撞体调试颜色打开后贴上,真机上就能看到子弹是否真的和敌机发生了接触,还是单纯因为资源没加载所以视觉上“穿模”。

上线前把调试开关用wx.getSystemInfoSync().platform判断,真机上默认关闭,避免多一次相机渲染反而掩盖性能问题。回显确认是相机相关后分两类处理:一类是相机矩阵不对,把主相机projection改成 ORTHO,按 Canvas 分辨率校正orthoHeight;另一类是动态资源没加载,回显里精灵空白但节点在场景树中,去检查远程资源 Bundle 的加载回调。这样一个飞机大战部署到真机后,再遇到黑屏就能直接定位是微信侧问题还是渲染侧问题,再顺着回显去排查,比盲改代码有效得多。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 2:49:46

Python+OpenCV人脸识别签到系统:客户端服务端架构与工程实践

简介&#xff1a;一套基于Python与OpenCV的人脸识别签到管理系统完整源码&#xff0c;面向毕业设计、期末大作业及课设实践&#xff0c;适合需要快速搭建人脸考勤项目并学习客户端与服务端双重架构的开发者。系统功能覆盖人脸注册、实时检测、身份识别、签到记录管理&#xff0…

作者头像 李华
网站建设 2026/9/14 2:49:39

VTK医学影像三维重建实战:从DICOM到STL临床级流程

简介&#xff1a;本资源是一个基于VTK的医学影像三维重建完整实践项目&#xff0c;面向医学图像处理初学者、计算机视觉开发者及生物医学工程相关专业学生&#xff0c;解决从DICOM数据读取、预处理、分割到三维可视化的一整套技术落地问题。压缩包共318个文件&#xff0c;含10个…

作者头像 李华
网站建设 2026/9/14 2:49:35

C#人脸识别考勤系统开发实战:从选型到语音播报

简介&#xff1a;C#人脸识别考勤系统完整源码&#xff0c;内置语音播报&#xff0c;面向C#开发者、计算机专业学生及需要快速落地考勤系统的技术团队。项目将人脸识别、USB摄像头采集、考勤时段控制与TTS语音反馈整合于一体&#xff0c;并提供用户界面交互&#xff0c;能有效提…

作者头像 李华
网站建设 2026/9/14 2:49:16

SpringBoot点餐推荐系统实战:Slope One与协同过滤算法融合

简介&#xff1a;一款基于Spring Boot的智能推荐点餐系统设计与实现完整项目&#xff0c;适合正在学习Spring Boot整合开发、推荐算法落地及餐饮系统设计的开发者。项目采用前后端分离架构&#xff0c;业务逻辑涵盖登录、点餐、支付等核心流程&#xff0c;并利用协同过滤或基于…

作者头像 李华
网站建设 2026/9/14 2:47:42

STM32驱动DS1302实时时钟:GPIO模拟时序从零实现

1. 项目背景与整体设计思路 做嵌入式开发的同学&#xff0c;几乎都会遇到需要给设备加一个“时间戳”的场景。不管是做数据采集器、智能家居网关&#xff0c;还是毕业设计里的电子时钟&#xff0c;都绕不开实时时钟&#xff08;RTC&#xff09;这颗小芯片。市面上常见的RTC方案…

作者头像 李华