摘要
我们已经从最基础的画线填充、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() {
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值。
下面是输出结果,我没办法展示触屏,不过可以在控制台看到事件发生时候的输出文字:
障碍物地图
先制定一个障碍物规则:
- 障碍物是树桩,每个树桩的大小一致,且障碍物的高度能被游戏屏幕高度整除(就是说一个屏幕高度能放下整数个障碍物)
- 障碍物从屏幕的顶部和底部开始往中间增加,但至少会留一个缺口,缺口大小和障碍物一致(让小鸟飞过);缺口数量不会超过每列障碍物总数 - 1(意思是说,每列障碍物至少要有一个)
- 每一列障碍物之间的间隔至少是一个障碍物的宽度; 最多间隔m个障碍物宽度(m的值可以根据需要调整)
- 障碍物旁边可有装饰图片(树枝一类的),但不作为障碍物的一部分,即小鸟碰到后不算碰到障碍物。
下面是我绘制的大致的障碍物图:
上图是利用多个独立的图片进行绘制的,如果你了解游戏地图的制作,就知道这是利用图块(tile)拼成的:
每一列障碍物是由多个不同图块拼接而成,整个游戏的障碍物图就是多列障碍物的组合。
假设我们每一列分成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();
}
}
测试结果如下:
红色区域是整个地图的大小,这样看来这个TileMap应该是没问题的。
我没有绘制装饰树杈,后面代码也不会写,这个需要加入一些数据到地图数据中,为了简化我就懒得做了
卷轴地图移动
就如我之前说过的,整个游戏中,小鸟是不会横向移动的,但地图会移动,这样看上去就好像小鸟一直在往前飞行。
那什么是卷轴地图呢?我画了个图,看了就懂了:
我们的游戏里这样规定:
- 有两个不同的地图首尾相连,即: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以及树桩的宽度一定要合适。这个不属于讨论范畴就不讲了。
我将这个游戏加入一个背景作为参照,重新设置了小鸟的大小以及障碍地图的行、列以及每列宽度,感受一下:
小结
这期本来想要将整个游戏开发全部讲完的,写着写着发现写了好多(我还删了一些多余内容),加上今天和明天我这里要停电,所以我想把碰撞等内容放到第二部分讲。
作业
如果要让地图障碍(树桩)绘制出树干的分支,如何改动地图的数据结构?