实现一个像素鸟
Phaser入门教程
有了前面的入门教程,这篇教程理解起来会简单很多。先来张图看看游戏效果。
本教程源码地址:
码云
github
现在开始分析这个游戏如何制作。我们首先添加几个游戏场景。
game.states = {};
game.states.boot = function(){} //引导
game.states.preloader = function(){} //加载资源,显示进度条
game.states.menu = function(){} //游戏菜单界面
game.states.start = function(){} //游戏界面
game.states.stop = function(){} //游戏结束界面
game.state.add('boot', game.states.boot);
game.state.add('preloader', game.states.preloader);
game.state.add('menu', game.states.menu);
game.state.add('start', game.states.start);
game.state.add('stop', game.states.stop);
game.state.start('boot'); //首先启动boot场景
接下来先完善这两个场景,这两个场景很简单。
var game = new Phaser.Game(WIDTH, HEIGHT, Phaser.AUTO, 'game');
game.states = {};
// 引导
game.states.boot = function() {
this.preload = function() {
this.load.image('loading', 'assets/image/progress.png');
},
this.create = function() {
this.state.start('preloader');
}
}
// 用来显示资源加载进度
game.states.preloader = function() {
this.preload = function() {
var loadingSprite = this.add.sprite((this.world.width - 311) / 2, this.world.height / 2, 'loading');
this.load.setPreloadSprite(loadingSprite, 0);
this.load.image('bg', 'assets/image/bg.png');
this.load.atlasJSONHash('ui', 'assets/image/ui.png', 'assets/image/ui');
this.load.audio('die', 'assets/audio/die.wav');
this.load.audio('music', 'assets/audio/bg.mp3');
},
this.create = function() {
this.state.start('menu');
}
}
boot场景加载一个进度条资源,然后preloader里设置这个进度条,并加载其它资源。刷新应该能看到这个进度条,本地加载过快的话可能直接就闪过了。其中加载资源的时候使用到了this.load.atlasJSONHash,这个方法加载一个纹理图集文件。什么是纹理图集文件呢?在资源包assets下的image文件夹里有个ui.png(下图所示),同时还有个ui文件,这个ui.png文件就是纹理文件,原理就是把其它的图片都打包到这一个大图上,而相应的那个ui文件就是一个json hash文件,打开你就能看到是一个json的数组文件,里面描述了这个ui.png里包含的图片和图片所对应的在这张大图上的坐标。而那些打包的小图还在文件夹里只是给大家看着对应,不需要打开ui这个json文件来查找(●’◡’●)
现在实现菜单场景。一个背景图,一只小肥鸟不停地在扑腾着减肥。
// 游戏菜单
game.states.menu = function() {
this.create = function() {
this.bg = this.add.sprite(0, 0, 'bg');
this.bird = this.add.sprite(0, 0, 'ui', 'bird1.png');
this.bird.anchor.set(0.5);
this.bird.x = game.world.centerX;
this.bird.y = game.world.centerY - 100;
this.bird.animations.add('bird_fly', ['bird1.png', 'bird2.png', 'bird3.png'], 20, true, false);
this.bird.animations.play('bird_fly');
this.startButton = this.add.sprite(0, 0, 'ui', 'button.png');
this.startButton.anchor.set(0.5);
this.startButton.x = game.world.centerX;
this.startButton.y = this.bird.y + this.bird.height + this.startButton.height / 2;
this.startButton.inputEnabled = true;
this.startButton.input.useHandCursor = true;
this.startButton.events.onInputDown.add(this.startGame, this);
},
this.startGame = function() {
this.bg.destroy();
this.bird.destroy();
this.startButton.destroy();
game.state.start('start');
}
}
为了简单直接给小肥鸟的锚点设为中心,这样直接给小鸟的x和y赋值为舞台场景的中心坐标就把小鸟居中了。从加载的纹理图集中加载我们需要的资源,需要指定一个key,这样就能找到我们要的资源,比如:this.add.sprite(0, 0, ‘ui’, ‘bird1.png’),其中那个参数ui就指定了从那个纹理图集中加载,最后一个参数指定要加载那个图片。
this.bird.animations.add('bird_fly', ['bird1.png', 'bird2.png', 'bird3.png'], 20, true, false);
这句代码是给小鸟添加一个动画,第一个参数是动画名称,后面直接播放动画的时候,用play调用这个动画名就开始播放了。第二个参数是个数组指定要添加到这个动画里的资源图片。第三个参数表示动画播放速度,一秒钟播放多少次。第四个参数表示开始循环。第五个参数的意思就是第二个参数数组里是使用数字索引还是直接用资源名,这里赋值false表示用资源名。
现在菜单界面就出来了。
接着分析游戏界面。
从最上面的演示图看到,游戏界面有个背景色,最下面有一排建筑,建筑上面有云层,都在缓慢的移动,小鸟只是上下移动,顺便变换着角度。移动最快的是烟囱,上下各一个,组成一组。这些元素移动速度从快到慢依次是烟囱,建筑,云层。这样子的设计是有个一个层次感,云层最远,所以移动速度最慢,建筑次之,烟囱最快,它是我们的主要道具。
首先来设计这个小鸟的上下运动。先开启物理引擎,这里用到phaser推荐的简单的物理引擎ARCADE。
game.physics.startSystem(Phaser.Physics.ARCADE); //启动物理引擎
game.physics.arcade.enable(this.bird); //对小鸟使用物理引擎。
对小鸟使用物理引擎后通过对小鸟的body属性的重力属性设置一个y轴方向的速度,它就开始运动了。比如:
this.bird.body.gravity.y = 1000
小鸟就急速下降。来张坐标图分析下小鸟的运动。
如图,当给小鸟的this.bird.body.gravity.y 设一个负值的时候它就会向上运动。小鸟的默认角度是0°,90°是垂直向下的。
我们给小鸟一个向上飞的方法。
this.fly = function() {
this.bird.body.velocity.y = -350; //给鸟设一个向上的速度
game.add.tween(this.bird).to({angle:-30}, 100, null, true, 0, 0, false); //上升时头朝上的动画
}
然后在create方法里添加如下代码给鼠标点击的时候触发这个飞翔的方法。
game.input.onDown.add(this.fly, this); //给鼠标按下事件绑定鸟的飞翔动作
首先在y轴上向上移动350像素,然后通过一个tween动画在100毫秒内把小鸟的角度转到-30°,即向上转。
当小鸟下落的时候我们给它一个转向的动作,即最终转到头朝下的方向。我们在update方法里添加如下代码:
this.update = function() {
if(this.bird.angle < 90) {
this.bird.angle += 2.5;//一直朝下旋转鸟头
}
}
刷新看下结果。
每次点击的时候小鸟会向上转30°(-30°),同时上升350像素(-350),如果不点击,那么小鸟的下落速度是1000,同时会转到90°(update里添加的代码)。
对小鸟的分析就到此为止,下面分析烟囱的创建与移动。
查看素材包下的image文件夹,我们发现烟囱是由这两个图片构成的。
上面是烟囱头,下面是烟囱体。要形成一个烟囱,我们需要一个烟囱头加上多个烟囱体组成。烟囱应该有多长呢?一屏幕最多出现多少对烟囱呢?
每次烟囱都是成对出现的,一个在屏幕上方,一个在屏幕下方,只是每次这两个烟囱的坐标位置不固定,我们设定上方的烟囱跟下方的烟囱之间的距离是180像素(让我们的小肥鸟容易通过)。我们跟据屏幕高度来创建烟囱。
var chimney = game.add.sprite(100, 0, 'ui', '6.png');
chimney.anchor.set(0.5);//烟囱头的锚点设为中心点
game.physics.arcade.enable(chimney);//对烟囱头启动物理引擎
var halfH = chimney.height / 2;//烟囱头高度的一半
var body = game.add.sprite(0, 0, 'ui', '9.png');//烟囱体
body.anchor.set(0.5, -0.5);//同样设置锚点,相对与烟囱头的锚点
game.physics.arcade.enable(body);//启动物理引擎
chimney.addChild(body);
var n = Math.floor((game.height - halfH) / body.height);//计算还需要多少烟囱体
for (var j = 0;j < n;j++) {
var b = game.add.sprite(0, 0, 'ui', '9.png');
b.anchor.set(0.5, -0.5 - (j + 1));
chimney.addChild(b);
game.physics.arcade.enable(b);
}
其中chimney.addChild(body)是把烟囱体作为烟囱头的一部分,这样在移动烟囱头的时候烟囱体会跟着移动。我们看下图来解释这里的实现方式。
烟囱体是作为烟囱头的一部分的,所以设置锚点的时候要根据烟囱头来设置,如图所示烟囱头的锚点在中心点,继续朝上的话需要加一个对应的值,朝下要减去一个对应的值,比如它跟第一个烟囱体接触的地方的中心点的锚点值是(0.5,0),所以第一个烟囱体的锚点是(0.5,-0.5),朝下加一个烟囱体,它的锚点就累加一个-1。
现在来看看头朝下的烟囱怎么添加?是不是角度转180°就好了?试试。
var chimney = game.add.sprite(100, game.height, 'ui', '6.png');
chimney.anchor.set(0.5);//烟囱头的锚点设为中心点
chimney.angle = 180;
game.physics.arcade.enable(chimney);//对烟囱头启动物理引擎
var halfH = chimney.height / 2;//烟囱头高度的一半
var body = game.add.sprite(0, 0, 'ui', '9.png');//烟囱体
body.anchor.set(0.5, -0.5);//同样设置锚点,相对与烟囱头的锚点
game.physics.arcade.enable(body);//启动物理引擎
chimney.addChild(body);
var n = Math.floor((game.height - halfH) / body.height);//计算还需要多少烟囱体
for (var j = 0;j < n;j++) {
var b = game.add.sprite(0, 0, 'ui', '9.png');
b.anchor.set(0.5, -0.5 - (j + 1));
chimney.addChild(b);
game.physics.arcade.enable(b);
}
果然出来了,我们用debug来输出下精灵的区域看看有没有问题。
this.render = function() {
game.debug.body(chimney);
chimney.children.forEach(function(item) {
game.debug.body(item);
});
}
给场景添加一个render方法就可以调试精灵了。
我们发现烟囱体都在下面,这样子就尴尬了,小鸟在烟囱体显示的部分能通过,在空白处却被碰撞,,,ԾㅂԾ,,
我们通过给烟囱体都设置180°,同时改下锚点(前面有图文分析)。代码如下:
chimney = game.add.sprite(100, 300, 'ui', '6.png');
chimney.anchor.set(0.5);//烟囱头的锚点设为中心点
chimney.angle = 180;//角度转动180°
game.physics.arcade.enable(chimney);//对烟囱头启动物理引擎
var halfH = chimney.height / 2;//烟囱头高度的一半
var body = game.add.sprite(0, 0, 'ui', '9.png');//烟囱体
body.anchor.set(0.5, 1.5);//同样设置锚点,相对与烟囱头的锚点
body.angle = 180;//角度转动180°
game.physics.arcade.enable(body);//启动物理引擎
chimney.addChild(body);
var n = Math.floor((game.height - halfH) / body.height);//计算还需要多少烟囱体
for (var j = 0;j < n;j++) {
var b = game.add.sprite(0, 0, 'ui', '9.png');
b.angle = 180;//角度转动180°
b.anchor.set(0.5, 1.5 + (j + 1));
chimney.addChild(b);
game.physics.arcade.enable(b);
}
这次正常了,烟囱体的物理位置跟显示位置一致了。
(这部分测试是在menu里做的)
我们允许一屏幕最多出现5对烟囱,那么创建两个分组,一个放上面的烟囱,一个放下面的烟囱,每一对烟囱移出屏幕的时候都设置为死亡的(dead),然后回收使用。
初始化烟囱的代码如下,之前都有详细解释。
// 随机10个烟囱
function initChimney() {
initUpChimney();
initDownChimney();
ChimneyGroupsUp.setAll('checkWorldBounds',true); //边界检测
ChimneyGroupsUp.setAll('outOfBoundsKill',true); //出边界后自动kill
ChimneyGroupsDown.setAll('checkWorldBounds',true); //边界检测
ChimneyGroupsDown.setAll('outOfBoundsKill',true); //出边界后自动kill
}
function initUpChimney() {
for(var i = 0;i < MaxChinmeyNum;i++) {
var chimney = game.add.sprite(game.width, game.height, 'ui', '6.png');
chimney.scored = false;
chimney.anchor.set(0.5);
chimney.angle = 180;//角度转动180°
game.physics.arcade.enable(chimney);
var halfH = chimney.height / 2;
var body = game.add.sprite(0, 0, 'ui', '9.png');
body.anchor.set(0.5, 1.5);
body.angle = 180;//角度转动180°
game.physics.arcade.enable(body);
chimney.addChild(body);
var n = Math.floor((game.height - halfH) / body.height);
for (var j = 0;j < n;j++) {
var b = game.add.sprite(0, 0, 'ui', '9.png');
b.anchor.set(0.5, 1.5 + (j + 1));
b.angle = 180;//角度转动180°
chimney.addChild(b);
game.physics.arcade.enable(b);
}
chimney.alive = false;
chimney.visible = false;
ChimneyGroupsUp.add(chimney);
}
}
function initDownChimney() {
for(var i = 0;i < MaxChinmeyNum;i++) {
var chimney = game.add.sprite(game.width, 0, 'ui', '6.png');
chimney.anchor.set(0.5);//烟囱头的锚点设为中心点
game.physics.arcade.enable(chimney);//对烟囱头启动物理引擎
var halfH = chimney.height / 2;//烟囱头高度的一半
var body = game.add.sprite(0, 0, 'ui', '9.png');//烟囱体
body.anchor.set(0.5, -0.5);//同样设置锚点,相对与烟囱头的锚点
game.physics.arcade.enable(body);//启动物理引擎
chimney.addChild(body);
var n = Math.floor((game.height - halfH) / body.height);//计算还需要多少烟囱体
for (var j = 0;j < n;j++) {
var b = game.add.sprite(0, 0, 'ui', '9.png');
b.anchor.set(0.5, -0.5 - (j + 1));
chimney.addChild(b);
game.physics.arcade.enable(b);
}
chimney.alive = false;
chimney.visible = false;
ChimneyGroupsDown.add(chimney);
}
}
其中ChimneyGroupsUp和ChimneyGroupsDown是两个分组,使用setAll方法给分组里所有的对象设置属性(也可以用for循环单独设置)。
烟囱初始化完成,多长时间出一个烟囱以及烟囱的位置如何确定呢?
//移动一个烟囱
function moveAChimney() {
var up = ChimneyGroupsUp.getFirstDead();
if (up == null) {
return;
}
var y = game.rnd.between(minChimneyHeight, maxChimneyHeight);
up.reset(game.width, y)
up.scored = false;
up.body.velocity.x = speed;
var down = ChimneyGroupsDown.getFirstDead();
if (down != null) {
down.angle = 0;
down.reset(game.width, y + distance);
down.body.velocity.x = speed;
}
}
getFirstDead是从分组里取出第一个没有使用的或是已经出了舞台场景后被kill掉的对象,这里循环使用这5对烟囱。
这里minChimneyHeight跟maxChimneyHeight是我们设定的烟囱在这个舞台场景上最高的位置和最低的位置,不能跨度太过大,不然一个最高一个最低是没有足够时间穿过的。
精灵对象的reset方法重新设置它的位置到屏幕边缘,同时恢复一些被kill方法修改掉的属性(比如alive存活、visible可视等等)。
最后在create方法里添加一个时钟事件来每隔1500毫秒重新取出一对烟囱。
game.time.events.loop(1500, moveAChimney, this); //利用时钟事件来循环产生管道
要做碰撞检测,需要在create方法里调用如下代码:
ChimneyGroupsUp.forEach(function(Chimney) {
game.physics.arcade.collide(this.bird, Chimney);//烟囱头
Chimney.children.forEach(function(item) {//烟囱体
game.physics.arcade.collide(this.bird, item);
}, this);
}, this);
ChimneyGroupsDown.forEach(function(Chimney) {
game.physics.arcade.collide(this.bird, Chimney);//烟囱头
Chimney.children.forEach(function(item) {//烟囱体
game.physics.arcade.collide(this.bird, item);
}, this);
}, this);
对分组里的所有对象以及这个对象的子对象都进行碰撞检测(烟囱和烟囱体都开启了物理引擎检测)。
添加背景。在preload里添加如下加载代码:
game.stage.backgroundColor = day;//设置背景色为白天
this.cloud1 = this.add.tileSprite(0, game.height - 68 * 2, game.width, 95, 'ui', 'cloud1.png');
this.cloud1.autoScroll(speed / 10, 0);//自动重复滚动
this.cloud2 = this.add.tileSprite(0, game.height - 68 * 2, game.width, 95, 'ui', 'cloud2.png');
this.cloud2.autoScroll(speed / 10, 0);//自动重复滚动
this.cloud2.visible = false;//隐藏
this.building1 = this.add.tileSprite(0, game.height - 68, game.width, 68, 'ui', 'building1.png');
this.building1.autoScroll(speed / 5, 0);//自动重复滚动
this.building2 = this.add.tileSprite(0, game.height - 68, game.width, 68, 'ui', 'building2.png');
this.building2.autoScroll(speed / 5, 0);//自动重复滚动
this.building2.visible = false;//隐藏
这样背景就开始移动了。
碰撞检测与计算得分
this.update = function() {
if(this.bird.x <= 0 || this.bird.x >= game.width || this.bird.y <= 0 || this.bird.y >= game.height) {
this.gameOver();
}//小鸟飞出或是跌落出舞台
ChimneyGroupsUp.forEach(function(Chimney) {
if (Chimney.x + Chimney.width / 2 < this.bird.x + this.bird.width / 2 && Chimney.scored === false) {
Chimney.scored = true;
score += 1;
changeBGScore += 1;
updateScore();
}//如果小鸟飞过了烟囱,加一分
game.physics.arcade.overlap(this.bird, Chimney, this.gameOver, null, this);
Chimney.children.forEach(function(item) {
game.physics.arcade.overlap(this.bird, item, this.gameOver, null, this);
}, this);//如果小鸟跟烟囱或是烟囱体有碰撞直接结束游戏
}, this);
ChimneyGroupsDown.forEach(function(Chimney) {
game.physics.arcade.overlap(this.bird, Chimney, this.gameOver, null, this);
Chimney.children.forEach(function(item) {
game.physics.arcade.overlap(this.bird, item, this.gameOver, null, this);
}, this);
}, this);
if(this.bird.angle < 90) {
this.bird.angle += 2.5;//一直朝下旋转鸟头
}
if (changeBGScore === changeScore) {//看看是否该切换背景了
changeBGScore = 0;//清空统计
if(this.cloud2.visible === false) {//切换到黑夜
this.cloud2.visible = true;
this.building2.visible = true;
this.cloud1.visible = false;
this.building1.visible = false;
game.stage.backgroundColor = night;
} else {//切换到白天
this.cloud2.visible = false;
this.building2.visible = false;
this.cloud1.visible = true;
this.building1.visible = true;
game.stage.backgroundColor = day;
}
}
}
切换场景的时候清理资源。
// 每次游戏重新开始的时候,清空保存的烟囱数组
function clearChimney() {
console.log("清理所有烟囱");
ChimneyGroupsUp.forEach(function(Chimney) {
Chimney.destroy();
});
ChimneyGroupsUp.destroy();
ChimneyGroupsDown.forEach(function(Chimney) {
Chimney.destroy();
});
ChimneyGroupsDown.destroy();
}
this.gameOver = function() {
this.bang.play();
clearChimney();//清空保存的烟囱数组
localStorage.setItem("bird_topScore", Math.max(score, topScore));
updateScore();
this.music.stop();
this.cloud1.destroy();
this.cloud2.destroy();
this.building1.destroy();
this.building2.destroy();
this.bird.destroy();
this.bang.destroy();
this.music.destroy();
game.state.start('stop');
}
这样游戏就完成了。下次打算写一个塔防或者跑酷的游戏教程来。