1. 程式人生 > >【老臉教你做遊戲】小鳥飛過障礙物的遊戲(上)

【老臉教你做遊戲】小鳥飛過障礙物的遊戲(上)

摘要

我們已經從最基礎的畫線填充、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個動作,每個動作圖片的寬度和高度都是一致的:
birds_sprite.png
我們將這幅圖利用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.1600.160.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(