说起小游戏,最经典的就是空战。相信很多同学都玩过。今天我们就来尝试开发一款有趣的小游戏吧!我们从零开始,一步步看如何实现一款H5版本的飞机大战!
首先,我们设定一个目标:我们要打造什么样的飞机大战,去哪里收集游戏素材?
恰巧微信小程序官方提供了飞机大战小游戏的模板。打开【微信开发者工具】,选择【新建项目】-【小游戏】,选择飞机大战模板,创建完成后就是飞机大战的小程序版本。
运行小程序后,可以看到如下效果:
从作战效果来看,这次机战比较完整,包括以下内容:
1.滚动地图并播放背景音效;
2.玩家控制飞行器移动;
3、飞机持续发射子弹并播放射击音效;
4.向下移动的敌人随机出现;
5.当子弹与敌人碰撞时,会播放爆炸动画和音效。同时,子弹和敌人都会被消灭,并增加1分;
6. 当飞行器与敌人相撞时,游戏结束,弹出结束面板。
接下来我们将以这个效果为参考,复制本项目中的图片和音效素材,从头开始制作一个H5版本的飞机大战!
选择游戏框架
你可能会好奇,既然微信小程序已经官方生成了完整的代码,那直接引用那套代码不是更好吗?
这就涉及到游戏框架的问题。小程序的代码没有使用游戏框架,所以很多基础方面都需要自己实现,比如子弹移动、子弹与敌人碰撞检测等。
我们以碰撞为例。在小程序项目中,是这样实现的:
1.首先定义碰撞检测方法isCollideWith(),根据两个物体的坐标、宽度和高度计算碰撞检测:
isCollideWith(sp) { 让spX=sp.x + sp.width/2;让spY=sp.y + sp.height/2; if (!this.visible || !sp.visible) 返回false;返回! (spX=this.x spX=this.x + this.width spY=this.y spY=this.y + this.height);},
2、然后在每一帧的回调中,遍历所有子弹和所有敌人,依次调用isCollideWith()进行碰撞检测:
update() {Bullets.forEach((bullet)={ for (let i=0, il=heavens.length; i il; i++) { if (enemys[i].isCollideWith(bullet)) { //做某事} } });}
3、有了游戏框架,可能只需要一行代码。我们以Phaser 为例:
this.physics.add.overlap(bullets, 敌人, ()={ //做某事}, null, this);
上面代码的意思是:如果子弹(子弹群)和敌人(敌群)重叠,则触发回调。
从上面的例子可以看出,选择游戏框架来开发游戏可以大大降低开发难度,减少代码量。
在开发专业游戏时,我们通常会选择专门的游戏引擎,比如Cocos、Egret、LayaBox、Unity等。但如果只是做一个简单的H5游戏,嵌入到我们的前端项目中,就使用Phaser即可。
引用Phaser官网的介绍:
[Phaser 是一个快速、免费且有趣的开源HTML5 游戏框架,可在桌面和移动Web 浏览器上提供WebGL 和Canvas 渲染。游戏可以使用第三方工具编译成iOS、Android 和本机应用程序。您可以使用JavaScript 或TypeScript 进行开发。 】
同时,Phaser 在社区中也很受欢迎。过去一周,它在Github 上获得了35,500 颗星,在Npm 上获得了19,000 次下载。
因此我们使用Phaser作为游戏框架。接下来,正式开始我们的位面战争之旅吧!
准备
3.1 创建项目
项目采用的技术栈为:Phaser + Vue3 + TypeScript + Vite。
当然,对于这个游戏来说,核心框架是Phaser,其他都是可选的。也可以只使用Phaser + Html进行开发,但我们希望采用目前比较主流的开发方式。
进入工作目录,直接使用vue脚手架创建一个名为plane-war的项目。
npm 创建vue
项目创建完毕,安装依赖,检查是否运行正常。
cdplane-warnpm installnpm run dev
接下来安装移相器。
npm 安装移相器
3.2 整理材料
接下来,我们重新整理项目,清除不需要的文件,并将游戏素材复制到assets目录下。最终的目录结构如下:
飞机战争 src 资产 音频 bgm.mp3 繁荣.mp3 子弹.mp3 图片 背景.jpg 繁荣.png 子弹.png 敌人.png 玩家.png sprites.png json sprites.json App.vue main.ts
材料加工1:
原版游戏素材中,爆炸动画由19张独立画面组成。在Phaser中,需要合成精灵图像。可以通过精灵图片合成工具合成,命名为boom.png。效果如下:
材料加工2:
这里我们指定两个目标图像,result是结束面板,button是按钮。
{ '纹理': [ { '图像': 'sprites.png', '尺寸': { 'w': 512, 'h': 512 }, '帧': [ { '文件名': '结果', '框架': {'x': 0,'y': 0,'w': 119,'h': 108}},{'文件名':'按钮','框架': {'x': 120, 'y': 6, 'w': 39, 'h': 24 } } ] } ]}
3.3 初步操作
我们重构了App.vue,创建了游戏对象game,并指定父容器为#container。创建成功后,父容器中会生成一个canvas元素。游戏的所有内容都将通过这个画布来呈现和交互。
通过npm run dev 再次运行项目。我们切换浏览器显示区域:移动设备显示。这时,你就可以看到画布了,它的宽度和高度应该正好是全屏的。
3.4 场景设计
您可以看到画布现在仍然是全黑的。这是因为创建游戏对象时还没有连接场景。在Phaser中,一个游戏可以包含多个场景,每个场景中都实现特定的游戏图像和交互。
接下来我们设计3个场景:
预加载场景:加载整个游戏资源,创建动画,并显示等待开始画面。
主场景:游戏的主画面和交互。
结束场景:显示游戏结束画面。
我们在项目中添加了3个自定义场景类:
自定义场景类继承了Scene类,包含以下基本结构:
import { Scene } from 'phaser';export class Preloader extends Scene { constructor() { //场景名称,后面场景切换时会用到这个名称super('Preloader'); } //加载游戏资源preload() {} //在preload 中的所有资源加载完毕后执行create() {} //每帧回调update() {}}
根据上述基本结构,分别实现了三个场景类,并导入到游戏对象的创建中:
预加载场景
准备工作完成后,我们开始实际开发第一个游戏场景:预加载场景,对应Preloader.ts文件。
4.1 加载游戏资源
在preload方法中加载整个游戏所需的资源。
从'phaser'导入{场景};从'./assets/images/background.jpg'导入backgroundImg;从'./assets/images/enemy.png'导入敌人Img;从'./assets/导入playerImg images/player.png';从'./assets/images/bullet.png'导入bulletImg;从'./assets/images/boom.png'导入boomImg;从'./assets/audio/导入bgmAudio bgm.mp3';从'./assets/audio/boom.mp3'导入boomAudio;从'./assets/audio/bullet.mp3'导入bulletAudio;导出类预加载器扩展场景{constructor() { super('预加载器'); } preload() { //加载图像this.load.image('background', backgroundImg); this.load.image('敌人',敌人Img); this.load.image('玩家',playerImg); this.load.image('子弹',bulletImg); this.load.spritesheet('boom',boomImg,{frameWidth: 64,frameHeight: 48,}); //加载音频this.load.audio('bgm' , bgmAudio); this.load.audio('boom',boomAudio); this.load.audio('子弹',bulletAudio); } 创造() {}}
4.2 添加元素
接下来,我们在create()方法中添加背景、背景音乐、标题、开始按钮、后续动画,并为开始按钮绑定一个点击事件。
const { 宽度, 高度}=this.cameras.main; //背景this.add.tileSprite(0, 0, width, height, 'background').setOrigin(0, 0); //背景音乐this.sound. play('bgm');//标题this.add .text(width/2, height/4, '飞机大战', { fontFamily: 'Arial', fontSize: 60, color: '#e3f2ed', stroke: '#203c5b', StrokeThickness: 6, }) .setOrigin(0.5);//让button=this.add .image(width/2, (height/4) * 3, 'sprites', 'button') .setScale(3, 2 ) . setInteractive() .on('pointerdown', ()={ //点击事件:关闭当前场景,打开主场景this.scene.start('Main'); });//按钮复制this.add. text(button.x, button.y, '开始游戏', { fontFamily: 'Arial', fontSize: 20, color: '#e3f2ed', }) .setOrigin(0.5);//创建动画,命名为boom,使用稍后.anims.create({ key: 'boom',frames: this.anims.generateFrameNumbers('boom', { start: 0, end: 18 }), Repeat: 0,});
运行效果如下:
有一个细节可以关注一下,就是这个背景是如何覆盖整个屏幕的?
上面的代码使用this.add.tileSprite() 创建一个图块精灵。素材中的背景图像像瓷砖一样一张一张地覆盖在屏幕上。因此,要求素材中的背景图像从头到尾无缝连接。图片,以便它们可以无限平铺。主场景中的背景动作也是以此为基础的。
主场景
5.1 梳理场景元素
单击预加载场景中的“开始游戏”按钮,您将看到屏幕再次变黑。此时,预载场景关闭,游戏打开主场景。
主场景中,总共涉及到场景元素:背景、玩家、子弹、敌人、爆炸。我们可以尝试先将它们全部渲染出来,然后添加一些简单的动作,比如移动背景、添加子弹和敌人的垂直速度、播放爆炸动画等。
从'phaser' 导入{ Scene, GameObjects, type Types }; //场景元素let background: GameObjects.TileSprite;让敌人: Types.Physics.Arcade.SpriteWithDynamicBody;让玩家: Types.Physics.Arcade.SpriteWithDynamicBody;让子弹: Types.Physics。 Arcade.SpriteWithDynamicBody;letoom: GameObjects.Sprite;export class Main extends Scene { 构造函数() { super('Main'); } } create() { const { 宽度, 高度}=this.cameras.main; //背景background=this.add.tileSprite(0, 0, width, height, 'background').setOrigin(0, 0); //玩家this.physical.add.sprite(100, 600, 'player').setScale(0.5) ; //子弹this.physicals.add.sprite(100, 500, 'bullet').setScale(0.25).setVelocityY(-100); //敌人this.physicals.add.sprite(100, 100, '敌人').setScale(0.5).setVelocityY(100); //爆炸this.add.sprite(200, 100, 'boom').play('boom'); } update() { //设置背景图块连续Move background.tilePositionY -=1; }}
效果如下:
看起来原型已经有了,但是这里代码设计还需要优化。我们不希望场景中的所有元素创建和交互都合并到Main.ts 文件中,这会显得有点臃肿且难以维护。
然后我们设计了:玩家类、子弹类、敌人类、炸弹类,这样每个元素都可以独立实现自己的事件和行为,而主场景只负责创建它们并处理它们之间的交互事件。无需关心它们的内部实现。
虽然这个游戏的整体代码不多,但是通过这样的设计思想,可以让我们的代码设计更加合理。未来开发其他更复杂的小游戏时也可以应用该模型。
5.2 玩家等级
回顾上面创建播放器的代码:
this.physical.add.sprite(100, 600, '玩家').setScale(0.5);
原始代码直接创建了一个“物理精灵对象”。我们现在改成创建一个新的Player类,它继承Physics.Arcade.Sprite,然后在主场景中也通过new Player()生成一个“物理Sprite对象”。相当于Player类扩展了原有的Physics.Arcade.Sprite并添加了一些自身的事件处理和行为封装。后续的子弹类型、敌人类型等也是如此。
Player类主要扩展‘长按移动事件’,具体实现如下:
从'phaser'导入{物理,场景};导出类Player扩展Physics.Arcade.Sprite { isDown: boolean=false;下X:号;下Y:号; constructor(scene: Scene) { //创建对象let { width, height }=scene .cameras.main; super(场景,宽度/2,高度- 80,'玩家');场景.add.existing(this);场景.physical.add.existing(this); //设置属性this.setInteractive( ); this.setScale(0.5); this.setCollideWorldBounds(true); //注册事件this.addEvent(); } addEvent() { //将手指按在飞机上this.on('pointerdown', ()={ this.isDown=true; //记录按下时的飞机坐标this.downX=this.x; this.downY=这个.y; }); //手指抬起this.scene.input.on('pointerup ', ()={ this.isDown=false; }); //手指移动this.scene.input.on('pointermove', (pointer)={ if (this.isDown) { this.x=this.downX + point.x - pointer.downX; this.y=this. downY + 指针.y - 指针.downY; } }); }}
5.3 项目符号
Bullet类主要扩展了‘射击子弹’和‘子弹出界事件’,具体实现如下:
import {Physics, Scene } from 'phaser';export class Bullet extendsPhysics.Arcade.Sprite { constructor(scene: Scene, x: number, y: number,texture: string) { //创建对象super(scene, x, y,texture) ;场景.add.existing(this);场景.physical.add.existing(this); //设置属性this.setScale(0.25); } //发射子弹fire(x: number, y: number) { this.enableBody( true, x, y, true, true); this.setVelocityY(-300); this.scene.sound.play('子弹'); } //每帧更新回调preUpdate(time: number, delta: number) { super .preUpdate(time, delta); //子弹出界事件(子弹到达顶部并冲出屏幕) if (this.y=-14) { this.disableBody(true, true); } }}
5.4 敌人类型
Enemy类主要扩展‘敌人生成’和‘敌人出界事件’。具体实现如下:
import {Physics, Math, Scene } from 'phaser';export class Enemy extendsPhysics.Arcade.Sprite { constructor(scene: Scene, x: number, y: number,texture: string) { //创建对象super(scene, x, y,质地);场景.add.existing(this);场景.physical.add.existing(this); //设置属性this.setScale(0.5); } //生成敌方部队Born() { let x=Math.Between (30, 345);
let y = Math.Between(-20, -40); this.enableBody(true, x, y, true, true); this.setVelocityY(Math.Between(150, 300)); } // 每一帧更新回调 preUpdate(time: number, delta: number) { super.preUpdate(time, delta); let { height } = this.scene.cameras.main; // 敌军出界事件(敌军走到底部超出屏幕) if (this.y >= height + 20) { this.disableBody(true, true) } }} 5.5 爆炸类 Boom 类主要拓展了"显示爆炸"和“隐藏爆炸”,具体实现如下: import { GameObjects, Scene } from "phaser";export class Boom extends GameObjects.Sprite { constructor(scene: Scene, x: number, y: number, texture: string) { super(scene, x, y, texture); // 爆炸动画播放结束事件 this.on("animationcomplete-boom", this.hide, this); } // 显示爆炸 show(x: number, y: number) { this.x = x; this.y = y; this.setActive(true); this.setVisible(true); this.play("boom"); this.scene.sound.play("boom"); } // 隐藏爆炸 hide() { this.setActive(false); this.setVisible(false); }} 5.6 重构主场景 上面我们实现了玩家类,子弹类,敌军类,爆炸类,接下来我们在主场景中重新创建这些元素,并加入分数文本元素。 import { Scene, Physics, GameObjects } from "phaser";import { Player } from "./Player";import { Bullet } from "./Bullet";import { Enemy } from "./Enemy";import { Boom } from "./Boom";// 场景元素let background: GameObjects.TileSprite;let player: Player;let enemys: Physics.Arcade.Group;let bullets: Physics.Arcade.Group;let booms: GameObjects.Group;let scoreText: GameObjects.Text;// 场景数据let score: number;export class Main extends Scene { constructor() { super("Main"); } create() { let { width, height } = this.cameras.main; // 创建背景 background = this.add.tileSprite(0, 0, width, height, "background").setOrigin(0, 0); // 创建玩家 player = new Player(this); // 创建敌军 enemys = this.physics.add.group({ frameQuantity: 30, key: "enemy", enable: false, active: false, visible: false, classType: Enemy, }); // 创建子弹 bullets = this.physics.add.group({ frameQuantity: 15, key: "bullet", enable: false, active: false, visible: false, classType: Bullet, }); // 创建爆炸 booms = this.add.group({ frameQuantity: 30, key: "boom", active: false, visible: false, classType: Boom, }); // 分数 score = 0; scoreText = this.add.text(10, 10, "0", { fontFamily: "Arial", fontSize: 20, }); // 注册事件 this.addEvent(); }, update() { // 背景移动 background.tilePositionY -= 1; }} 需要注意的是,这里的子弹,敌军,爆炸都是按组创建的,这样我们可以直接监听子弹组和敌军组的碰撞,而不需要监听每一个子弹和每一个敌军的碰撞。另一方面,创建组时已经把组内的元素全部创建好了,比如创建敌军时指定frameQuantity: 30,表示直接创建30个敌军元素,后续敌军不断出现和销毁其实就是这30个元素在循环使用而已,而并非源源不断地创建新元素,以此减少性能损耗。 最后再把注册事件实现,主场景就全部完成了。 结束场景 最后再实现一下结束场景,很简单,主要包含结束面板,得分,重新开始按钮。 优化 经过上面的代码,整个游戏已经基本完成。不过在测试的时候,感觉玩家和敌军还存在一定距离就触发了碰撞事件。在创建game时,我们可以打开debug模式,这样就可以看到Phaser为我们提供的一些调试信息。 game = new Game({ physics: { default: "arcade", arcade: { debug: true, }, }, // ...}); 测试一下碰撞: 可以看到两个元素的边框确实发生碰撞了,但是这并不符合我们的要求,我们希望两个飞机看起来是真的挨到一起才触发碰撞事件。所以我们可以再优化一下,飞机本身不变,但是边框缩小。 在Player.ts的构造函数中追加如下: export class Player extends Physics.Arcade.Sprite { constructor() { // ... // 追加下面一行 this.body.setSize(120, 120); }} 在Enemy.ts的构造函数中追加如下: export class Enemy extends Physics.Arcade.Sprite { constructor() { // ... // 追加下面一行 this.body.setSize(100, 60); }} 最终可以看到边框已经被缩小,效果如下: 结语 至此,飞机大战全部开发完成。 回顾一下开发过程,我们先搭建项目,创建游戏对象,接下来又设计了:预载场景、主场景、结束场景,并且为了减少主场景的复杂度,我们以场景元素的维度,将涉及到的场景元素进行封装,形成:玩家类、子弹类、敌军类、爆炸类,让这些场景元素各自实现自身的事件和行为。 在Phaser中的场景元素又可以分为普通元素和物理元素,物理元素是来自Physics,其中玩家类,子弹类,敌军类都是物理元素,物理元素具有物理属性,比如重力,速度,加速度,弹性,碰撞等。 演示效果:https://yuhuo.online/plane-war/ 源码地址:https://gitee.com/yuhuo520/plane-war
用户评论
哇塞,这太酷了!我一直想学习 Phaser 搞个小游戏,这款《飞机大战》挺有童年回忆的。用 Vue3 配合 Phaser 能实现动态、交互的效果一定很不错!我感觉学习起来应该很有趣,下次试试看!
有14位网友表示赞同!
这个标题一看就是吸引人的!飞机大战小时候可是玩得次数最多的游戏了,现在能用技术重现出来太有趣了!希望教程讲解详细一点,这样我也可以自己动手尝试一下!
有10位网友表示赞同!
讲道理, Phaser 玩起来还是挺难的,我之前试过几次都没搞懂。用 Vue3 结合它倒是比较新颖的方法,会不会让这个游戏开发更加流畅呢?期待后续的详细介绍!
有14位网友表示赞同!
飞机大战?太老土了,这时代还有人喜欢玩这种简单粗暴的游戏吗? 还是不如用 Unity 开发什么高科技类型的项目更能吸引人的眼球,我觉得。
有10位网友表示赞同!
这也太酷了!我从小就非常喜欢飞机大战类的小游戏,现在能用 Vue3 和 Phaser 来实现简直想想就兴奋!这个教程肯定会让我受益匪浅,一定要好好学下去。感谢分享!
有13位网友表示赞同!
飞机大战这种经典游戏其实玩法很简单,主要在于画面和音效的表现。希望这个教程不仅讲解语法,也要分享一些提高游戏品质的技巧。
有7位网友表示赞同!
这篇文章标题写的很有吸引力啊!我曾经也尝试过用 JavaScript 制作简单的 HTML5 游戏,感觉效果还是挺不错的。如果能让这款《飞机大战》的功能变得更强,例如加入不同的武器和道具,应该会更加有趣!
有19位网友表示赞同!
我已经很久没看到有人用 Phaser 做游戏了,感觉这款引擎逐渐被遗忘了吗? Vue3 和 Phaser 的结合还真是创新思路,我想试着学习一下这个新方法。
有16位网友表示赞同!
话说,这款《飞机大战》是不是可以用移动端运行? 如果可以的话,那一定会非常方便携带和体验。希望作者能够考虑提供一些移植到移动端的指南!
有10位网友表示赞同!
我之前看过很多类似的文章,都是简单的介绍 Phaser 的基础用法,这篇关于用 Vue3 + Phaser 实现《飞机大战》的教程看起来更实用得多。 期待能看到详细的开发过程和代码示例。
有8位网友表示赞同!
学习这个教程的话,我可以打造属于自己的经典游戏吗? 我从小就喜欢玩这种类型的游戏,现在自己动手实现一定更有成就感!
有7位网友表示赞同!
我对 Phaser 有点了解,但 Vue3 还是不太熟悉。希望这篇教程能解释清晰易懂的Vue3 的使用方式,这样我才能更好地理解这个结合。
有5位网友表示赞同!
飞机大战这种游戏很容易上手,但是想玩得更好却很难,希望能看到作者分享一些提升游戏难度的技巧,比如加入更丰富的BOSS和技能之类的!
有7位网友表示赞同!
我很期待看到成品游戏演示! 能够用 Vue3 和 Phaser 制作出一个优秀的《飞机大战》,一定很能体现技术的强大。 希望它能成为学习Phaser 的好范例。
有7位网友表示赞同!
这篇教程的重点是不是在于展示 Vue3 与 Phaser 的结合运用?我个人更偏向于 Unity,因为它可以跨平台开发,不过这个思路还是很有借鉴意义!
有13位网友表示赞同!
如果这篇文章介绍了游戏的设计思路和实现策略,那就更好了, 这样对我们学习更有帮助。希望作者后期能补充一些关于游戏设计的理论知识。
有7位网友表示赞同!
我很喜欢这种用代码来实现经典游戏的方式! 这让我对编程和游戏的兴趣更加强烈,期待看到这个《飞机大战》的最终成品!
有14位网友表示赞同!
我觉得这篇文章很有教育意义,它不仅展示了Vue3 和 Phaser 的强大功能,也激发了我们去尝试更多创意的游戏开发。 值得推广!
有13位网友表示赞同!