虽然这个小游戏逻辑不是很复杂,但为了熟悉Phaser这个游戏框架的使用方法所以就选择了它。
另外第一次在项目中尝试使用ES6,之后利用babel进行转换。
自动化构建:gulp(其他文件复制和解析) + webpack(负责js的模块打包) + browser-sync(实时预览);
刚开始拿到项目的交互后,对游戏功能进行了分析,然后将整个游戏大致分”游戏启动前、加载、游戏、结束“4个场景。确定场景后,考虑实现的方式。我选择webpack + gulp来打包我的代码, 我的工程目录大致如下所示:
文件目录如下:
.
├── src
│ ├── img //存放图片资源
│ ├── js
│ │ ├── app //一些自己写的库
│ │ ├── lib //第三方库
│ │ ├── prefabs //存放游戏元件
│ │ ├── states //存放游戏场景
│ │ │ ├── boot.js
│ │ │ ├── preload.js
│ │ │ ├── play.js
│ │ │ └── over.js
│ │ └── index.js //程序入口
│ ├── css
│ │ └── style.less
│ └── media //存放媒体文件
├── index.html
├── gulpfile.js
└── webpack.config.js
程序入口
主要是利用es6的class创建一个游戏对象并继承于Phaser.Game,然后将所有的场景添加到Phaser.state中。
class Game extends Phaser.Game { // 子类继承父类Phaser.Game
constructor () { //构造函数
super(width, height, Phaser.CANVAS|Phaser.webgl|Phaser.auto, elementName, null); //通过super来调用父类(Phaser.Game)构造数
this.state.add('Boot', Boot, true); //添加场景
this.state.add('Preload', Preload, true);
this.state.add('Play', Play, true);
this.state.add('Over', Over, true);
this.state.start('Boot'); //启动
}
}
注:关于Phaser的各种对象、方法我就不过多描述了,文档比我写的详细。主要写写我怎么构建这个游戏的吧,哈哈哈~~
游戏启动场景
该场景继承于Phaser.State对象,这样便于切换和构建画面。主要功能对游戏进行适配以及开启游戏的物理引擎。如果加载场景中需要图片可以在这个场景中进行下一场景需要的图片。
注: 游戏中所有场景继承于Phaser.State对象,Phaser.State通常会有preload、create、update、render方法。
export default class Boot extends Phaser.State {
//先预紧力。通常情况下,你会使用这个来装载你的游戏资产(或当前状态所需的)
preload () {}
//创建被称为一次预载完成,这包括从装载的任何资产的装载。
create () {
//show_all规模的模式,展示了整个游戏的同时保持比例看
this.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;
this.scale.pageAlignHorizontally = true; //当启用显示画布将水平排列的
this.scale.pageAlignVertically = true; //当启用显示画布将垂直对齐的
//物理系统启动:phaser.physics.arcade,phaser.physics.p2js,phaser.physics.ninja或相位。物理。Box2D。
this.game.physics.startSystem(Phaser.Physics.ARCADE);
this.state.start('Preload');
}
}
加载场景
在preload方法中对游戏的资源进行加载,加载完成之后进入create然后切换场景。
export default class Boot extends Phaser.State {
preload () {
//...
//加载游戏所需要的资源
}
create () {
this.state.start('Play');
}
}
Phaser给我们提供了各种资源加载的方式,这里列一下我在游戏中加载的资源类型:
单个图加载
image(key, src); //key: 在游戏中使用时的名称、src: 图片地址
this.load.image('bg', imgPath + 'bg.jpg');
雪碧图加载
spritesheet(key, src, 图片单帧的宽, 图片单帧的高, 帧数, margin, spacing); 另外两个参数我使用的默认值
this.load.spritesheet('master', imgPath + 'master.png', 280, 542, 14);
单个音频加载
image(key, src); //key: 在游戏中使用时的名称、src: 音频地址
this.load.audio('bgMusic',[mediaPath + 'bg.mp3']);
雪碧音加载
audiosprite(key, urls, jsonURL, jsonData) //jsonURL:如果通过数据直接设置设为空, jsonData:json数据(可以自己去生成,见音频处理)
this.load.audiosprite('music', mediaPath + 'audio.mp3', null, audioJSON);
游戏场景
游戏的核心都在这一块,先罗列一下我需要实现的功能:
-
背景、云、建筑、地板的移动。
-
点击start按钮倒数
-
三个人物的自动跑。
-
障碍物的生成与移动。
-
能量的生成与移动。
-
点击jump按钮主人物跳起。
-
两个npc遇到障碍物自动跳起。
-
吃到能量后能量条的变化。
-
剩余生命数的显示。
-
replay功能。
下面来看一下怎么具体来实现着一些功能:
首先我将游戏的进行拆分,把所有的元素都写成单独的一个元件,然后将这些元件合起来。大致分为以下(其实还可以细分):
import TopBar from '../prefabs/TopBar'; //顶部
import Person from '../prefabs/Master'; //主人物
import Enemys from '../prefabs/Enemys'; //两个npc
import Obstacles from '../prefabs/Obstacles'; //障碍物
import Bullet from '../prefabs/Bullet'; //子弹(功能目前去掉了)
import Energies from '../prefabs/Energies'; //能量
import Death from '../prefabs/Death'; //死亡画面
准备好之后来实现我需要的功能。
元件的移动
Phaser提供了一个TileSprite的对象给我们使用,我们把需要自动移动的元件利用TileSprite添加到场景中去,下面以云为例:
//定义一个移动的基准速度,然后通过这个速度去实现不同速度的移动
this.gameSpeed = 300;
//云
this.cloud = new Phaser.TileSprite(this.game, 0, 132, this.game.width, 408, 'cloud'); //添加到场景中
//TileSprite(game, x|坐标, y|坐标, width|宽, height|高, key|图片名, frame|指定帧数,默认第一帧)
this.cloud.fixedToCamera = true; //固定
this.cloud.autoScroll(-this.gameSpeed / 8 , 0); // 元件移动
注:移动主要靠
autoScroll()
来进行自动移动。 停止移动stopScroll()
;
其他的元件移动方法跟这个一样的操作,只是速度不同而已。
人物自动跑
这个其实不用考虑,只要一直运行人物跑的动画,然后背景和地板等移动,这样人物就跑起来了。所有首先要做的是将人物添加动画并绘制在场景中。
- 先用Sprite构建一个人物对象:
export default class Person extends Phaser.Sprite {
constructor ({game, x, y, asset, frame, floor}) {
super(game, x, y, asset, 0);
//... 人物的初始化设置
}
}
- 然后添加animations()添加需要的动画。我把人物动画大致分成’初始化、跑、跳、死亡、通过‘代码如下:
//参数: 使用时候的name、 动画运行的帧、time、重复运行
this.animations.add('init',[0], 10, false);
this.animations.add('run',[1,2,3,4,5,6], 20, true);
this.animations.add('jump', [7], 10, false);
//外部使用: obj.animations.play('run');
- 要让人物在地板上跑,这里要用到碰撞检测,Phaser提供了检测的方法,我们添加上就可以。首先开启人物与地板的物理系统,然后利用碰撞检测 的方法检测人物是否落在地板上,代码大致如下:
this.game.physics.arcade.enable(人物对象); //开启人的物理系统
this.body.gravity.y = 1600; //设置人物的重力
this.game.physics.enable(地面对象); //开启地面物理系统
this.floor.body.immovable = true; //这里需要将地面设置为固定不动
this.game.physics.arcade.collide(人物对象, 地面对象,callback); //在update方法里用collide去实时检测这两个元件是否有接触
注: 另外可以用
人物对象.body.setSize(130, 522, 75, 0);
去设置元件的碰撞范围,这里要让人物看起来跑在地面是上所以需要对地面进行接触面的设置。
点击start按钮倒数
- 这个功能比较简单,在绘制按钮的时候刚开始我是用两个图去绘制两个按钮,然后我发现Button这个对象可以去设置当前显示帧数,所以后面我将两个按钮 合成一张图,然后去改变显示的帧数,刚设置完的时候,出现了jump按钮一直显示第一帧的情况,因为Button它有几种状态,然而我只设置了一种, 其它的状态都被设置成了默认的。设置代码基本如下:
startBtn = new Phaser.Button(game, x, y, 'btn', null, null, 0, 0);
jumpBtn = new Phaser.Button(game, x, y, 'btn', null, null, 1, 1);
//这里设置第一个null,当按钮按下时的callback。第二个null,callback的上下文环境。
- 按钮设置完成之后就是添加时间和倒数的功能了,Phaser添加事件比较简单,代码如下:
startBtn.inputEnabled = true;
startBtn.input.pixelPerfectClick = true; //精确点击
startBtn.events.onInputDown.addOnce(function(){}, this);
注:这里用
addOnced
的原因是我的开始按钮只点击一次,其他的用add
添加即可。
- 倒数功能直接用
setInterval
实现即可,主要是利用loadTexture
去改变每次显示的帧数来达到数字的切换。
障碍物、能量的生成与移动
首先分析简单分析障碍物与能量有哪些对外的方法“修改图片、设置速度、停止移动、隐藏、重置位置”。接下了就是实现着一些方法。之前想着障碍物会无限循环的出来,这个点想了
比较久,因为如果每次都去创建一个新的障碍物,那么假设有100个障碍物这样就会创建100次,这样资源就会出现浪费,也会出现性能上的问题。因为Phaser中提供kill()
和reset()
方法,所以可以利用一下。大致就是假设创建5个障碍物对象,每次当障碍物移出左边屏幕的时候,将它kill掉然后用reset去重置当前这个障碍物的位置,这样
场景中永远都只有这几个在重复利用了。大致实现代码如下所示:
this.createMultiple(num, asset, 0, false); //创建num个贴图为asset的元件
//添加每个元件的信息
let obstacle;
for(var i = 0; i< this.num; i++){
let EnergyX = (i * this.distance) + (this.distance * this.distanceThan[i]) + 110;
let EnergyY = Math.floor(this.game.height-295 -140);
//设置元件的物理属性、触碰大小、动画、基点位置等。
//..
}
this.lastObstacle = obstacle; //保存最后一个信息
//在update中判断是否移出屏幕将其kill,然后重置对象
updata() {
this.forEach((obstacle)=>{
if (obstacle.body.right <= 0) {
obstacle.kill();
//..
}
},this);
this.forEachDead((obstacle)=>{
obstacle.reset(x,y);
//...
this.lastObstacle = obstacle;
},this);
}
注:
forEachDead
循环死亡对象。
最后因为障碍物时固定的所以我把这一部分功能剔除掉了,在这里还有一个就是由于能量的个数只有3个,所以我用了个投机取巧的办法去让这个障碍物与能量对应起来。 就是用两个数组,去固定相应位置。
人物的跳起
人物跳起的核心就是去改变人物的重力velocity.y
代码如下所示:
jumpEvent () {
if(this.isMasterJump) return;
this.master.body.velocity.y = -700;
//播放跳起动画...
}
//接下来只要在update中检测人物与地面再次接触即可
updata () {
this.game.physics.arcade.collide(this.master, this.floor, ()=>{
//人物跳起落地
if(!this.isDown && this.master.body.touching.down) {
this.isMasterJump = false;
this.isDown = true;
this.master.animations.play('run');
}
}, null, this);
}
注:
obj.body.touching.down
这个属性当有检测多个碰撞是都会触发。
主人物的跳起功能完成,接下来就是NPC的自动跳起,大致的思路就是得到障碍的位置,然后根据位置去执行NPC的动画。其中利用forEachExists
去实时检测障碍物的位置
这个方法会返回当前元件的信息,里面包含位置信息。代码大致如下:
updata () {
this.obstacles.forEachExists(this.checkObstacle,this); // 检测柱子位置
}
checkObstacle () {
this.enemys.enemy1.checkJump(obstacle);
this.enemys.enemy1.checkDown();
//..其他操作
}
//检测是否跳起
checkJump (obstacle) {
if(!this.jump && obstacle.x - this.x < 57 && obstacle.x - this.x > 0){
//..
};
}
//检测是否落地
checkDown () {
if(!this.isDown && this.body.touching.down && this.jump) {
//..
};
}
注: 这里关闭NPC与障碍物得碰撞检测,不然当NPC碰到障碍物
body.touching.down=true
这个结果不是我们想要的。
能量条、生命数的显示与变化
首先用Sprite
对象绘制出生命图形以及能量条,然后对外暴露出“更新、显示、隐藏”等方法,这里能量条的变化利用crop()
配合Rectangle()
得到需要显示的地方。代码如下:
//创建能量条
this.energyBg = new Phaser.Sprite(this.game, 0, 33, 'energyBar', 0);
this.energyCover = new Phaser.Sprite(this.game, 0, 33, 'energyBar', 1);
//创建3条生命
for (var i = 0; i< 3; i++) {
var x = (i * 43)+3;
var key = 0;
if(i >= this.life) {key = 1;} //如果有死亡显示的图形
let sprite = new Phaser.Sprite(this.game, x, 33, 'heart',key);
sprite.animations.add('death',[1], 10, false);
this.heartGroup.add(sprite);
}
//更新能量条
updateEnergy () {
let distance = this.energyBg.width * (3-this.score) / 3;
this.energyCover.x = distance;
this.energyCover.crop(new Phaser.Rectangle(distance, 0, this.energyBg.width * (this.score / 3), 35)); //裁切一个矩形区域
this.energyCover.updateCrop(); //更新
}
replay功能
这里我的做法比较粗暴,直接state.start('Play')
。
至此游戏的大体功能都实现了,剩下的就是结束场景然后就是调试与测试了。
结束场景
最后就是游戏结束之后会跳转到这个场景,之后的逻辑可以在create中编写。
export default class Over extends Phaser.State {
preload () {}
create () {
//...通过逻辑
}
}
音频处理
因为用到了雪碧音,如果自己去合成雪碧音的换修改和替换起来会比较麻烦所以在npm找了个合成雪碧音的工具:audiosprite。
于是就写了个简单的音频合成代码:
var audiosprite = require('audiosprite')
var files = ['file1.mp3', 'file2.mp3'];
var opts = {
output: 'audio',
format: 'jukebox',
export: 'mp3',
loop: 'false'
}
audiosprite(files, opts, function(err, obj) {
if (err) return console.error(err)
console.log(JSON.stringify(obj, null, 2))
})
输出json格式:
{
"resources": [
"audio.mp3"
],
"spritemap": {
"file1": {
"start": 0,
"end": 1.2026984126984126,
"loop": false
},
"file2": {
"start": 3,
"end": 4.202698412698412,
"loop": false
}
}
}
之后把这个json数据复制到音频加载那里就可以了,
重点是音频修改起来方便只要运行一下这个js,然后替换下json数据就可以了。
最后再说点吧,虽然这个小游戏比较简单,但是让我用另外一种思维去思考问题。代码方面写法比较粗糙,还要去写更多的练习去磨练自己。期待下次自己的进步吧!
文章中Phaser的各类方法我就没细说了,具体使用看文档吧,Phaser的话demo超多,文档写的也比较详细了,帮了我不少忙了。 文章中不配游戏截图因为我太懒了~~~
附上Phaser文档http://phaser.io/docs/2.6.2/index