本文字數:7539字
預計閱讀時間:30分鐘
01
前言
說起小游戲,最經典的莫過于飛機大戰了,相信很多同學都玩過。今天我們也來試試開發個有趣的小游戲吧!我們將從零開始,看看怎樣一步步實現一個H5版的飛機大戰!
首先我們定好目標,要做一個怎樣的飛機大戰,以及去哪整游戲素材?
剛好微信小程序官方提供了一個飛機大戰小游戲的模板,打開【微信開發者工具】,選擇【新建項目】-【小游戲】,選擇飛機大戰的模板,創建后就是一個小程序版飛機大戰。
運行小程序之后可以看到下面的效果:
從運行效果上看,這個飛機大戰已經比較完整,包含了以下內容:
1.地圖滾動,播放背景音效;
2.玩家控制飛機移動;
3.飛機持續發射子彈,播放發射音效;
4.隨機出現向下移動的敵軍;
5.子彈碰撞敵軍時,播放爆炸動畫和爆炸音效,同時子彈和敵軍都銷毀,并增加1個得分;
6.飛機碰撞敵軍時,游戲結束,彈出結束面板。
接下來我們以這個效果為參考,并拷貝這個項目中的圖片和音效素材,從頭做一個H5版飛機大戰吧!
02
選擇游戲框架
你可能會好奇,既然微信小程序官方已經生成好了完整代碼,直接參考那套代碼不就好嗎?
這里就涉及到游戲框架的問題,小程序那套代碼是沒有使用游戲框架的,所以很多基礎的地方都需要自己實現,比如說子彈移動,子彈與敵軍碰撞檢測等。
我們以碰撞為例,在小程序項目中是這樣實現的:
1.先定義好碰撞檢測的方法isCollideWith(),通過兩個物體的坐標和寬高進行碰撞檢測計算:
isCollideWith(sp)?{let?spX?=?sp.x?+?sp.width?/?2;let?spY?=?sp.y?+?sp.height?/?2;if?(!this.visible?||?!sp.visible)?return?false;return?!!(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?=?enemys.length;?i?<?il;?i++)?{if?(enemys[i].isCollideWith(bullet))?{//?Do?Something}}});
}
3.而通過游戲框架,可能只需要一行代碼。我們以Phaser為例:
this.physics.add.overlap(bullets,?enemys,?()?=>?{?//?Do?Something
},?null,?this);
上面代碼的含義是:bullets(子彈組)和enemys(敵軍組)發生overlap(重疊)則觸發回調。
從上面的例子可以看出,選擇一個游戲框架來開發游戲,可以大大降低開發難度,減少代碼量。
當開發一個專業的游戲時,我們一般會選擇專門的游戲引擎,比如Cocos,Egret,LayaBox,Unity等。但是如果只是做一個簡單的H5小游戲,嵌入我們的前端項目中,使用Phaser就可以了。
引用Phaser官網上的介紹:
【Phaser是一個快速、免費且有趣的開源HTML5游戲框架,可在桌面和移動Web瀏覽器上提供WebGL和Canvas渲染。可以使用第三方工具將游戲編譯為iOS、Android和本機應用程序。您可以使用JavaScript或TypeScript進行開發。】
同時Phaser在社區也非常受歡迎,Github上收獲35.5k的Star,Npm上最近一周下載量19k。
因此我們采用Phaser作為游戲框架。接下來,開始正式我們的飛機大戰之旅啦!
03
準備工作
3.1 創建項目
項目采用的技術棧是:Phaser + Vue3 + TypeScript + Vite。
當然對于這個游戲來說,核心的框架是Phaser,其他都是可選的。只使用Phaser + Html也是可以開發的,只是我們希望采用目前更主流的開發方式。
進行工作目錄,直接使用vue手腳架創建名為plane-war的項目。
npm?create?vue
項目創建完成,安裝依賴,檢查是否運行正常。
cd?plane-war
npm?install
npm?run?dev
接下來再安裝phaser。
npm?install?phaser
3.2 整理素材
接下來我們重新整理下項目,清除不需要的文件,并把游戲素材拷貝到assets
目錄,最終目錄結構如下:
plane-war
├──?src
│???├──?assets
│???│???├──?audio
│???│???│???├──?bgm.mp3
│???│???│???├──?boom.mp3
│???│???│???└──?bullet.mp3
│???│???├──?images
│???│???│???├──?background.jpg
│???│???│???├──?boom.png
│???│???│???├──?bullet.png
│???│???│???├──?enemy.png
│???│???│???├──?player.png
│???│???│???└──?sprites.png
│???│???└──?json
│???│???????└──?sprites.json
│???├──?App.vue
│???└──?main.ts
素材處理1:
原本游戲素材中,爆炸動畫是由19張獨立圖片組成,在Phaser中需要合成一張雪碧圖,可以通過雪碧圖合成工具合成,命名為boom.png
,效果如下:
素材處理2:
原本游戲素材中,結束面板的圖片來源一張叫Common.png
的雪碧圖,我們重命名為sprites.png
。并且我們還需要為這個雪碧圖制作一份說明,起名為sprites.json
。通過它來指定我們需要用到目標圖片及其在雪碧圖中的位置。
這里我們指定2個目標圖片,result是結束面板,button是按鈕。
{"textures":?[{"image":?"sprites.png","size":?{"w":?512,"h":?512},"frames":?[{"filename":?"result","frame":?{?"x":?0,?"y":?0,?"w":?119,?"h":?108?}},{"filename":?"button","frame":?{?"x":?120,?"y":?6,?"w":?39,?"h":?24?}}]}]
}
3.3 初步運行
我們重構App.vue
,創建了一個游戲對象game,指定父容器為#container
,創建成功后則會在父容器中生成一個canvas
?元素,游戲的所有內容都通過這個canvas
進行呈現和交互。
<template><div id="container"></div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { Game, AUTO, Scale } from "phaser";let game: Game;
onMounted(() => {game = new Game({parent: "container",type: AUTO,width: 375,// 高度依據屏幕寬高比計算height: (window.innerHeight / window.innerWidth) * 375,scale: {// 自動縮放至寬或高與父容器一致,類似css中的contain// 由于寬高比與屏幕寬高比一致,最終就是剛好全屏效果mode: Scale.FIT,},physics: {default: "arcade",arcade: {debug: false,},},});
});onUnmounted(() => {game.destroy(true);
});
</script>
<style>
body {margin: 0;
}
#app {height: 100%;
}
</style>
通過npm run dev
再次運行項目,我們把瀏覽器展示區切換:為移動設備展示,此時可以看到canvas,并且其寬高應該正好全屏。
3.4 場景設計
可以看到現在畫布還是全黑的,這是因為創建game對象時還沒有接入任何場景。在Phaser中,一個游戲可以包含多個場景,而具體的游戲畫面和交互都是在各個場景中實現的。
接下來我們設計3個場景:
預載場景 :加載整個游戲資源,創建動畫,展示等待開始畫面。
主場景:游戲的主要畫面和交互。
結束場景:展示游戲結束畫面。
在項目中我們新增3個自定義場景類:
plane-war
├──?src
│???├──?game
│???│???├──?Preloader.ts
│???│???├──?Main.ts
│???│???└──?End.ts
自定義場景類繼承Scene類,包含了以下基本結構:
import?{?Scene?}?from?"phaser";export?class?Preloader?extends?Scene?{constructor()?{//?場景命名,這個命名在后面場景切換使用super("Preloader");}//?加載游戲資源preload()?{}//?preload中的資源全部加載完成后執行create()?{}//?每一幀的回調update()?{}
}
按上面的基本結構分別實現好3個場景類,并導入到game對象的創建中:
import?{?onMounted,?onUnmounted?}?from?"vue";
import?{?Game,?AUTO,?Scale?}?from?"phaser";
import?{?Preloader?}?from?"./game/Preloader";
import?{?Main?}?from?"./game/Main";
import?{?End?}?from?"./game/End";let?game:?Game;
onMounted(()?=>?{game?=?new?Game({//?其他參數省略...//?定義場景,默認初始化數組中首個場景,即?Preloaderscene:?[Preloader,?Main,?End],});
});
04
預載場景
準備工作完成后,接下來我們開始真正開發第一個游戲場景:預載場景,對應Preloader.ts
文件。
4.1 加載游戲資源
在preload
方法中加載整個游戲所需的資源。
import?{?Scene?}?from?"phaser";
import?backgroundImg?from?"../assets/images/background.jpg";
import?enemyImg?from?"../assets/images/enemy.png";
import?playerImg?from?"../assets/images/player.png";
import?bulletImg?from?"../assets/images/bullet.png";
import?boomImg?from?"../assets/images/boom.png";
import?bgmAudio?from?"../assets/audio/bgm.mp3";
import?boomAudio?from?"../assets/audio/boom.mp3";
import?bulletAudio?from?"../assets/audio/bullet.mp3";export?class?Preloader?extends?Scene?{constructor()?{super("Preloader");}preload()?{//?加載圖片this.load.image("background",?backgroundImg);this.load.image("enemy",?enemyImg);this.load.image("player",?playerImg);this.load.image("bullet",?bulletImg);this.load.spritesheet("boom",?boomImg,?{frameWidth:?64,frameHeight:?48,});//?加載音頻this.load.audio("bgm",?bgmAudio);this.load.audio("boom",?boomAudio);this.load.audio("bullet",?bulletAudio);}create()?{}
}
4.2 添加元素
接下來我們在create()
方法中去添加背景,背景音樂,標題,開始按鈕,后續使用的動畫,并且為開始按鈕綁定了點擊事件。
const?{?width,?height?}?=?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);//?開始按鈕
let?button?=?this.add.image(width?/?2,?(height?/?4)?*?3,?"sprites",?"button").setScale(3,?2).setInteractive().on("pointerdown",?()?=>?{//?點擊事件:關閉當前場景,打開Main場景this.scene.start("Main");});//?按鈕文案
this.add.text(button.x,?button.y,?"開始游戲",?{fontFamily:?"Arial",fontSize:?20,color:?"#e3f2ed",}).setOrigin(0.5);//?創建動畫,命名為?boom,后面使用
this.anims.create({key:?"boom",frames:?this.anims.generateFrameNumbers("boom",?{?start:?0,?end:?18?}),repeat:?0,
});
運行效果如下:
有個細節可以留意下,就是這個背景是怎樣鋪滿整個屏幕的?
上面的代碼是this.add.tileSprite()
創建了一個瓦片精靈,素材中的背景圖就像一個一個瓦片一樣鋪滿屏幕,所以就要求素材中的背景圖是一張首尾能無縫相連的圖片,這樣就能無限平鋪。主場景中的背景移動也是基于此。
05
主場景
5.1 梳理場景元素
在預載場景中點擊“開始游戲”按鈕,可以看到畫面又變成黑色,此時預載場景被關閉,游戲打開主場景。
在主場景中,涉及到的場景元素一共有:背景、玩家、子彈、敵軍、爆炸,我們可以先嘗試把它們都渲染出來,并加一些簡單的動作,比如移動背景,子彈和敵軍添加垂直方向速度,播放爆炸動畫等。
import?{?Scene,?GameObjects,?type?Types?}?from?"phaser";//?場景元素
let?background:?GameObjects.TileSprite;
let?enemy:?Types.Physics.Arcade.SpriteWithDynamicBody;
let?player:?Types.Physics.Arcade.SpriteWithDynamicBody;
let?bullet:?Types.Physics.Arcade.SpriteWithDynamicBody;
let?boom:?GameObjects.Sprite;export?class?Main?extends?Scene?{constructor()?{super("Main");}create()?{const?{?width,?height?}?=?this.cameras.main;//?背景background?=?this.add.tileSprite(0,?0,?width,?height,?"background").setOrigin(0,?0);//?玩家this.physics.add.sprite(100,?600,?"player").setScale(0.5);//?子彈this.physics.add.sprite(100,?500,?"bullet").setScale(0.25).setVelocityY(-100);//?敵軍this.physics.add.sprite(100,?100,?"enemy").setScale(0.5).setVelocityY(100);//?爆炸this.add.sprite(200,?100,?"boom").play("boom");}update()?{//?設置背景瓦片不斷移動background.tilePositionY?-=?1;}
}
效果如下:
看起來似乎已經有了雛形,但是這里還需要優化一下代碼設計。我們不希望場景中的所有元素創建,交互都糅合Main.ts
這個文件中,這樣就顯得有點臃腫,不好維護。
我們再設計出:玩家類、子彈類、敵軍類、炸彈類,讓每個元素它們自身的事件和行為都各自去實現,而主場景只負責創建它們,并且處理它們之間的交互事件,不需要去關心它們內部的實現。
雖然這個游戲的整體代碼也不多,但是通過這個設計思想,可以讓我們的代碼設計更加合理,當以后開發其他更復雜的小游戲時也可以套用這種模式。
5.2 玩家類
回顧上面的創建玩家的代碼:
this.physics.add.sprite(100,?600,?"player").setScale(0.5);
原本的代碼是直接創建了一個“物理精靈對象“,我們現在改成新建一個Player
類,這個類繼承Physics.Arcade.Sprite
,然后在主場景中通過new Player()
也同樣生成"物理精靈對象"。相當于Player
類拓展了原本Physics.Arcade.Sprite
,增加了對自身的一些事件處理和行為封裝。后續的子彈類,敵軍類等也是同樣的方式。
Player
類主要拓展了"長按移動事件",具體實現如下:
import?{?Physics,?Scene?}?from?"phaser";export?class?Player?extends?Physics.Arcade.Sprite?{isDown:?boolean?=?false;downX:?number;downY:?number;constructor(scene:?Scene)?{//?創建對象let?{?width,?height?}?=?scene.cameras.main;super(scene,?width?/?2,?height?-?80,?"player");scene.add.existing(this);scene.physics.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?=?this.y;});//?手指抬起this.scene.input.on("pointerup",?()?=>?{this.isDown?=?false;});//?手指移動this.scene.input.on("pointermove",?(pointer)?=>?{if?(this.isDown)?{this.x?=?this.downX?+?pointer.x?-?pointer.downX;this.y?=?this.downY?+?pointer.y?-?pointer.downY;}});}
}
5.3 子彈類
Bullet
類主要拓展了"發射子彈"和"子彈出界事件",具體實現如下:
import?{?Physics,?Scene?}?from?"phaser";export?class?Bullet?extends?Physics.Arcade.Sprite?{constructor(scene:?Scene,?x:?number,?y:?number,?texture:?string)?{//?創建對象super(scene,?x,?y,?texture);scene.add.existing(this);scene.physics.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("bullet");}//?每一幀更新回調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?extends?Physics.Arcade.Sprite?{constructor(scene:?Scene,?x:?number,?y:?number,?texture:?string)?{//?創建對象super(scene,?x,?y,?texture);scene.add.existing(this);scene.physics.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個元素在循環使用而已,而并非源源不斷地創建新元素,以此減少性能損耗。
最后再把注冊事件實現,主場景就全部完成了。
//?注冊事件
addEvent()?{//?定時器this.time.addEvent({delay:?400,callback:?()?=>?{//?生成2個敵軍for?(let?i?=?0;?i?<?2;?i++)?{enemys.getFirstDead()?.born();}//?發射1顆子彈bullets.getFirstDead()?.fire(player.x,?player.y?-?32);},callbackScope:?this,repeat:?-1,});//?子彈和敵軍碰撞this.physics.add.overlap(bullets,?enemys,?this.hit,?null,?this);//?玩家和敵軍碰撞this.physics.add.overlap(player,?enemys,?this.gameOver,?null,?this);
}
//?子彈擊中敵軍
hit(bullet,?enemy)?{//?子彈和敵軍隱藏enemy.disableBody(true,?true);bullet.disableBody(true,?true);//?顯示爆炸booms.getFirstDead()?.show(enemy.x,?enemy.y);//?分數增加scoreText.text?=?String(++score);
}
//?游戲結束
gameOver()?{//?暫停當前場景,并沒有銷毀this.sys.pause();//?保存分數this.registry.set("score",?score);//?打開結束場景this.game.scene.start("End");
}
06
結束場景
最后再實現一下結束場景,很簡單,主要包含結束面板,得分,重新開始按鈕。
import?{?Scene?}?from?"phaser";export?class?End?extends?Scene?{constructor()?{super("End");}create()?{let?{?width,?height?}?=?this.cameras.main;//?結束面板this.add.image(width?/?2,?height?/?2,?"sprites",?"result").setScale(2.5);//?標題this.add.text(width?/?2,?height?/?2?-?85,?"游戲結束",?{fontFamily:?"Arial",fontSize:?24,}).setOrigin(0.5);//?當前得分let?score?=?this.registry.get("score");this.add.text(width?/?2,?height?/?2?-?10,?`當前得分:${score}`,?{fontFamily:?"Arial",fontSize:?20,}).setOrigin(0.5);//?重新開始按鈕let?button?=?this.add.image(width?/?2,?height?/?2?+?50,?"sprites",?"button").setScale(3,?2).setInteractive().on("pointerdown",?()?=>?{//?點擊事件:關閉當前場景,打開Main場景this.scene.start("Main");});//?按鈕文案this.add.text(button.x,?button.y,?"重新開始",?{fontFamily:?"Arial",fontSize:?20,}).setOrigin(0.5);}
}
07
優化
經過上面的代碼,整個游戲已經基本完成。不過在測試的時候,感覺玩家和敵軍還存在一定距離就觸發了碰撞事件。在創建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);}
}
最終可以看到邊框已經被縮小,效果如下:

08
結語
至此,飛機大戰全部開發完成。
回顧一下開發過程,我們先搭建項目,創建游戲對象,接下來又設計了:預載場景、主場景、結束場景,并且為了減少主場景的復雜度,我們以場景元素的維度,將涉及到的場景元素進行封裝,形成:玩家類、子彈類、敵軍類、爆炸類,讓這些場景元素各自實現自身的事件和行為。
在Phaser中的場景元素又可以分為普通元素和物理元素,物理元素是來自Physics
,其中玩家類,子彈類,敵軍類都是物理元素,物理元素具有物理屬性,比如重力,速度,加速度,彈性,碰撞等。
在本文代碼中涉及到了很多Phaser的API,介于篇幅沒有一一解釋,但是很多通過字面意思也可以理解,比如說disableBody
表示禁用元素,setVelocityY
表示設置Y?軸方向速度。并且我們也可以通過編譯器的代碼提示功能去了解這些方法的說明和參數含義:
最后,本文的所有代碼都已上傳gitee,有興趣的同學可以拉取代碼看下。
演示效果:https://yuhuo.online/plane-war/(點擊"閱讀原文"訪問鏈接)
源碼地址:https://gitee.com/yuhuo520/plane-war