飞机大战
序章
最近在学习cocos creator, 教程常常是cocos creator 2.4.x, 3.x API很多发生变化, 故记录一下使用3.x练习实现过程。如果对您有用,倍感荣幸。
习作: 经典的微信小游戏飞机大战
软件: cocos creator 3.8.0
开源地址: https://gitee.com/depingl/cc_plane_war.git
体验地址:https://planewar.lisi.fun/
设置背景和音乐
背景
使用两张图片, 循环向下, 实现背景图图片切换
有一个技巧,将两张背景图放在一个空节点下,循环遍历两个背景图片, 交替下移
刚开始我直接在下侧图片溢出可视区域后, 重新设置位置到上方, 偶尔会出现黑边, 即两个图片之间出现缝隙。这是由于屏幕刷新率的不确定性导致的,直接在图片坐标上加最好。
update(deltaTime: number) {
// console.log('update ...')
this.bgMove(deltaTime)
}
bgMove(dt: number) {
for (const bgNode of this.node.children) {
// console.log(bgNode.position.y);
// bgNode.position.set(0, bgNode.position.y - 50 * dt, 0)
let p= bgNode.getPosition()
p.y -= 50 * dt * 5;
if(p.y <= -850) {
// 使背景图片网上移动拼接 比直接重新设置位置更好, 直接设置可能出现黑边
p.y += 852 * 2
}
bgNode.setPosition(p)
}
}
背景音乐
背景音乐可以直接在背景图片组件上添加AudioSource
组件实现,也可以用代码实现,不过为了后续可以添加设置功能,控制背景音乐,建议使用代码的方式控制。
官方提供了一个音频播放管理器示例可以直接使用:
//AudioMgr.ts
import { Node, AudioSource, AudioClip, resources, director } from 'cc';
/**
* @en
* this is a sington class for audio play, can be easily called from anywhere in you project.
* @zh
* 这是一个用于播放音频的单件类,可以很方便地在项目的任何地方调用。
*/
export class AudioMgr {
private static _inst: AudioMgr;
public static get inst(): AudioMgr {
if (this._inst == null) {
this._inst = new AudioMgr();
}
return this._inst;
}
private _audioSource: AudioSource;
constructor() {
//@en create a node as audioMgr
//@zh 创建一个节点作为 audioMgr
let audioMgr = new Node();
audioMgr.name = '__audioMgr__';
//@en add to the scene.
//@zh 添加节点到场景
director.getScene().addChild(audioMgr);
//@en make it as a persistent node, so it won't be destroied when scene change.
//@zh 标记为常驻节点,这样场景切换的时候就不会被销毁了
director.addPersistRootNode(audioMgr);
//@en add AudioSource componrnt to play audios.
//@zh 添加 AudioSource 组件,用于播放音频。
this._audioSource = audioMgr.addComponent(AudioSource);
}
public get audioSource() {
return this._audioSource;
}
/**
* @en
* play short audio, such as strikes,explosions
* @zh
* 播放短音频,比如 打击音效,爆炸音效等
* @param sound clip or url for the audio
* @param volume
*/
playOneShot(sound: AudioClip | string, volume: number = 1.0) {
if (sound instanceof AudioClip) {
this._audioSource.playOneShot(sound, volume);
}
else {
resources.load(sound, (err, clip: AudioClip) => {
if (err) {
console.log(err);
}
else {
this._audioSource.playOneShot(clip, volume);
}
});
}
}
/**
* @en
* play long audio, such as the bg music
* @zh
* 播放长音频,比如 背景音乐
* @param sound clip or url for the sound
* @param volume
*/
play(sound: AudioClip | string, volume: number = 1.0, loop: boolean = true) {
if (sound instanceof AudioClip) {
this._audioSource.clip = sound;
this._audioSource.play();
this.audioSource.volume = volume;
this.audioSource.loop = loop;
}
else {
resources.load(sound, (err, clip: AudioClip) => {
if (err) {
console.log(err);
}
else {
this._audioSource.clip = clip;
this._audioSource.play();
this.audioSource.volume = volume;
this.audioSource.loop = loop;
}
});
}
}
/**
* stop the audio play
*/
stop() {
this._audioSource.stop();
}
/**
* pause the audio play
*/
pause() {
this._audioSource.pause();
}
/**
* resume the audio play
*/
resume() {
this._audioSource.play();
}
}
在背景图片控制脚本中调用:
@property(AudioClip)
bgAudio: AudioClip = null;
// 音频管理器
audioMgr: AudioMgr = null;
start() {
console.log('开始了..')
// 播放背景音乐
this.audioMgr = AudioMgr.inst;
this.audioMgr.play(this.bgAudio, 0.6)
}
英雄就位
添加英雄节点,设置图片和脚本。英雄随指尖移动,通过监听事件实现:
// 移动
heroMove() {
input.on(Input.EventType.TOUCH_MOVE, this.heroMoveEvent, this)
}
// 英雄移动逻辑
heroMoveEvent(event: EventTouch) {
// 英雄的位置就是移动的位置
let p: Vec2 = event.getUILocation();
console.log(p);
// 加一个判断, 飞机边缘也不能溢出屏幕, 这有一个问题, 敌机出现位置不应在英雄射击盲区
if(p.x < this.nodeUI.width / 2) {
p.x = this.nodeUI.width / 2
}else if(p.x > this.viewSize.width - this.nodeUI.width / 2) {
p.x = this.viewSize.width - this.nodeUI.width / 2
}
if(p.y < this.nodeUI.height / 2) {
p.y = this.nodeUI.height / 2
}else if(p.y > this.viewSize.height - this.nodeUI.height / 2) {
p.y = this.viewSize.height - this.nodeUI.height / 2
}
this.node.setWorldPosition(new Vec3(p.x, p.y))
}
注意:
- 这里加了一个判断, 使英雄不会部分躯干溢出屏幕
- 坐标系问题, 监听事件返回的是UI坐标, 需要使用
setWorldPosition
设置位置
英雄发射子弹
子弹通过预制体生成。
关于子弹的逻辑:
- 在英雄正前方周期生成
- 生成时播放发射音乐
- 向上飞行,超出可视区域销毁
- 碰到敌机,销毁,并通知敌机发生碰撞
2 3 4显然是子弹的行为,1是英雄行为,所以脚本逻辑写在对应组件上。新建子弹脚本Bullet.ts
:
生成时播放发射音乐
我们使用背景音乐播放中用的AudioMgr
来播放:
// Bullet.ts
// 音频路径
_shootAudio: string = 'audio/shot';
start() {
this.playShootAudio()
}
/**
* 播放发射音频
*/
playShootAudio() {
AudioMgr.inst.playOneShot(this._shootAudio)
}
向上飞行,超出可视区域销毁
update(deltaTime: number) {
this.fly(deltaTime)
}
/**
* 子弹飞行
*/
fly(dt: number) {
let p = this.node.getPosition()
p.y += dt * 800;
this.node.setPosition(p)
if (p.y > 450) {
// console.log('子弹销毁!!');
this.node.destroy()
}
}
碰到敌机,销毁,并通知敌机发生碰撞
这里需要用到碰撞系统。
- 可以碰撞的物体必须是刚体组件和碰撞体组件, 本例要将重力设置为0, 防止敌机和子弹受到重力影响
- 通过tag判断碰撞体分组
- 在碰撞回调中处理碰撞结果逻辑:
- 子弹销毁
- 敌机死亡
// Bullet.ts
start() {
// 注册碰撞回调方法
let collider = this.getComponent(Collider2D)
if (collider) {
console.log('开启碰撞...');
collider.on(Contact2DType.BEGIN_CONTACT, this.beginContact, this)
}
}
// 死亡
die() {
console.log('子弹销毁...');
this.node.destroy()
}
// 碰撞检测方法
/**
* 子弹 1
* 敌机 2
*/
beginContact(selfCollider: Collider2D, otherCollider: Collider2D) {
// 敌机
if (otherCollider.tag == 2) {
// 必须使用director.once延迟销毁
director.once(Director.EVENT_AFTER_PHYSICS, ()=> {
// 销毁自身 即 子弹
selfCollider.node.destroy()
console.log(otherCollider);
// otherCollider.node.destroy()
// 调用敌机销毁方法
otherCollider.node.getComponent(EnemyControl).die()
})
}
}
本例要将重力设置为0, 防止敌机和子弹受到重力影响:在背景脚本中添加:
start() {
console.log('开始了..')
// 开启物理系统
PhysicsSystem2D.instance.enable = true
// 重力为0
PhysicsSystem2D.instance.gravity = v2()
}
英雄周期生成子弹
将上方字段作为预制体, 在英雄脚本中添加:
start() {
// 发射子弹
this.shot()
}
// 周期发射子弹
shot() {
setInterval(() => {
console.log('create a bullet');
let myBullet = instantiate(this.bulletPrefab)
myBullet.setParent(director.getScene().getChildByName('Canvas'))
// director.getScene().addChild(myBullet)
let nodePos = this.node.getPosition()
myBullet.setPosition(new Vec3(nodePos.x, nodePos.y + this.nodeUI.height / 2))
}, 400)
}
敌机登场
-
敌机自上而下滑行, 超出屏幕下方时自动销毁
-
敌机被子弹碰撞后销毁,销毁时播放爆炸声和爆炸动画
-
敌机与英雄碰撞杀死英雄, 减少游戏机会或游戏结束
-
敌机有多种类型:
- 更大的飞机可以承受更多子弹
- 更大的飞机可以获取更多的分数
-
敌机随机出现, 出现位置在可视区域稍上方
飞行和自动销毁
敌机脚本:
update(deltaTime: number) {
// 向下移动
this.move(deltaTime)
}
move(dt: number) {
let p = this.node.getPosition()
p.y -= dt * 50 * 4
this.node.setPosition(new Vec3(p.x, p.y))
if(p.y < -450) {
this.node.destroy()
}
}
敌机被子弹碰撞后销毁
此处碰撞逻辑由子弹完成, 只用写一个死亡脚本。
// 死亡方法
die() {
this._isActive = false
// 播放击中死亡音乐
AudioMgr.inst.playOneShot('audio/boom')
// 显示死亡图片
resources.load('enemy0_die/spriteFrame', SpriteFrame, (err, asset) => {
if(err) console.log('图片加载失败: ', err);
this.node.getComponent(Sprite).spriteFrame = asset
})
// 1s后销毁节点
this.scheduleOnce(() => this.node.destroy(), 0.2)
}
**特别注意: **
加载图片资源时, 需要在url中加入
spriteFrame
, 否则无法加载成功
击溃英雄
也是一次碰撞检测
start() {
// 注册碰撞回调方法
let collider = this.getComponent(Collider2D)
if (collider) {
// console.log('开启碰撞...');
collider.on(Contact2DType.BEGIN_CONTACT, this.beginContact, this)
}
}
// 碰撞检测方法
/**
* 子弹 1
* 敌机 2
* 英雄 3
*/
beginContact(selfCollider: Collider2D, otherCollider: Collider2D) {
// 英雄
if (this._isActive && otherCollider.tag == 3) {
console.log('发生敌机碰撞..');
director.once(Director.EVENT_AFTER_PHYSICS, ()=> {
// 自我销毁
selfCollider.node.destroy()
otherCollider.node.getComponent(HeroControl).die()
})
}
}
不同敌机
更多重复逻辑即可
敌机随机出现
使用预制体生成敌机, 在背景脚本中添加:
/**
*
* 生成敌机
*
*/
generateEnemy() {
this.schedule(() => this.randonEnemy(), math.randomRange(0.5, 3))
}
/**
* 随机生成敌机
*/
randonEnemy() {
let enemy = instantiate(this.enemyPre0)
enemy.setParent(director.getScene().getChildByName('Canvas'))
// 左右不超过攻击范围
let tmp = this.viewSize.width / 2 - 30
enemy.setPosition(v3(math.randomRange(-tmp, tmp), 480))
}
英雄的落幕
英雄之死, 同敌机。
die() {
console.log('you are die');
// 播放击中死亡音乐
AudioMgr.inst.playOneShot('audio/boom2')
// 显示死亡图片
resources.load('hero1_die/spriteFrame', SpriteFrame, (err, asset) => {
if(err) console.log('图片加载失败: ', err);
this.node.getComponent(Sprite).spriteFrame = asset
})
// 1s后销毁节点
this.scheduleOnce(() => this.node.destroy(), 0.2)
}
还应该有开始游戏、结束游戏场景切换,得分系统,多样敌机,随时间难度增加,子弹buff等功能。。。
有还录制了一个视频,欢迎围观:https://www.bilibili.com/video/BV187hVeoEf9