【老脸教你做游戏】小鸟飞过障碍物的游戏(上)

摘要

我们已经从最基础的画线填充、cavans2d context状态变换,做出了绘制封装类(Figure)以及动画类(Animation),就目前而言,这几个简单的类已经可以做简单的游戏了。这期就做个一简单的小鸟飞跃障碍的游戏,用来验证我们之前的代码。该游戏前几年好像还挺多人玩:就是一个小鸟在丛林里飞,速度会随着时间退役越来越快,一旦碰到树桩游戏就结束。
本期内容依旧是在微信小游戏上进行实现的。由于内容以及代码都承接以前文章,如果你没有阅读过,可以从这里开始。

本文不允许任何形式的转载!

阅读提示

本系列文章不适合以下人群阅读,如果你无意点开此文,请对号入座,以免浪费你宝贵的时间。

  • 想要学习利用游戏引擎开发游戏的朋友。本文不会涉及任何第三方游戏引擎。
  • 不具备面向对象编程经验的朋友。本文的语言主要是Javascript(ECMA 2016),如果你不具备JS编程经验倒也无妨,但没有面向对象程序语言经验就不好办了。

关注我的微信公众号,回复“源代码3”可获得本文示例代码下载地址,谢谢各位了!
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第1张图片

Spirit类实现

我不知道为什么要叫Spirit,都这么命名的那我也这样来吧。Spirit可以展示动画,并能移动旋转缩,我们之前的Figure类和Animation类就已经实现了这些功能,并且在前一期的开始我给出了一个FigureImage类,一个可以绘制图片的类,所以我决定从这个类继承然后进行扩展:

import FigureImage from "./FigureImage.js";

export default class Spirit extends FigureImage {
    constructor(p) {
        super(p);
    }
}

我们先测试一下这个类,绘制个图片,这个图片是一个小鸟飞行动作图片,一共有4个动作,每个动作图片的宽度和高度都是一致的:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第2张图片
我们将这幅图利用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();
}

显示在界面是的结果如下:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第3张图片
如果我们想要实现小鸟飞行动作的动画效果(拍打翅膀),我们可以利用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();
    });

这是输出结果:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第4张图片
如果配合上Animation去移动它:

       // 加载图片完成后立即开始动画
        bird.playBirdFly();
        let animation = new Animation(bird,4000);
        animation.moveTo(bird.left,graph.height)
            .start();

【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第5张图片
好像没问题,但其实上面这段代码里面有个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() {
        this.animationFrame.stop();
        this.requestId = undefined;
    }
}

beforeRefresh方法和afterRefresh方法会在graph刷新的前后执行,并且每一帧刷新都会调用。所以这两个方法里做逻辑操作是最合适的。
我们先把这个类放一边,一会儿再聊它,先看看如何实现小鸟移动。

只会上下移动的小鸟

小鸟飞跃障碍物这类游戏,实际上小鸟在x轴上是不移动的,移动的只是背景和障碍物,就跟大多数横向卷轴游戏一样。所以我们只需要实现小鸟向上和向下移动即可。
我们的游戏这样规定:

  • 一旦玩家按住屏幕,小鸟就会加速向上移动至版边,且移动速度有一个最大值限制。
  • 一旦屏幕上没有任何操作,小鸟就会加速向下移动至版边,同样,速度有个最大值限制。

上下移动只要更改Bird对象的top值就可以,我们有两种方法来实现。
一种是利用刚才新建的BirdFlyGame,在beforeRefresh里更改Bird的top值。另一种是在Bird类里加入几个方法,比如flyUp,dropDown之类的,通过调用这些方法来更改Bird的top值。
一般按照面向对象的惯有思维来说,绝对会选择第二种办法。但要注意,如果调用Bird类自身的方法改变top,就一定要和刷新同步,最简单的办法每次更改操作都通过requestAnimationFrame加入到其调用方法栈中。
不过我用第一种方法,简单易操作。代码如下:

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.bird = undefined;
        this.flyUp = false;
        this.flySpeed = 0;
        this.deltaSpeed = 0.1;
        this.maxSpeed = 10;
        this.gameOver = true;
        this.animationFrame.repeat = function (refreshCount) {
            that.beforeRefresh(refreshCount);
            that.refresh(that.gameRefreshId);
            that.afterRefresh(refreshCount);
        }
        // 这是微信小游戏的触屏事件监听方法
        // 一旦屏幕被按下,小鸟就往上飞,反之
        wx.onTouchStart(function (evt) {
            console.log('向上飞');
            that.flySpeed = 0;
            that.flyUp = true;
        });
        wx.onTouchEnd(function (evt) {
            console.log('向下落')
            that.flySpeed = 0;
            that.flyUp = false;
        });
    }

    /**
     * 监控小鸟飞行
     */
    monitorBirdFly() {
        if (this.flySpeed > this.maxSpeed) {
            this.flySpeed = this.maxSpeed;
        }
        if (this.flyUp) {
            // 如果是往上飞,递减bird的top值:
            this.bird.top -= this.flySpeed;
            // 飞行速度会递增,模拟加速度
            this.flySpeed += this.deltaSpeed;
        } else {
            // 反之亦然
            this.bird.top += this.flySpeed;
            this.flySpeed += this.deltaSpeed;
        }
        // 超过版边
        if (this.bird.top < 0) this.bird.top = 0;
        if (this.bird.top + this.bird.height > this.height)
            this.bird.top = this.height - this.bird.height;
    }

    beforeRefresh(refreshCount) {
        if (!this.gameOver)
            this.monitorBirdFly();
    }

    afterRefresh(refreshCount) {

    }

    gameStart() {
        this.gameOver = false;
        this.animationFrame.start();
    }

    gameStop() {
        this.animationFrame.stop();
        this.requestId = undefined;
    }
}

然后我们的game.js需要改一下,一是用BirdFlyGame替代之前的graph,二是在BirdFlyGame调用gameStart之后开始Bird的飞行动画。这里说明一下,整个游戏中,Bird的飞行动画一直在运行,即使游戏结束还是在拍打翅膀。

let game = new BirdFlyGame(wx.createCanvas());
let bird = new Bird({imageName:'birds'});
bird.left = 10;
bird.top = game.height;
bird.width = 100;
bird.height = 70;
game.addChild(bird);
game.bird = bird;

ImageManager.getInstance().registerImage('birds',
    './example/images/birds_spirit.png',
    {
        column: 4,
        row: 1
    }, function (evt) {
        game.gameStart();
        bird.playBirdFly();
    });

BirdFlyGame 在构造函数里开始监听触屏事件,肯定会有懂得多的杠精跳出来说,这个不对,应该有一个“手势”类专门来处理触屏事件,的确这样,但一方面游戏操作太简单,且为了节约时间,我就直接处理了,省的还要讲"手势"怎么去实现,麻烦。
monitorBirdFly方法在beforeRefresh中被调用,即,如果游戏没有结束,每次刷新前都会去更改Bird对象的top值。
下面是输出结果,我没办法展示触屏,不过可以在控制台看到事件发生时候的输出文字:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第6张图片

障碍物地图

先制定一个障碍物规则:

  • 障碍物是树桩,每个树桩的大小一致,且障碍物的高度能被游戏屏幕高度整除(就是说一个屏幕高度能放下整数个障碍物)
  • 障碍物从屏幕的顶部和底部开始往中间增加,但至少会留一个缺口,缺口大小和障碍物一致(让小鸟飞过);缺口数量不会超过每列障碍物总数 - 1(意思是说,每列障碍物至少要有一个)
  • 每一列障碍物之间的间隔至少是一个障碍物的宽度; 最多间隔m个障碍物宽度(m的值可以根据需要调整)
  • 障碍物旁边可有装饰图片(树枝一类的),但不作为障碍物的一部分,即小鸟碰到后不算碰到障碍物。

下面是我绘制的大致的障碍物图:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第7张图片

上图是利用多个独立的图片进行绘制的,如果你了解游戏地图的制作,就知道这是利用图块(tile)拼成的:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第8张图片
每一列障碍物是由多个不同图块拼接而成,整个游戏的障碍物图就是多列障碍物的组合。
假设我们每一列分成7段,每个障碍图共有5列的话,我们可以把每一列的障碍物看成一个数组,则可以得到如下的数据结构:

障碍物地图数据结构(1代表要绘制树桩,0表示不绘制,数组从左到右表示从上到下绘制的障碍物):
第1列:[1,1,0,1,1,1,1]2列:[1,1,1,0,0,1,1] 
...5列:[0,1,1,1,1,1,1]

注意我们之前设定的规则,每列障碍物之间是有间隔的,间隔大小是障碍物的宽度,且至少要有一个间隔,最多m个间隔。
所以我们还需要一组数据来描述障碍物的间隔,假设我们最多有4个间隔距离,可以得到以前一个数组来描述:

障碍物地图的每列间隔数据结构(因为我们之前假设每个障碍物地图有5列,所以是有5个间隔):
[1,1,2,3,2]
这个数组表示:
第一列和第二列间隔为1(即1个障碍物宽度)
以此类推,数组的最后一个数据表示第5列和第6列之间间隔为2

实际上我们没有第6列,但是障碍物地图不是单个,会有多个障碍物地图,他们直接拼接也需要间隔,所以数据的最后一个数据就是地图之间的间隔。
看下我们的障碍物图,缺口处上下的树桩是不一样的,但我们的数据结构里都是用1来表示,为了方便以后绘制,我们最好用其他数字来表示便于区分:

2表示向下的树桩尾部,3表示向上的树桩尾部
第1列:[1,2,0,3,1,1,1]2列:[1,1,2,0,0,3,1] 
...5列:[0,3,1,1,1,1,1]

我们根据上面的描述,可以写一个程序来随机生成障碍物地图数据。
首先,我们先随机缺口的起始位置,然后再随机给出缺口个数,这样就确定了缺口起始和终止的位置,并且缺口起始位置-1 位置的值为2,且缺口终止位置+1位置的值为3(缺口两端绘制树桩尾部),生成好这几样后,数组其余数据就是1了。
树桩之间的间隔数据就容易得多,只需随机生成即可,无需计算。
我们设地图数据的数据结构如下:

{
    row : 数字 // 每列障碍物一共有多少个图块(包括空白处)
    column : 数字 // 每个障碍物地图一共有几列 (不包括每列之间的间隔)
    mapData:二维数组 // 整个障碍物地图的数据
    spaceData:数组 // 每列障碍物之间的间隔
}

那我们可以用以下代码生成地图数据:

export default class MapGenerator {
    constructor() {

    }

    /**
     * 生成地图数据
     * @param column 一共有几列障碍物
     * @param row 每列障碍物有多少个
     */
    static generateMapData(out, column, row, maxSpace) {
        if (maxSpace == undefined) maxSpace = 4;
        let mapData;
        if (out == undefined) {
            out = {column: column, row: row, mapData: new Array(column), spaceData: new Array(column)};
        }
        column = out.column;
        row = out.row;
        mapData = out.mapData;
        for (let i = 0; i < column; i++) {
            // 随机生成每列之间的间隔数据
            out.spaceData[i] = Math.floor(Math.random() * maxSpace + 1);
            let data = mapData[i];
            if (data == undefined) {
                mapData[i] = new Array(row);
                data = mapData[i];// 这才是每列障碍物的地图数据
            }
            // 先全部设置为1
            for (let j = 0; j < row; j++)
                data[j] = 1;

            // 随机生成缺口起始位置:0 - (row-1)
            let spaceStartIndex = Math.floor(Math.random() * (row - 1));
            // 随机生成缺口个数: 1- (row-1)
            let spaceNum = Math.floor(Math.random() * (row - 2) + 1);

            // 缺口开始处-1是向下的树桩尾部:
            if (spaceStartIndex - 1 >= 0) {
                data[spaceStartIndex - 1] = 2;
            }
            let lastSpaceIndex = spaceStartIndex + spaceNum - 1;
            // 缺口结束处+1是向上的树桩尾部:
            if (lastSpaceIndex + 1 <= row - 1) {
                data[lastSpaceIndex + 1] = 3;
            }

            // 缺口处都设置为0
            for (let k = 0; k < spaceNum; k++) {
                // 超过最大索引就退出
                if (k + spaceStartIndex >= row) break;
                data[k + spaceStartIndex] = 0;
            }
        }
        return out;
    }
}

该方法接受一个out参数,目的是为了避免不必要的内存浪费,即如果地图数据已经生成过,请将其放入到方法中重新生成一次。

一定要记住,我们在用canvas 2d做游戏,绘制已经很慢了,所以能节约的地方就节约

有了地图数据,我们就可以根据数据进行地图绘制了。新建一个TileMap类,继承自Figure:

import Figure from "./Figure";
import MapGenerator from "./MapGenerator";
import ImageManager from "./utils/ImageManager";

export default class TileMap extends Figure {
    constructor(p) {
        super(p);
        this.mapData = undefined;
        this.trunkHeight = 0; // 单个树桩的高度
        this.trunkWidth = 0; // 单个树桩的宽度
        this.column = 0;
        this.row = 0;
    }

    initMap() {
        if (this.mapData != undefined) {
            MapGenerator.generateMapData(this.mapData);
        } else {
            this.mapData = MapGenerator.generateMapData(this.mapData, this.column, this.row);
        }
        // 根据map数据来设置Map的高度和宽度:
        this.height = this.mapData.row * this.trunkHeight;
        this.width = this.mapData.column * this.trunkWidth;
        // 还要加上每列树桩之间的间隔:
        for (let i = 0; i < this.mapData.spaceData.length; i++) {
            this.width += this.mapData.spaceData[i] * this.trunkWidth;
        }
    }

    drawSelf(ctx) {
        ctx.fillStyle = 'red';
        ctx.beginPath();
        ctx.rect(0,0,this.width,this.height);
        ctx.closePath();
        ctx.fill();
        let imageManager = ImageManager.getInstance();
        let trunk1 = imageManager.getImage('trunk1');
        let trunk2 = imageManager.getImage('trunk2');
        let trunk3 = imageManager.getImage('trunk3');
        let startX = 0;
        let startY = 0;
        // 一列一列画:
        for (let i = 0; i < this.mapData.mapData.length; i++) {
            startY = 0;//每一列的y轴坐标都是从0开始
            // 先计算出每一列绘制的起始x轴的值:
            if (i != 0) {
                // 从第二列开始就要计算了,第一列是0
                startX += this.trunkWidth;// 第n列比第n-1列绝对多出一个宽度
                // 再加上它们之间的间隔:
                startX += this.mapData.spaceData[i-1] * this.trunkWidth;
            }
            // 某一列的地图数据:
            let datas = this.mapData.mapData[i];
            for (let j = 0; j < datas.length; j++) {
                let data = datas[j];
                let image = undefined;
                switch (data) {
                    case 1:
                        image = trunk2;
                        break;
                    case 2:
                        image = trunk3;
                        break;
                    case 3:
                        image = trunk1;
                        break;

                }
                // 如果image为空,即地图数据为0,此处是个空白处就不需要绘制
                if (image) {
                    ctx.drawImage(image, startX, startY, this.trunkWidth, this.trunkHeight);
                }
                // 画好一个就往下y坐标往下移动一个树桩高度
                startY += this.trunkHeight;
            }
        }
    }
}

注意drawSelf方法,我在绘制树桩之前用红色填充了整个地图,这是为了判断地图大小是否正确被设置以及树桩位置是否正确。测试完成后可以将其删除。

然后我们在game.js中测试一下地图:

let images = [];
images.push({name: 'birds', src: './example/images/birds_spirit.png', properties: {column: 4, row: 1}});
images.push({name: 'trunk1', src: './example/images/trunk1.png'});
images.push({name: 'trunk2', src: './example/images/trunk2.png'});
images.push({name: 'trunk3', src: './example/images/trunk3.png'});

let loadedImages = 0;
let game = new BirdFlyGame(wx.createCanvas());
let map = new TileMap();
// 地图其实位置在canvas左上角
map.left= 0; 
map.top = 0;
// 假设是一个5列7行的地图
map.column = 5;
map.row = 7;
// 设置树桩的宽度。
map.trunkWidth =40;
// 让树桩的高度能纵向铺满整个屏幕
map.trunkHeight = game.height/map.row;
// 生成随机地图,并设置好map的实际大小
map.initMap();

game.addChild(map);

for (let i = 0; i < images.length; i++) {
    let image = images[i];
    ImageManager.getInstance().registerImage(image.name, image.src, image.properties, loadImageComplete);
}

function loadImageComplete() {
    loadedImages++;
    if(loadedImages == images.length){
        // 说明全部加载完成
        game.gameStart();
    }
}

测试结果如下:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第9张图片

红色区域是整个地图的大小,这样看来这个TileMap应该是没问题的。

我没有绘制装饰树杈,后面代码也不会写,这个需要加入一些数据到地图数据中,为了简化我就懒得做了

卷轴地图移动

就如我之前说过的,整个游戏中,小鸟是不会横向移动的,但地图会移动,这样看上去就好像小鸟一直在往前飞行。
那什么是卷轴地图呢?我画了个图,看了就懂了:
【老脸教你做游戏】小鸟飞过障碍物的游戏(上)_第10张图片
我们的游戏里这样规定:

  • 有两个不同的地图首尾相连,即:map2.left = map1.left + width;
  • 一旦map1的最右侧移除了屏幕,就会把它移到map2的尾部,即:
    if(map1.left + map1.width < 0) : map1.left = map2.left + map2.width;

知道是怎么回事,编码就简单了。
首先我们建两个TileMap对象,然后和控制小鸟上下移动一样,我将监控地图移动的代码也放到BirdFlyGame的beforeRefresh中:

game.js 更改代码:
...
// 建两个不同的地图对象
let firstMap = new TileMap();
firstMap.left= 0;
firstMap.top = 0;
firstMap.column = 5;
firstMap.row = 7;
firstMap.trunkWidth =40;
firstMap.trunkHeight = game.height/firstMap.row;
firstMap.initMap();

let secondMap = new TileMap();
// 第二个地图在第一个地图的末尾
secondMap.left= firstMap.left + firstMap.width;
secondMap.top = 0;
secondMap.column = 5;
secondMap.row = 7;
secondMap.trunkWidth =40;
secondMap.trunkHeight = game.height/firstMap.row;
secondMap.initMap();

game.addChild(firstMap);
game.addChild(secondMap);
// 这是为了测试:
firstMap.color = 'red';
secondMap.color = 'blue';
game.firstMap = firstMap;
game.secondMap = secondMap;
......

BirdFlyGame.js中更改代码:
...
monitorMapMove(){
        let delta = 2;// 假设以2个像素的速度向左慢慢移动
        this.firstMap.left -= delta;
        this.secondMap.left -= delta;
        // 如果第一个地图的最右侧小于0,即超出了屏幕
        if(this.firstMap.left + this.firstMap.width <= 0){
            this.secondMap.left = 0;// 第二张地图开始位置绝逼在屏幕最左侧
            this.firstMap.initMap();// 重新生成地图
            this.firstMap.left = this.secondMap.left + this.secondMap.width;
            // 指针重指一下
            let temp = this.firstMap;
            this.firstMap = this.secondMap;
            this.secondMap = temp;
        }
    }

    beforeRefresh(refreshCount) {
        if (!this.gameOver){
            this.monitorBirdFly();
            this.monitorMapMove();
        }
    }
....

在monitorMapMove方法中,我们首先是在不停地将两个地图往左移动,然后判断:如果第一张地图移除了屏幕,那么就重新生成地图,并把它放到第二张地图的末尾,接着将firstMap和secondMap的指针互换一下(之前的firstMap成了secondMap,secondMap成了firstMap)。
下面是测试输出结果(红色背景的是firstMap,蓝色背景的是secondMap):

这里要注意一点,每张地图的宽度必须要不小于屏幕宽度,否则会出现一段空白区域(在我的实例里是黑色的),所以我们一开始设置的地图的row和column以及树桩的宽度一定要合适。这个不属于讨论范畴就不讲了。

我将这个游戏加入一个背景作为参照,重新设置了小鸟的大小以及障碍地图的行、列以及每列宽度,感受一下:

小结

这期本来想要将整个游戏开发全部讲完的,写着写着发现写了好多(我还删了一些多余内容),加上今天和明天我这里要停电,所以我想把碰撞等内容放到第二部分讲。

作业

如果要让地图障碍(树桩)绘制出树干的分支,如何改动地图的数据结构?

你可能感兴趣的:(JS游戏开发)