【老臉教你做遊戲】小鳥飛過障礙物的遊戲(上)
摘要
我們已經從最基礎的畫線填充、cavans2d context狀態變換,做出了繪製封裝類(Figure)以及動畫類(Animation),就目前而言,這幾個簡單的類已經可以做簡單的遊戲了。這期就做個一簡單的小鳥飛躍障礙的遊戲,用來驗證我們之前的程式碼。該遊戲前幾年好像還挺多人玩:就是一個小鳥在叢林裡飛,速度會隨著時間退役越來越快,一旦碰到樹樁遊戲就結束。
本期內容依舊是在微信小遊戲上進行實現的。由於內容以及程式碼都承接以前文章,如果你沒有閱讀過,可以從這裡開始。
本文不允許任何形式的轉載!
閱讀提示
本系列文章不適合以下人群閱讀,如果你無意點開此文,請對號入座,以免浪費你寶貴的時間。
- 想要學習利用遊戲引擎開發遊戲的朋友。本文不會涉及任何第三方遊戲引擎。
- 不具備面向物件程式設計經驗的朋友。本文的語言主要是Javascript(ECMA 2016),如果你不具備JS程式設計經驗倒也無妨,但沒有面向物件程式語言經驗就不好辦了。
關注我的微信公眾號,回覆“原始碼3”可獲得本文示例程式碼下載地址,謝謝各位了!
Spirit類實現
我不知道為什麼要叫Spirit,都這麼命名的那我也這樣來吧。Spirit可以展示動畫,並能移動旋轉縮,我們之前的Figure類和Animation類就已經實現了這些功能,並且在前一期的開始我給出了一個FigureImage類,一個可以繪製圖片的類,所以我決定從這個類繼承然後進行擴充套件:
import FigureImage from "./FigureImage.js";
export default class Spirit extends FigureImage {
constructor(p) {
super(p);
}
}
我們先測試一下這個類,繪製個圖片,這個圖片是一個小鳥飛行動作圖片,一共有4個動作,每個動作圖片的寬度和高度都是一致的:
我們將這幅圖利用Spirit類繪製在canvas上:
import Graph from "./example/Graph.js";
import Spirit from "./example/Spirit.js" ;
let graph = new Graph(wx.createCanvas());
let birdsSpirit = new Spirit();
birdsSpirit.left = 10;
birdsSpirit.top =100;
birdsSpirit.width = 400;
birdsSpirit.height = 50;
graph.addChild(birdsSpirit);
// 微信小遊戲建立image的方法
// 如果想要適配到web上你可以自己建個Image類
let image = wx.createImage();
image.onload = function(evt){
// 載入成功後把image物件給spirit並繪製
birdsSpirit.img = evt.target;
drawImage();
}
image.src = './example/images/birds_spirit.png';
function drawImage(){
graph.refresh();
}
顯示在介面是的結果如下:
如果我們想要實現小鳥飛行動作的動畫效果(拍打翅膀),我們可以利用ctx.drawImage方法中指定source圖片的bounds(源圖片的位置以及大小,可以看下上一期文章最開始,FigureImage類已經封裝好了),結合我們之前的Animation類一幀一幀的繪製上述圖片中小鳥動作。
首先要確定源圖片的大小,如果我們按照從左到右的順訊給每個動作安排上索引,由於每個動作圖片大小都是相同的,很簡單的就能計算出每個索引對應圖片的bounds,同時我們引入一個新的專門管理圖片資源的類,ImageManager:
let instance;
let _imageMap = Symbol('儲存的圖片列表');
export default class ImageManager {
constructor() {
if (instance) {
return instance;
}
instance = this;
this[_imageMap] = [];
}
static getInstance() {
if (!instance) new ImageManager();
return instance;
}
get images() {
let imgs = [];
for (let key in this[_imageMap]) {
imgs.push(this[_imageMap][key]);
}
return imgs;
}
registerImage(name, src, properties, callback) {
var image = wx.createImage();
var that = this;
image.data = properties;
image.onload = function (evt) {
that[_imageMap][name] = {image: evt.target, property: evt.target.data};
if (callback) {
callback(evt);
}
}
image.src = src;
}
getImage(name, index) {
var image = this[_imageMap][name].image;
if (index == undefined)
return image;
var property = this[_imageMap][name].property;
if (!property)
return image;
var column = property.column;
var row = property.row;
var total = column * row;
if (index < 0 || index >= total)
return image;
var width = image.width;
var height = image.height;
var perWidth = width / column;
var perHeight = height / row;
var vIndex = Math.floor(index / column);
var hIndex = Math.floor(index % column);
var srcLeft = hIndex * perWidth;
var srcTop = vIndex * perHeight;
var srcBounds = {left: srcLeft, top: srcTop, width: perWidth, height: perHeight};
return {image: image, bounds: srcBounds};
}
}
這個類的呼叫如下所示:
let graph = new Graph(wx.createCanvas());
let bird = new Spirit();
bird.left = 10;
bird.top = 100;
bird.width = 100;
bird.height = 70;
graph.addChild(bird);
ImageManager.getInstance().registerImage('birds',
'./example/images/birds_spirit.png',
{
column: 4,
row: 1
}, function (evt) {
// 圖片註冊成功繪製bird spirit中的第1個
let imageInfo = ImageManager.getInstance().getImage('birds', 1);
bird.img = imageInfo.image;
bird.srcLeft = imageInfo.bounds.left;
bird.srcTop = imageInfo.bounds.top;
bird.srcWidth = imageInfo.bounds.width;
bird.srcHeight = imageInfo.bounds.height;
graph.refresh();
});
可以看到這個類是一個單例類,我們首先要將圖片註冊進去讓它維護,並且給出該圖片一共分成幾行幾列,然後通過它的getImage方法指定圖片名(註冊圖片時給的唯一名稱)以及想要的索引,就可以得到該索引所對應的原圖片中的位置以及大小(我叫它bounds)。
既然我們可以利用ImageManager獲取原圖片中不同索引的bounds,那我們就可以在我們的Spirit中加入一個imageIndex屬性(用於指定原圖片中第幾個圖)和imageName屬性(原圖片註冊時的標識名稱)。通過設定這兩個屬性就可以繪製原圖片中不同位置:
import FigureImage from "./FigureImage";
import ImageManager from "./utils/ImageManager";
export default class Spirit extends FigureImage {
constructor(p) {
p = p || {};
super(p);
this.imageIndex = 0;
this.imageName = p['imageName'];
}
drawSelf(ctx){
if(this.imageName == undefined) return;
// imageIndex必須取整!!!
let imageInfo = ImageManager.getInstance().getImage(this.imageName, Math.floor(this.imageIndex));
this.img = imageInfo.image;
this.srcLeft = imageInfo.bounds.left;
this.srcTop = imageInfo.bounds.top;
this.srcWidth = imageInfo.bounds.width;
this.srcHeight = imageInfo.bounds.height;
super.drawSelf(ctx);
}
}
還記得上期文章中的Animation類嗎,“在固定時間內改變物件的屬性值”,如果我們在固定時間內改變Spirit的imageIndex值,並且注意到,Animation是在更改完屬性值之後才進行一次重新整理的(請翻閱前一篇文章),所以如果Sprite中的Animation一旦啟動,Spirit類每次繪製會在Animation更改完imageIndex後開始。這不就可以實現動畫了嗎?
在小鳥飛行的例子中,我們讓Sprite的Animation將imageIndex從0均勻變化到3.9,那麼每次imageIndex都會增加一點,注意到上面的drawSelf方法,我們都會對imageIndex進行一次取整,比如某次Animation計算出當前的imageIndex從0變化到了0.16,取整後得到的imageIndex還是0,那繪製的圖片比較之前沒有變化,若從0.9變化到了1.06,取整後imageIndex從0調到了1,繪製圖片就換了:
imageIndex屬性變化值從0均勻變化到3.9,假設每次增加0.16:
0,0.16,0.32,....,0.96, 這組資料都會取整得到imageIndex為0
1.12............,1.9x ,這組資料得1
........
3.xx ,......... 3.9 , 這組資料得到3
每組資料變化都是均勻的,則imageIndex取整後會在某個時間段內均勻地從0變化到3,且每次取整得到的索引值維持的時間基本相同。
我們可以新建一個Bird類,通過上述方法來實現小鳥飛行效果的動畫:
import Spirit from "./Spirit";
import Animation from "./Animation";
export default class Bird extends Spirit{
constructor(p){
super(p);
// 一個400毫秒完成的動畫
this.animation = new Animation(this,400);
}
playBirdFly(){
// 初始化imageIndex
this.imageIndex = 0;
// index變化從0-3.9;
this.animation.propertyChangeTo('imageIndex',3.9);
this.animation.loop = true;// 這是個無線迴圈的動畫
this.animation.start();
}
}
Animation類較上期又多了一個loop屬性,我在上期中沒有講到,因為比較簡單。僅僅是一個標識,讓Animation在結束後不清空記錄資料,而是直接重新再開始執行。並且我還更正了之前AnimationFrame類的一個bug,不過本系列文章主旨還是講方法,所以就不在這裡多講了。
我在整個遊戲開發講完後最後會給出所有程式碼,到時可以看看跟以前的Animation和AnimationFrame和以前有什麼不同。
我們在game.js了測試一下:
import Graph from "./example/Graph.js";
import ImageManager from "./example/utils/ImageManager";
import Bird from "./example/Bird";
let graph = new Graph(wx.createCanvas());
let bird = new Bird({imageName:'birds'});
bird.left = 10;
bird.top = 100;
bird.width = 100;
bird.height = 70;
graph.addChild(bird);
ImageManager.getInstance().registerImage('birds',
'./example/images/birds_spirit.png',
{
column: 4,
row: 1
}, function (evt) {
// 載入圖片完成後立即開始動畫
bird.playBirdFly();
});
這是輸出結果:
如果配合上Animation去移動它:
// 載入圖片完成後立即開始動畫
bird.playBirdFly();
let animation = new Animation(bird,4000);
animation.moveTo(bird.left,graph.height)
.start();
好像沒問題,但其實上面這段程式碼裡面有個bug,可以說是個error。
只繪製一次
我們上期說過,requestAnimationFrame方法是註冊一個方法控制代碼然後,會在重新整理到來的時候執行這個方法。我們看上面那段程式碼:
bird.playBirdFly();
let animation = new Animation(bird,4000);
animation.moveTo(bird.left,graph.height)
.start();
實際上是運行了兩個Animation,一個是bird物件裡那個更改imageIndex的Animation,另外一個是移動bird物件的Animation。
這兩個Animation都在更改完屬性值後都呼叫了graph的refresh方法,這就重複讓ctx進行了繪製,實際上重新整理到來之前繪製一次就好了,太多的繪製操作會讓整個程式效能降低(本來就是用的canvas2d已經很慢了,再做一些冗餘操作更慢)這就是問題所在。
我們希望,如果我們在一次重新整理到來之前呼叫了多少次graph的refresh方法,只需要執行一次就夠了。這就需要改造一下refresh方法了,我想到的辦法是讓refresh增加一個引數,名為requestId,意為“請求重新整理的ID”:
refresh(requestId) {
// 如果當前的requestId是空,則說明當前呼叫是第一回,將傳入id賦值給當前requestId
if (this.requestId == undefined || this.requestId == null) {
this.requestId = requestId;
} else {
// 如果有requestId引數,同時和當前的requestId不同,
// 這說明有一個迴圈重新整理正在執行中,這次則不重新整理
if (this.requestId != requestId) {
return;
}
}
this.ctx.clearRect(0, 0, this.width, this.height);
this.draw(this.ctx);
}
同時Animation也需要進行修改,即需要增加一個唯一標示屬性,並且在重新整理的時候傳入該屬性值;另外,如果該Animation停止後,還需要重置graph物件的requestId值,為了防止該Animation在結束後其餘呼叫該方法的物件無法重新整理。Animation部分程式碼:
import AnimationFrame from "./AnimationFrame";
let AnimationId = 0; // 自增長ID
let _id = Symbol('Animation的唯一標示');
..........
export default class Animation {
constructor(figure, totalTime, type) {
// 增加一個ID屬性,模擬私有變數
this[_id] = 'Animation' +(AnimationId++);
this.type = type || Linear;
this.loop = false;
this.figure = figure;
// 利用AnimationFrame來實現定時迴圈
this.animationFrame = new AnimationFrame();
// 計算一下totalTime如果間隔16毫秒重新整理一次的話,一共需要animationFrame重新整理多少次:
// 這個重新整理次數要取整
this.totalRefreshCount = Math.floor(totalTime / PRE_FRAME_TIME_FLOAT);
// 這是存放屬性初始值和結束值的列表,資料結構是:{ 屬性名 : { start:初始值, end:結束值}}
this.propertyValueTable = {};
this.nextAnimation = undefined; //下一個動畫
this.preAnimation = undefined; // 上一個動畫
}
..........
start() {
if (this.preAnimation) {
// 如果有上一個動畫就先執行它:
this.preAnimation.start();
return;
}
let that = this; // 便於匿名方法內能訪問到this
this.applyStartValue();
// 設定AnimationFrame的迴圈方法
this.animationFrame.repeat = function (refreshCount) {
// 如果AnimationFrame重新整理次數超過了動畫規定的最大次數
// 說明動畫已經結束了
if (refreshCount >= that.totalRefreshCount) {
// 動畫結束
that.animationFrame.stop();
} else {
// 如果動畫在執行,計算每次屬性增量:
that.applyPropertiesChange(refreshCount);
}
// 重新整理介面,傳入Animation的id(因為那是一個唯一值)
that.figure.getGraph().refresh(that[_id]);
};
// 設定AnimationFrame的結束回撥方法
this.animationFrame.stopCallback = function () {
// 停止後如果graph重新整理主ID是自己的,移除掉
// 否則其餘重新整理再呼叫是不會執行繪製的
if(that.figure.getGraph().requestId == that.id){
that.figure.getGraph().requestId = undefined;
}
that.applyEndValue();
if(that.loop){
that.start();
return;
}
// 清空我們的記錄的屬性值表:
for (let p in that.propertyValueTable) {
delete that.propertyValueTable[p];
}
if (that.nextAnimation) {
that.nextAnimation.preAnimation = undefined; // 避免形成死迴圈
that.nextAnimation.start();
}
};
..........
}
..........
}
目前我是這麼解決的,應該有其他解決辦法,至少方法名可以起的更容易理解一些,比如refreshRequest,updateImmediately之類的吧。
而大多數遊戲,都會有一個全域性的迴圈重新整理,所有繪製物件的修改只要在全域性重新整理之前完成即可。所以我們可以新建一個類,就叫BirdFlyGame,繼承製Graph,且具有一個gameStart,一旦呼叫,主重新整理立即開始:
import Graph from "./Graph";
import AnimationFrame from "./AnimationFrame";
export default class BirdFlyGame extends Graph {
constructor(canvas) {
super(canvas);
this.gameRefreshId = 'main_refresh';
this.animationFrame = new AnimationFrame();
let that = this;
this.animationFrame.repeat = function(refreshCount){
that.beforeRefresh(refreshCount);
that.refresh(that.gameRefreshId);
that.afterRefresh(refreshCount);
}
}
beforeRefresh(refreshCount){
}
afterRefresh(refreshCount){
}
gameStart() {
this.animationFrame.start();
}
gameStop(