在这章,开始对Painter游戏的源代码进一步的整理组织。这非常有必要,因为源代码里面有很多行代码。在上章里,我们把变量组合到了对象里面。这章里,会使用到更多的对象并且把代码分离到不同的文件里面去。
你发现你的javascript文件已经有点大了。一个javascript中包含你所有的代码并不明智,因为很难从其中找到我们想要的代码。把文件分离开来很有意义。一个好的方式就是把不同的javascript文件根据每个javascript文件含有的对象分离开来。Painter3程序包含了之前章节里的对象,并且每个对象都写入了一个独立的javascript文件。现在找到指定代码和理解程序结构就变的容易多了。你甚至可以把文件放入不同的文件夹,表明它们属于哪个对象。比如可以把Keyboard和Mouse文件放进一个input文件夹里。这样,我们能清楚的知道这些文件都是处理用户输入用的。
加载这些分离的文件到浏览器中有点麻烦。之前是这样加载javascript文件的:
<script src="FlyingSpriteWithSound.js"></script>
你也许想的是像下面这样加载多个javascript文件:
<script src="input/Keyboard.js"></script>
<script src="input/Mouse.js"></script>
<script src="Canvas2D.js"></script>
<script src="system/Keys.js"></script>
<script src="Painter.js"></script>
<script src="Cannon.js"></script>
不幸的是,这样行不通。因为javascript文件从服务器那里获取,所以不确定哪一个javascript首先加载。假设第一个被加载的文件是Painter.js,浏览器不能解释其中的代码因为其中的相关代码跟另一个文件中的代码有关。所以,为了加载好文件,你需要知道它们之间的依赖关系和顺序关系。换句话说,如果A文件需要B文件,那么先需要加载B文件。
在javascript中,可以修改HTML页面。因此理论上,你可以在HTML页面添加额外的脚本元素,用来开始加载另一个JavaScript文件。通过巧妙的使用事件处理,可以想象写个JavaScript代码来加载预先定义好的JavaScript文件。你可以不用自己干这事,因为有人已经写好了。
这本书里面,我选择使用一个叫做LABjs的动态脚本加载工具。这是一个能让你动态的加载预先定义的JavaScript文件的简单脚本。下面是一个使用LABjs加载你JavaScript 文件的例子:
<script src="../LAB.min.js"></script>
<script>
$LAB.script('input/Keyboard.js').wait()
.script('input/Mouse.js').wait()
.script('Canvas2D.js').wait()
.script('system/Keys.js').wait()
.script('Painter.js').wait()
.script('Cannon.js').wait(function () {
Game.start('mycanvas');
});
</script>
可以看出,使用LABjs很简单。只需简单的调用一系列JavaScript文件和wait方法。最后一个wait方法传递了一个函数作为参数。在这个函数里,启动游戏。通过改变这些脚本文件的调用顺序,就可以改变JavaScript文件的加载顺序。当你开发游戏或者更大的JavaScript应用程序时,使用这个脚本非常有用,因为可以让开发和维护代码变得容易。Painter3例子可以看到一个完整的加载不同的JavaScript文件的代码。
也许你不想在最终的游戏里面使用上述方法,因为浏览器需要加载很多的JavaScript文件。那么可以使用另外一个程序把所有的JavaScript文件都放在一个更大的JavaScript文件里面,这样加载更快。此外,更常见的做法是对尽可能小的JavaScript文件的代码结构做优化。这个过程叫做压缩。第30章会详细的说明这一切。
先前,我说明了浏览器会无序的加载JavaScript文件。这个规则同样适用于加载游戏资源比如精灵和音效。下面的方法是你截止目前看到的加载游戏资源的方法:
var sprite = new Image();
sprite.src = "someImageFile.png";
var anotherSprite = new Image();
anotherSprite.src = "anotherImageFile.png";
// and so on
看起来很简单,对每一个要加载的精灵创建一个Image对象并且赋值src变量。给src赋值并不意味着图片马上就会被加载。它只是简单的告诉浏览器从服务器开始获取这些图片。这些都跟网络速度有关,有可能需要会儿时间。如果你想马上画出图片,JavaScript会报错(还没加载完就开始画图)。为了避免这种情况,这是之前加载精灵的例子:
sprites.background = new Image();
sprites.background.src = spriteFolder + "spr_background.jpg";
sprites.cannon_barrel = new Image();
sprites.cannon_barrel.src = spriteFolder + "spr_cannon_barrel.png";
sprites.cannon_red = new Image();
sprites.cannon_red.src = spriteFolder + "spr_cannon_red.png";
sprites.cannon_green = new Image();
sprites.cannon_green.src = spriteFolder + "spr_cannon_green.png";
sprites.cannon_blue = new Image();
sprites.cannon_blue.src = spriteFolder + "spr_cannon_blue.png";
cannon.initialize();
window.setTimeout(Game.mainLoop, 500);
注意最后一行代码。在赋值完所有Imgae对象的src变量后,你告诉浏览器在500毫秒后执行游戏循环。这样,浏览器就有足够的时间加载图片。但是,如果网络连接很慢的话怎么办呢?那么500毫秒根本不够。如果网络速度很快呢?则浪费了玩家很多的等待时间。为了解决这个问题,你需要一个程序在执行主循环之前加载完所有的图片。可以使用事件处理函数。但是在那之前,需要稍微讨论下关于方法和函数。
(省略)
为了让加载精灵更简单,给Game对象增加了一个loadSprite方法:
Game.loadSprite = function(imageName) {
var image = new Image();
image.src = imageName;
return image;
}
现在加载不同精灵的代码就变得简便短小了:
var sprFolder = "../../assets/Painter/sprites/";
sprites.background = Game.loadSprite(sprFolder + "spr_background.jpg");
sprites.cannon_barrel = Game.loadSprite(sprFolder + "spr_cannon_barrel.png");
sprites.cannon_red = Game.loadSprite(sprFolder + "spr_cannon_red.png");
sprites.cannon_green = Game.loadSprite(sprFolder + "spr_cannon_green.png");
sprites.cannon_blue = Game.loadSprite(sprFolder + "spr_cannon_blue.png");
然而,加载精灵的时间问题仍然没有解决。为了解决这个问题,首先要做的就是记录加载了多少精灵。可以在Game对象中增加一个变量,叫做spritesStillLoading:
var Game = {
spritesStillLoading : 0
};
开始spritesStillLoading的值为0。每次你加载一个精灵,这个值加一。如下所示:
Game.loadSprite = function(imageName) {
var image = new Image();
image.src = imageName;
Game.spritesStillLoading += 1;
return image;
}
每次加载完后spritesStillLoading要递减。这可以通过一个事件处理来实现,如下所示:
Game.loadSprite = function (imageName) {
var image = new Image();
image.src = imageName;
Game.spritesStillLoading += 1;
image.onload = function () {
Game.spritesStillLoading -= 1;
};
return image;
};
现在可以通过spritesStillLoading的值来决定是否开始游戏。当spritesStillLoading是0可以进行游戏主循环。为了做到这些,创建两个循环:一个资源加载的循环和一个游戏主循环。在资源加载循环里面,检测精灵是否被加载。如果所有的精灵被加载后,在调用游戏主循环。下面资源加载的循环:
Game.assetLoadingLoop = function () {
if (Game.spritesStillLoading > 0)
window.setTimeout(Game.assetLoadingLoop, 1000 / 60);
else {
Game.initialize();
Game.mainLoop();
}
};
截止目前为止,都是用window.setTimeout方法来创建一个游戏循环。虽然代码有用,但是它不是最好的。许多浏览器都提供了一个交互的进行绘画的方法,比如游戏。问题是不是所有的浏览器或者浏览器版本都使用相同的方法名字。比较新的版本都是用window.requestAnimationFrame方法。然而比较老点的版本使用window.mozRequestAnimationFrame或者 window.webkitRequestAnimationFrame。现在我们可以像下面这样书写来处理游戏循环:
window.requestAnimationFrame = window.requestAnimationFrame ||
window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame || window.msRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
};
使用||操作符来决定使用哪个方法名字。如果没有有效的游戏循环方法,那么就调用 window.setTimeout方法。在javascript里面,window变量是一个全部命名空间的容器。这意味着:
window.requestAnimationFrame(callbackFunction);
等于
requestAnimationFrame(callbackFunction);
painter3例子就是用最优化的游戏循环方法来运行游戏资源加载循环和游戏主循环,如下:
Game.assetLoadingLoop = function () {
if (Game.spritesStillLoading > 0)
window.requestAnimationFrame(Game.assetLoadingLoop);
else {
Game.initialize();
Game.mainLoop();
}
};
与之相似的是游戏主循环:
Game.mainLoop = function () {
Game.handleInput();
Game.update();
Game.draw();
Mouse.reset();
window.requestAnimationFrame(Game.mainLoop);
};
浏览器之间的差异性对javascript开发者来说是一个相当的挑战,即使大部分已经朝标准化发展了。当你开发javascript游戏时,你可能会遇上不同浏览器之间的兼容性。因此,在你发布游戏之前最好在常见的一些浏览器上进行测试。
之前,你没有区分那些可以用于不同游戏和特定游戏之间的代码。你写的某部分代码,比如 Game.mainLoop方法,同样适用于其他游戏。加载精灵的代码也是如此。你已经知道了如何分离不同的代码到不同的文件。通过分离出通用代码,那么在之后就很容易进行的代码的复用。如果你想复用精灵加载的代码,你只需在新的游戏里简单的包含源文件.另外一个原因是能让你以后更快的创建相似的游戏。通过这种方法,你可以快速的创建出你的新游戏而不是重新造轮子。
Painter4例子创建了一个Game.js文件,其中包含Game对象和一些与之相关的方法。与Painter游戏相关的代码放进了Painter.js文件。此外,还有一个PainterGameWorld.js文件处理游戏中不同的对象。在之前的painter版本里,游戏世界只有一个背景图和一个大炮。在下节里,会添加进一个球。painter的游戏世界通过一个对象进行定义确保所有的游戏对象更新和绘画。下面是painterGameWorld对象的一部分:
var painterGameWorld = {
};
painterGameWorld.handleInput = function (delta) {
ball.handleInput(delta);
cannon.handleInput(delta);
};
painterGameWorld.update = function (delta) {
ball.update(delta);
cannon.update(delta);
};
painterGameWorld.draw = function () {
Canvas2D.drawImage(sprites.background, { x : 0, y : 0 }, 0,
{ x : 0, y : 0 });
ball.draw();
cannon.draw();
};
当你初始化游戏,就会初始化游戏对象,并且告诉Game对象painterGameWorld正在支配着游戏世界:
Game.initialize = function () {
cannon.initialize();
ball.initialize();
Game.gameWorld = painterGameWorld;
};
在Game.mainLoop方法中,现在只需确定调用gameWorld变量中正确的方法:
Game.mainLoop = function () {
Game.gameWorld.handleInput();
Game.gameWorld.update();
Canvas2D.clear();
Game.gameWorld.draw();
Mouse.reset();
requestAnimationFrame(Game.mainLoop);
};
结果就是,非常好的分离的通用游戏代码(Game.js)和特定的游戏代码,其中包含了加载精灵和初始化游戏,更新和绘画Painter游戏中的对象。其他的与painter有关的游戏对象也分别放到了独立的脚本文件中(比如Cannon.js)。
在之前的章节里,你已经知道了如何通过分离的javascript文件让你的javascript游戏应用更加灵活和有效,从特定的游戏代码中分离出通用代码,正确的加载游戏资源,创建更有效的循环。这节里,添加一个可以射击的球来扩展painter游戏。因此需要添加一个ball对象。
设置ball对象和cannon对象多少有些相似。在Painter4例子中,可以看到添加了一个球的游戏(图7-1)。可以通过点击屏幕的任何地方来从大炮中射出小球。此外,球的颜色和大炮的颜色一样。可以在Ball.js文件中找到描述ball对象的代码。就像cannon对象一样,ball包含了一些变量,比如位置,当前颜色和最初的球的样子。因为球要移动,所以还要储存速度。速度是一个定义球随着时间改变位置的矢量。比如,如果球的速度为(0,1),那么每一秒,球的Y坐标都会增加1(意味着球在下落)。最终,球有两个状态:不是在空中飞行就是等待被射出。因为,为ball对象添加一个额外的布尔变量叫做shooting。下面是ball的完整定义:
var ball = {
};
ball.initialize = function() {
ball.position = { x : 65, y : 390 };
ball.velocity = { x : 0, y : 0 };
ball.origin = { x : 0, y : 0 };
ball.currentColor = sprites.ball_red;
ball.shooting = false;
};
(省略图7-1)
本书中开发的游戏,大多对象都有位置和速度。因为本书只关注2D游戏,位置和速度都只有x和y变量。当更新这些游戏对象,需要基于速度矢量和经过的时间计算出新的位置。之后的章节里,你就知道怎么做这些了。
为了能够使用ball对象,需要一些新的精灵。在Game.loadAssets方法里,加载红色球,绿色球,蓝色球。根据大炮的颜色,改变球的颜色。下面是扩展后的loadAssets方法:
Game.loadAssets = function () {
var loadSprite = function (sprite) {
return Game.loadSprite("../../assets/Painter/sprites/" + sprite);
};
sprites.background = loadSprite("spr_background.jpg");
sprites.cannon_barrel = loadSprite("spr_cannon_barrel.png");
sprites.cannon_red = loadSprite("spr_cannon_red.png");
sprites.cannon_green = loadSprite("spr_cannon_green.png");
sprites.cannon_blue = loadSprite("spr_cannon_blue.png");
sprites.ball_red = loadSprite("spr_ball_red.png");
sprites.ball_green = loadSprite("spr_ball_green.png");
sprites.ball_blue = loadSprite("spr_ball_blue.png");
};
上面可以看出如何让精灵加载在javascript中显得更加易读。声明了一个本地变量loadSprite来代表一个函数。这个函数需要一个精灵名字的参数,最后,函数返回Game.loadSprite方法后的结果。
在ball对象的initialize方法中,首先进行变量的赋值。在游戏开始时,球不会移动。因此,球的速度为0.当然球的初始位置也为0.换句话说,球藏在大炮的后面,因此当球不移动时你看不见它。初始化球为红色的球,设置shooting变量为False,如下:
ball.initialize = function() {
ball.position = { x : 0, y : 0 };
ball.velocity = { x : 0, y : 0 };
ball.origin = { x : 0, y : 0 };
ball.currentColor = sprites.ball_red;
ball.shooting = false;
};
紧接着初始化,需要添加一个reset方法来复位球的位置和shooting值:
ball.reset = function () {
ball.position = { x : 0, y : 0 };
ball.shooting = false;
};
当球飞出屏幕之外,就可以调用reset方法。此外,添加一个draw方法。如果球没有被射出,玩家就看不见它。也就是说,只在球状态为射出时才绘画球:
ball.draw = function () {
if (!ball.shooting)
return;
Canvas2D.drawImage(ball.currentColor, ball.position, ball.rotation,
ball.origin);
};
注意游戏对象的绘画顺序:首先是背景图,然后是球,然后才是大炮。
玩家可以点击鼠标左键来射出球。球的速度和它移动的方向是由玩家鼠标点击的位置决定的。玩家点击的位置距离大炮越远,球就有更快的速度。对玩家来说控制球速度的直觉就是这样。当你设计游戏时,仔细思考可以从玩家那里接收怎样的指令并且什么是最自然和有效的处理方式。
为了处理输入,为ball对象添加handleInput方法,检测用户是否点击了鼠标左键:
if (Mouse.leftPressed)
// do something...
然而,因为在任何时刻空中只能有一个球,你需要检测在空中是否有球。也就是说需要检测球的射出状态,如果球被射出了,那么就不再处理鼠标点击事件。因此,需要添加额外的判断条件:
if (Mouse.leftPressed && !ball.shooting)
// do something...
在If语句里,需要知道玩家点击了哪里和球已经射了出来。首先需要给shooting变量一个正确的值,因为球的状态需要改变:
ball.shooting = true;
因为球在移动了,那么就需要给它一个速度。速度就是一个玩家点击位置的矢量。可以通过鼠标点击位置减去球的位置来计算出这个值。因为速度有x和y变量,所以两者都要进行运算:
ball.velocity.x = (Mouse.position.x - ball.position.x);
ball.velocity.y = (Mouse.position.y - ball.position.y);
这个速度的计算方式有效考虑到了用户点击位置距离大炮更远,速度越快。然而,如果你现在玩这个游戏的话,你会发现球移动的很慢。因此,需要让速度乘以一个常量:
ball.velocity.x = (Mouse.position.x - ball.position.x) * 1.2;
ball.velocity.y = (Mouse.position.y - ball.position.y) * 1.2;
在进行了不同的测试后,选择了常量1.2。每个游戏都有一些游戏参数需要你在不断的测试中选出一个合适值。为这些参数选出一个正确的值是至关重要的,因为为了平衡游戏,并且还要保证这些参数不会让游戏太难或者太简单。比如如果用0.3代替1.2,球会移动的更慢。这会让游戏变的更难,甚至让游戏变得不可玩。
如果你向ball对象添加了handleInput方法,它不会被自动调用。需要在painterGameWorld对象里进行明确说明。因此,像下面这样书写代码:
painterGameWorld.handleInput = function () {
ball.handleInput();
cannon.handleInput();
};
把各种变量和方法组合到各个对象的一个巨大好处就是让每个对象看起来清楚和更小。你可以自行设计游戏中需要用到的对象。
在ball.update中,球的不同行为是由球的当前状态决定的。如下:
ball.update = function (delta) {
if (ball.shooting) {
ball.velocity.x = ball.velocity.x * 0.99;
ball.velocity.y = ball.velocity.y + 6;
ball.position.x = ball.position.x + ball.velocity.x * delta;
ball.position.y = ball.position.y + ball.velocity.y * delta;
}
else {
if (cannon.currentColor === sprites.cannon_red)
ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
ball.currentColor = sprites.ball_green;
else
ball.currentColor = sprites.ball_blue;
ball.position = cannon.ballPosition();
ball.position.x = ball.position.x - ball.currentColor.width / 2;
ball.position.y = ball.position.y - ball.currentColor.height / 2;
}
if (painterGameWorld.isOutsideWorld(ball.position))
ball.reset();
};
上面的方法有个参数delta。这个参数是用来计算球的新的位置,你需要知道自从上次调用update后经过了多少时间。这个参数也对某些对象的handInput方法有用——比如,想知道鼠标移动的速度,就需要知道经过的时间。Painter4例子扩展每个对象都有游戏循环方法(handleInput, update, draw),把距离上一次更新的时间当做参数。
但是在哪里计算delta值呢?怎么计算它?在下面的例子中,在Game.mainLoop方法中进行计算:
Game.mainLoop = function () {
var delta = 1 / 60;
Game.gameWorld.handleInput(delta);
Game.gameWorld.update(delta);
Canvas2D.clear();
Game.gameWorld.draw();
Mouse.reset();
requestAnimationFrame(Game.mainLoop);
};
因为你想游戏循环每秒执行60次,所以像下面这样计算delta的值:
var delta = 1 / 60;
这种计算游戏循环中经过的时间被叫做固定步长。如果你有一个很慢的电脑不能够一秒钟执行60次,你仍然需要让你的游戏对象知道自从上次时间后经历了60分之一秒,即使那不是真的。因此,游戏时间不同于真实的时间。另外一个方式是通过获取系统时间来计算真正经过的时间。如下:
var d = new Date();
var n = d.getTime();
变量n含有自1970年来的毫秒数,每一次你运行游戏循环,就会得到一次新的时间值,也就是你能得到经历的真实时间。这不是固定的时间步长,因为经过的时间跟电脑的速度有关,优先级跟系统有关,此时系统也在处理着其它任务。因此,这种处理游戏中时间的方法叫做可变的时间步长。
可变的时间步长在高帧率要求的游戏中特别有用,比如,第一人称射击游戏,镜头需要高速的移动。可变时间步长的缺点是当玩家暂时做些其它事情时(比如打开游戏菜单或者保存游戏)时间也在继续。通常玩家不会高兴在它们保存游戏之后,角色就已经挂掉。因此作为一个游戏开发者,使用可变的时间步长时需要注意处理这些问题。
另外一个使用可变时间步长的例子是与游戏可玩性的交互。这经常发生,尤其是开发浏览器中的游戏时。这也是为什么在本书中使用一个固定的时间步长的原因。当用户转换到另一个选项卡时,沉默的选项卡的程序会一直停止执行直到用户返回。当使用固定时间步长,当游戏暂停,用户重新激活游戏时,游戏继续运行,因为游戏对象不关心经过的真实时间,只在乎固定的delta值。
当前球的位置通过它的速度进行更新:
ball.position.x = ball.position.x + ball.velocity.x * delta;
ball.position.y = ball.position.y + ball.velocity.y * delta;
计算球新的位置是基于速度和经过的时间。乘以速度的每个维度,结果加上当前球的位置。如此的话,即使用高帧率或低帧率,游戏对象的移动速度也不会改变。
如果球的状态不为射出,那么就可以改变球的颜色了。这种情况下,通过获取大炮的颜色来确定球的颜色:
if (cannon.currentColor === sprites.cannon_red)
ball.currentColor = sprites.ball_red;
else if (cannon.currentColor === sprites.cannon_green)
ball.currentColor = sprites.ball_green;
else
ball.currentColor = sprites.ball_blue;
更新球的位置:
ball.position = cannon.ballPosition();
ball.position.x = ball.position.x - ball.currentColor.width / 2;
ball.position.y = ball.position.y - ball.currentColor.height / 2;
ball.position = cannon.ballPosition();
ball.position.x = ball.position.x - ball.currentColor.width / 2;
ball.position.y = ball.position.y - ball.currentColor.height / 2;
为什么改变位置?当球不在空中时,玩家可以通过转动大炮来改变球的射出位置。因此,需要根据当前大炮的位置计算出球的正确位置。因此,需要添加一个ballPosition方法到cannon中,此方法基于大炮位置计算球的位置。使用正弦和余弦函数:
cannon.ballPosition = function() {
var opp = Math.sin(cannon.rotation) * sprites.cannon_barrel.width * 0.6;
var adj = Math.cos(cannon.rotation) * sprites.cannon_barrel.width * 0.6;
return { x : cannon.position.x + adj, y : cannon.position.y + opp };
};
可以看出,乘以临边和对边的0.6,这样球看起来比在大炮之上一半多。函数返回一个包含x和y的对象。
当获取了理想的球的位置后,从其中减去球高和宽的一半。那样,那样,球就非常好的显示在大炮的中间。
ball.update方法的第二部分也是一个if语句:
if (painterGameWorld.isOutsideWorld(ball.position))
ball.reset();
这部分处理当球移除游戏世界后的情况。为了计算这种情况,添加了isOutsideWorld这个方法。这个方法目的是检测给定的位置是否超出了我们规定的范围。使用一些简单的规则指定游戏世界的边界,记住屏幕的左上角是起点。
如果你看这个方法的话,发现只有一个参数,postion:
painterGameWorld.isOutsideWorld = function (position)
如果你想查看一个坐标是否在屏幕之外,需要知道屏幕的宽和高。在一个HTML5游戏中路Pianter游戏,对应的就是画布的尺寸。Painter4为Game添加了一size变量。当Game.start方法被调用,屏幕的尺寸就会被传递给这个参数:
Game.start = function (canvasName, x, y) {
Canvas2D.initialize(canvasName);
Game.size = { x : x, y : y };
Keyboard.initialize();
Mouse.initialize();
Game.loadAssets();
Game.assetLoadingLoop();
};
在isOutsideWorld方法中,使用Game.size决定坐标是否在游戏世界之外。方法中只有一条return语句,返回一个布尔值。逻辑或运算包含了超出游戏世界之外的不同情况:
return position.x < 0 || position.x > Game.size.x || position.y > Game.size.y;
如上所示,不介意Y坐标是否小于0.这允许球越过屏幕之上然后再掉下来。
最后不要忘了在painterGameWorld.update方法中调用ball.update方法:
painterGameWorld.update = function (delta) {
ball.update(delta);
cannon.update(delta);
};
当运行这个Painter4例子,会发现大炮可以瞄准了,可以选颜色了和射出炮弹了。下一章,会在游戏中添加油漆罐。但是为了达到这个目的,会引进一个新的JavaScript编程概念:原型。
这章里,学到了: