前言
最近忙着看项目和写项目,在 github 上无意中发现了别人用 phaser 实现的2d小游戏,代码简单易懂,而且phaser框架本身就是非常的简单,非常适合想快速开发小游戏的开发者。但是国内关于 phaser3 的资料和教程甚少,于是笔者阅读官方学习文档简单实现了一个 2d 小游戏。因为官网本身有中文版的游戏实现教程,所以我在这里也就不做详细解读了,更多的是从学习笔记的角度来进行书写。
游戏源码和在线demo
如果你直接 push 下来源码在本地双击 html 文件是无法运行出来游戏的,该h5游戏需要在服务器环境中运行。因此笔者推荐使用vscode打开源文件,然后运行 firstgame.html 文件即可。
笔者这里建议可以优先把源码 clone 下来,tensteps文件夹是游戏开发中代码构建的过程,在看教程的过程中,可以参考。
在线demo也可以帮助理解。
准备工作
开发环境:vscode。并在vscode中安装 liveserver 插件,以创造服务器环境,满足h5游戏运行的条件。
phaser是否需要下载.? 我本人的建议是:直接在 html 文件中使用 cdn 即可。这里提供一下 phaser 的源码下载地址 ,供感兴趣的读者使用。
框架构建和解析
- 新建文件夹 自定义名称
- 将源文件中的素材文件 assets 文件夹复制到文件夹下。
- 文件夹内新建 html 文件,这将是我们接下来编写代码的地方。
首先搭建整个游戏的框架。我们之后的代码将会在此框架下不断拓展。
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Making your first Phaser 3 Game - Part 1</title>
<script src="//cdn.jsdelivr.net/npm/phaser@3.11.0/dist/phaser.js"></script>
<style type="text/css">
body {
margin: 0;
}
</style>
</head>
<body>
<script type="text/javascript">
var config = {
type: Phaser.AUTO,
width: 800,
height: 600,
scene: {
preload: preload,
create: create,
update: update
}
};
var game = new Phaser.Game(config);
function preload ()
{
}
function create ()
{
}
function update ()
{
}
</script>
</body>
</html>
config 对象中包含了游戏的各种配置:画布显示的长宽,游戏的场景配置,type 指游戏的整体渲染。可以是Phaser.CANVAS
,或者Phaser.WEBGL
,或者Phaser.AUTO
。我们在这里使用 AUTO
,它将自动尝试使用WebGL,如果浏览器或设备不支持,它将回退为Canvas。
scene
配置包含了 preload,create 和 update 。
- preload 方法可自定义。使用它来加载各种自定义资源(图片,音乐等)。此方法由场景管理器在 init() 之后和 create() 之前调用,前提是场景具有 LoaderPlugin。
- create 方法可自定义。使用它来创建您的游戏对象。通常在在 init() 和 preload() 之后被调用。
- update 方法需要自行重写,当游戏运行时,该方法在每个游戏步骤中被调用。(英文原句为:This method is called once per game step while the scene is running. 翻译很一般 请自行体会。)因此我们可以考虑之后人物的走路判定方法编写在 update 方法中即实现游戏运行时其每一阶段都被调用一次。
资源加载
preload
框架搭建成功之后,我们将加载游戏所需要的资源。
function preload ()
{
this.load.image('sky', 'assets/sky.png');
this.load.image('ground', 'assets/platform.png');
this.load.image('star', 'assets/star.png');
this.load.image('bomb', 'assets/bomb.png');
this.load.spritesheet('dude', 'assets/dude.png', { frameWidth: 32, frameHeight: 48 });
}
这样将加载5个资源:4张图(image)和一个精灵表单(sprite sheet)。图片用于游戏中的各种场景显示,背景以及人物等等。
精灵表单则用于人物在走路时的动态显示。
create
要显示我们之前在 preload 中引入的各种资源的话,我们需要在 create 函数中使用 this.add.image()
方法。
我们先尝试加载一片蓝天与一颗星星。
function create ()
{
this.add.image(400, 300, 'sky');
this.add.image(400, 300, 'star');
}
只需要先加入add ’sky‘ 再add ’star‘ 即可。400
和300
是图像坐标的x值和y值。为什么是400和300呢?这是因为,在Phaser 3 中,所有游戏对象的定位都默认基于它们的中心点。这个背景图像的尺寸是800 x 600像素,所以,如果我们显示它时将它的中心定在0 x 0,你将只能看到它的右下角。如果我们显示它时定位在400 x 300,你能看到整体。
构建游戏世界
从现在开始我们将开始构建游戏内的各种场景,根据在线 demo 可知,我们需要几个平台以及背景图片。因此我们需要在create()
函数中添加以下代码:
var platforms;
function create ()
{
this.add.image(400, 300, 'sky');//生成游戏的图片背景天空
platforms = this.physics.add.staticGroup();//添加物理组件组平台
platforms.create(400, 568, 'ground').setScale(2).refreshBody();
platforms.create(600, 400, 'ground');
platforms.create(50, 250, 'ground');
platforms.create(750, 220, 'ground');
}
我们在其中使用了this.physics
因此之前的游戏配置 config
也需要修改。修改后的config
:
var config = {
type: Phaser.AUTO,
width: 800,
height: 600,
physics: {
default: 'arcade',
arcade: {
gravity: { y: 300 },
debug: false
}
},
scene: {
preload: preload,
create: create,
update: update
}
};
新增了 physics 属性。
这些代码修改之后,我们的游戏场景就搭建成功了。效果如图下:
平台搭建代码详解
把目光再次锁定再我们刚刚修改过的create
函数上,我们新增了一张天空的照片用作游戏的背景。
然后我们引入了platforms
变量。
platforms = this.physics.add.staticGroup();
这一句生成一个静态物理组(Group),并把这个组赋值给局部变量platforms
。在Arcade物理系统中,有动态的和静态的两类物体(body)。动态物体可以通过外力比如速度(velocity)、加速度(acceleration),得以四处移动。它可以跟其他对象发生反弹(bounce)、碰撞(collide),此类碰撞受物体质量和其他因素影响。
与此明显不同的是,静态物体只有位置和尺寸。重力对它没有影响,你不能给它设置速度,有东西跟它碰撞时,它一点都不动。名副其实,完全是静态的。因此将静态物体用作地面和平台很完美,我们打算让玩家在上面来回跑动。
那么什么是组呢?如其名所示,是把近似对象组织在一起的手段,控制对象全体就像控制一个统一的个体。你也可以检查组与其他游戏对象之间的碰撞。组能够生成自己的游戏对象,这是通过便利的辅助函数如create
实现的。物理组会自动生成已经开启物理系统的子项(children),免得你使用多余的重复代码去新建更多的平台变量。
平台组做好了,我们现在可以用它生成平台:
platforms.create(400, 568, 'ground').setScale(2).refreshBody();
platforms.create(600, 400, 'ground');
platforms.create(50, 250, 'ground');
platforms.create(750, 220, 'ground');
上述代码的第一行,添加一张新的地面图像到400 x 568的位置(请记住,图像定位基于中心点)——问题是,我们需要这个平台撑满游戏的宽度。否则玩家就会掉出边界。
要做到这一点,我们用函数setScale(2)
把它按x2(两倍)缩放。现在它的尺寸是800 x 64了,宽度正好和我们的游戏画布大小相同。要调用refreshBody()
,这是因为我们缩放的是一个 静态 物体,所以必须把所作变动告诉物理世界(physics world)。
关于 refreshbody 方法。官方 docs 中的原话是这样的:Syncs the Body's position and size with its parent Game Object. You don't need to call this for Dynamic Bodies, as it happens automatically. But for Static bodies it's a useful way of modifying the position of a Static Body in the Physics World, based on its Game Object.将Body的位置和大小与它的父游戏对象同步。你不需要为Dynamic Bodies调用这个,因为它会自动变化。但对于静态物体来说,这是基于其游戏对象在物理世界中修改静态物体位置的有效方法。
剩下的平台搭建代码:
platforms.create(600, 400, 'ground');
platforms.create(50, 250, 'ground');
platforms.create(750, 220, 'ground');
这个步骤跟前面完全相同,只是不需要缩放,因为他们的尺寸本来就正好。
接下来我们来添加玩家。
添加玩家
在我们的html文件中加入新的变量 player
并在 create()
函数中加入player
的一些设置即可。
create()
中 player
相关的代码:
player = this.physics.add.sprite(100, 450, 'dude');
player.setBounce(0.2);
player.setCollideWorldBounds(true);
this.anims.create({
key: 'left',
frames: this.anims.generateFrameNumbers('dude', { start: 0, end: 3 }),
frameRate: 10,
repeat: -1
});
this.anims.create({
key: 'turn',
frames: [ { key: 'dude', frame: 4 } ],
frameRate: 20
});
this.anims.create({
key: 'right',
frames: this.anims.generateFrameNumbers('dude', { start: 5, end: 8 }),
frameRate: 10,
repeat: -1
});
这段代码所做的事情就是生成一个精灵,然后生成精灵运动时的动画。
添加 sprite
player = this.physics.add.sprite(100, 450, 'dude');
player.setBounce(0.2);
player.setCollideWorldBounds(true);
这样生成一个新的精灵,叫player
(玩家),位于100 x 450像素,在游戏的下部,该 sprite 的名称叫做 dude。精灵是通过物理游戏对象工厂函数(Physics Game Object Factory,即this.physics.add
)生成的,这意味着它默认拥有一个动态物体。
sprite 和 image 的区别:sprite 对象用于显示游戏中的静态和动画图像。精灵可以有输入事件和物理实体。它们还可以进行渐变、着色、滚动和动画。
sprite 和 image 的主要区别在于,你不能把 image 变成动画。因此,由于动画组件,精灵的处理时间更长,API占用也更大。如果你不需要动画,那么你可以在所有情况下使用 image 来替换 sprite 。
sprite 生成后,其被赋予0.2的一点点反弹(bounce)值。这意味着,它跳起后着地时始终会弹起那么一点点。然后精灵设置了与世界边界(bound)的碰撞。——边界默认在游戏尺寸之外。
setBounce(x [, y])设置这个对象的反弹值。弹跳是物体与另一个物体碰撞时的复原量,或弹性。值为1表示它将在反弹后保持其全部速度。值为0意味着它不反弹。
我们(通过player.setCollideWorldBounds(true)
)把游戏(的世界边界)设置为800 x 600后,玩家就不能不跑出这个区域了。这样会让玩家停下来,不能跑出画面边界,或跳出顶边。
setCollideWorldBounds( [value] )设置此物体是否与游戏世界边界碰撞。
如果需要该对象与游戏边界发生碰撞,则将 value 设为为 true,否则为false
动画实现
回顾一下preload
函数,你会看到'dude'是作为精灵表单(sprite sheet)载入的,而非 image 。这是因为它包含了动画帧(frame)。完整的精灵表单是这个样子的:
总共有9帧,4帧向左跑动,1帧面向镜头,4帧向右跑动。
我们定义两个动画,叫'left'和'right'。这是'left'动画:
this.anims.create({
key: 'left',
frames: this.anims.generateFrameNumbers('dude', { start: 0, end: 3 }),
frameRate: 10,
repeat: -1
});
'left'动画使用0, 1, 2, 3帧,跑动时每秒10帧。'repeat -1'告诉动画要循环播放。
这是我们的标准跑动周期。反方向的动画把这些重复一下,键值用'right'。最后一个动画键值用'turn'(转身)。
this.anims.create({
key: 'turn',
frames: [{ key: 'dude', frame: 4 }],
frameRate: 20
});
this.anims.create({
key: 'right',
frames: this.anims.generateFrameNumbers('dude', { start: 5, end: 8 }),
frameRate: 10,
repeat: -1
});
这里的 anims 对应的是 AnimationState 类. 这个组件提供了将动画应用到游戏对象的功能。
物理系统
Phaser支持多种物理系统,每一种都以插件形式运作,任何Phaser场景都能使用它们。针对本教程,我们将给我们的游戏使用Arcade物理系统,它简单,轻量,完美地支持移动浏览器。
物理精灵在生成时,即被赋予body
(物体)属性,这个属性指向它的Arcade物理系统的Body。它表示精灵是Arcade物理引擎中的一个物体。物体对象有很多属性和方法,我们可以玩一下。
比如,在一个精灵上模仿重力效果,可以这么简单写:
player.body.setGravityY(300)
这是个随意的值,但逻辑讲,值越大你的对象感觉越重,下落越快。如果你把这些加到你的代码里,或者运行part5.html
,你会看到玩家不停地往下落,完全无视我们先前生成的地面。
原因在于,我们还没有测试地面和玩家之间的碰撞。
当玩家和平台碰撞时,由于力的作用是相互的,玩家碰到它时,碰撞导致的力会作用于地面,因此两个物体交换彼此的速度,地面也开始下落。
要想玩家能与平台碰撞,且平台不动。我们可以生成一个碰撞对象。该对象监控两个物体(可以是组),检测二者之间的碰撞和重叠事件。如果发生事件,这时它可以随意调用我们的回调函数。不过仅仅就与平台间的碰撞而言,我们没必要那么做:
this.physics.add.collider(player, platforms);
碰撞器(Collider)接收两个对象,检测二者之间的碰撞,并使二者分开。在本例中,我们把玩家精灵和平台组传给它。最终实现玩家成功站在平台上的效果。
键盘控制
现在我们的游戏更像是一个动画,而非游戏,想要让它变的更像游戏一样,我们就应该向其中添加键盘控制,让玩家可以走动起来。
Phaser有内置的键盘管理器,用它的一个好处体现在这样一个方便的小函数:
cursors = this.input.keyboard.createCursorKeys();
这里把四个属性up, down, left, right(都是Key对象的实例),植入光标(cursor)对象。然后我们要做的就是在update
循环中做这样一些if判断:
//在update函数中编写
if (cursors.left.isDown)
{
player.setVelocityX(-160);
player.anims.play('left', true);
}
else if (cursors.right.isDown)
{
player.setVelocityX(160);
player.anims.play('right', true);
}
else
{
player.setVelocityX(0);
player.anims.play('turn');
}
if (cursors.up.isDown && player.body.touching.down)
{
player.setVelocityY(-330);
}
它做的第一件事,是查看方向左键是不是正被按下。如果是,我们应用一个负的水平速度,开动奔跑动画'left'。如果是方向右键正被按下,我们按字面意思做反向动作。通过清除速度值,再如此设置,一帧一帧,形成一个“走走停停”(stop-start)式的运动。
玩家只有键被按下时才移动,抬起时立即停止。键盘检测的最后部分,如果没有键被按下,就设置动画为'turn',水平速度为0。
代码的最后部分添加了跳起功能。方向up键是跳起键,我们检查它有没有被按下同时也检测玩家是不是正与地面接触,否则在半空中还会往上跳。(当然你也可以尝试实现二连跳的效果)
如果所有这些条件都符合,我们应用一个垂直速度,330像素每秒。玩家会由于重力的影响自动落回地面,控制已经就位,我们现在有了一个可以探索的游戏世界。请加载part7.html
,或者运行自己的代码玩一玩。尝试调整各个值,比如跳起值330,调低,调高,看看会有什么效果。
收集星星
接下来我们添加星星让玩家收集,首先要做的就是让星星出现在游戏画面上。因为要批量生成星星,因此我们生成一个新的组,叫'stars',再充实它。在create
函数中,我们加入如下代码(这些可以在part8.html
中看到):
stars = this.physics.add.group({
key: 'star',
repeat: 11,
setXY: { x: 12, y: 0, stepX: 70 }
});
stars.children.iterate(function (child) {
child.setBounceY(Phaser.Math.FloatBetween(0.4, 0.8));
});
这个过程跟我们生成平台组近似。但因为需要星星移动、反弹,我们生成动态物理组,而不是静态的。
组可以接收配置对象,以便于设置。在本例中,组配置对象有3个部分:
首先,它设置纹理key(键值)为星星图像。这意味着配置对象生成的所有子项,都将被默认地赋予星星纹理。然后,它设置重复值为11。因为它自动生成一个子项,重复11次就意味着我们总共将得到12颗,这正好是我们的游戏所需要的。
最后的部分是setXY
——这用来设置组的12个子项的位置。每个子项都将如此放置:初始是x: 12,y: 0,然后x步进70。这意味着第一个子项将位于12 x 0;第二个离开70像素,位于82 x 0;第三个在152 x 0,依次类推。'step'(步进)值用于组生成子项时加以排布,真是很方便的手段。选用值70是因为,这意味着所有12个子项将完美地横跨着布满画面。
下一段代码遍历组中所有子项,给它们的bounce.y
赋予0.4到0.8之间的随机值,反弹范围在0(不反弹)到1之间(完全反弹)。因为星星都是在y等于0的位置产出的,重力将把它们往下拉,直到与平台或地面碰撞为止。反弹值意味着它们将随机地反弹上来,直到最终恢复安定为止。
如果现在我们这样就运行代码,星星会落下并穿过游戏底边,消失不见了。要防止这个问题,我们就要检测它们与平台的碰撞。我们可以再使用一个碰撞器对象来做这件事:
this.physics.add.collider(stars, platforms);
与此类似,我们也将检测玩家是否与星星重叠:
this.physics.add.overlap(player, stars, collectStar, null, this);
这会告诉Phaser,要检查玩家与组中任何一颗星星的重叠。如果检测到,他们就会被传递到collectStar
函数:
function collectStar (player, star)
{
star.disableBody(true, true);
}
简单来说,星星带着个已关闭的物体,其父级游戏对象被设置为不活动、不可见,即将它从显示中移除。现在运行一下游戏,我们得到一个玩家,它左冲右突的,跳起,从平台反弹,收集头顶上落下的星星。不错,毕竟就这么几行、多半看起来还很好理解的代码.
计分
收集星星可以得分。接下来让我们来实现计分功能。
使用 text 对象即可。
在此我们生成两个新的变量,一个持有实际得分,一个文本对象本身:
var score = 0;
var scoreText;
scoreText
在create
函数中构建:
scoreText = this.add.text(16, 16, 'score: 0', { fontSize: '32px', fill: '#000' });
16 x 16是显示文本的坐标位置。score: 0
是要显示的默认字符串,接下来的对象包含字号、填充色。因为没有指定字体,实际上将用 Phaser 默认的,即 Courier 。
下一步我们要调整collectStar
函数,以便玩家捡到一颗星星时分数会提高,文本会更新以反映出新状态:
function collectStar (player, star)
{
star.disableBody(true, true);
score += 10;
scoreText.setText('Score: ' + score);
}
这样一来,每颗星星加10分,scoreText
将更新,显示出新的总分。如果运行part9.html
,你可以看到星星掉下来,收集星星时分数会提高。
弹球
现在该添加一些坏蛋,以此给我们的游戏收尾。
想法是这样的:你第一次收集到所有星星后,将放出一个炸弹。这个炸弹只是随机地在平台上各处跳,如果接触到它,你就死了。所有星星会重新产出,以便你可以再次收集,如果你完成了,又会放出另一个炸弹。这将给玩家一个挑战:别死掉,取得尽可能高的分数。
我们首先需要的东西是给炸弹用的一个组,还有几个碰撞器:
bombs = this.physics.add.group();
this.physics.add.collider(bombs, platforms);
this.physics.add.collider(player, bombs, hitBomb, null, this);
炸弹当然会跳出平台,如果玩家碰到它们,我们将调用hitBomb
函数。这个函数所作的就是停止游戏,使玩家变成红色:
function hitBomb (player, bomb)
{
this.physics.pause();
player.setTint(0xff0000);
player.anims.play('turn');
gameOver = true;
}
现在看来还不错,不过我们要放出一个炸弹。要做到这一点,我们改一下collectStar
函数:
function collectStar (player, star)
{
star.disableBody(true, true);
score += 10;
scoreText.setText('Score: ' + score);
if (stars.countActive(true) === 0)
{
stars.children.iterate(function (child) {
child.enableBody(true, child.x, 0, true, true);
});
var x = (player.x < 400) ? Phaser.Math.Between(400, 800) : Phaser.Math.Between(0, 400);
var bomb = bombs.create(x, 16, 'bomb');
bomb.setBounce(1);
bomb.setCollideWorldBounds(true);
bomb.setVelocity(Phaser.Math.Between(-200, 200), 20);
}
}
我们使用一个组的方法countActive
,看看有多少星星还活着。如果没有了,那么玩家把它们收集完了,于是我们使用迭代函数重新激活所有星星,重置它们的y位置为0。这将使所有星星再次从画面顶部落下。
下一部分代码生成一个炸弹。首先,我们取一个随机x坐标给它,始终在玩家的对侧画面,以便给玩家个机会。然后生成炸弹,设置它跟世界碰撞,反弹,拥有随机速度。
最终附上我们的所有源码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="//cdn.jsdelivr.net/npm/phaser@3.11.0/dist/phaser.js"></script>
<style type="text/css">
body {
margin: 0;
}
</style>
</head>
<body>
<script type="text/javascript">
var config = {
type: Phaser.AUTO,
width: 800,
height: 600,
physics: {
default: 'arcade',
arcade: {
gravity: { y: 300 },
debug: false
}
},
scene: {
preload: preload,
create: create,
update: update
}
};
var player;
var stars;
var platforms;
var cursors;
var score = 0;
var scoreText;
var bombs;
var game = new Phaser.Game(config);
function preload() {
this.load.image('sky', 'assets/sky.png');
this.load.image('ground', 'assets/platform.png');
this.load.image('star', 'assets/star.png');
this.load.image('bomb', 'assets/bomb.png');
this.load.spritesheet('dude', 'assets/dude.png', { frameWidth: 32, frameHeight: 48 }
);
}
function create() {
this.add.image(400, 300, 'sky');
platforms = this.physics.add.staticGroup();
platforms.create(400, 568, 'ground').setScale(2).refreshBody();
platforms.create(600, 400, 'ground');
platforms.create(50, 250, 'ground');
platforms.create(750, 220, 'ground');
player = this.physics.add.sprite(100, 450, 'dude');
this.physics.add.collider(player, platforms);
player.setBounce(0.2);
player.setCollideWorldBounds(true);
this.anims.create({
key: 'left',
frames: this.anims.generateFrameNumbers('dude', { start: 0, end: 3 }),
frameRate: 10,
repeat: -1
});
this.anims.create({
key: 'turn',
frames: [{ key: 'dude', frame: 4 }],
frameRate: 20
});
this.anims.create({
key: 'right',
frames: this.anims.generateFrameNumbers('dude', { start: 5, end: 8 }),
frameRate: 10,
repeat: -1
});
cursors = this.input.keyboard.createCursorKeys();
stars = this.physics.add.group({
key: 'star',
repeat: 11,
setXY: { x: 12, y: 0, stepX: 70 }
});
stars.children.iterate(function (child) {
child.setBounceY(Phaser.Math.FloatBetween(0.4, 0.8));
});
this.physics.add.collider(stars, platforms);
this.physics.add.overlap(player, stars, collectStar, null, this);
scoreText = this.add.text(16, 16, 'score: 0', { fontSize: '32px', fill: '#000' });
bombs = this.physics.add.group();
this.physics.add.collider(bombs, platforms);
this.physics.add.collider(player, bombs, hitBomb, null, this);
}
function update() {
if (cursors.left.isDown) {
player.setVelocityX(-160);
player.anims.play('left', true);
}
else if (cursors.right.isDown) {
player.setVelocityX(160);
player.anims.play('right', true);
}
else {
player.setVelocityX(0);
player.anims.play('turn');
}
if (cursors.up.isDown && player.body.touching.down) {
player.setVelocityY(-330);
}
}
function collectStar(player, star) {
star.disableBody(true, true);
score += 10;
scoreText.setText('score: ' + score);
if(stars.countActive(true) === 0){
stars.children.iterate(function(child){
child.enableBody(true, child.x, 0, true, true);
});
var x = (player.x < 400) ? Phaser.Math.Between(400, 800) : Phaser.Math.Between(0, 400);
var bomb = bombs.create(x, 16, 'bomb');
bomb.setBounce(1);
bomb.setCollideWorldBounds(true);
bomb.setVelocity(Phaser.Math.Between(-200, 200), 20);
}
}
function hitBomb (player, bomb){
this.physics.pause();
player.setTint(0xff0000);
player.anims.play('turn');
gameOver = true;
}
</script>
</body>
</html>