30-太空掠夺者游戏框架
7.6.3 太空掠夺者游戏框架
太空掠夺者是一个经典的动作游戏,一群外星入侵者从屏幕顶部进行攻击,玩家的职责是保卫世界。掠夺者在屏幕顶部附近水平方向移动。到达游戏区侧边时,掠夺者会向屏幕下方移动,并切换方向。
玩家通过鼠标移动控制一艘飞船,单击鼠标左键可以发射导弹。在玩家每次发射导弹时,需要播放“射击”声。当导弹击中敌人时,将敌人从屏幕上移除,并播放“爆炸”声。不限制玩家射击的次数,这也就意味着需要能在同一时间播放任意数量的射击声和爆炸声。本节的目标是能管理这些动态声音。
1.状态机
本游戏运行使用了一种非常简单的状态机。状态机是一种机制,这个机制在每个时刻只允许一个状态存在,也就是只做一件事情。这种机制对单人游戏支持得非常好,因为它不需要维护大量代表每个时间该做什么的布尔变量。
太空掠夺者有以下4种状态,用一个名为appState的变量保存当前状态。
- STATE_INIT:设置加载资源的状态。
var STATE_INIT = 10;
- STATE_LOADING:应用程序休眠,等待所有资源都被加载的状态。
var STATE_LOADING = 20;
- STATE_RESET:初始化游戏数值的状态。
var STATE_RESET = 30;
- STATE_PLAYING:处理所有游戏运行逻辑的状态。
var STATE_PLAYING = 40;
提示
这种类型游戏的最终版可能包含更多状态,例如STATE_END_GAME和STATE_NEXT_LEVEL,但是本案例不需要这些状态。 另外,因为IE不支持常量,所以要将它们都定义成变量。但是,用户仍然应该将它们当成常量来使用。
状态机的核心是run()函数。该函数每间隔20ms被调用一次。在每个时刻,使用switch语句根据appState变量的值来决定应该调用什么函数。每当程序准备好做其他事情的时候,就将appState更新为一个不同的状态。这种在每个间隔调用类似runApp()的函数并切换状态的处理方式通常被称为游戏循环。
function run(){
switch(appState){
ccase STATE_INIT:
initApp();
break;
case STATE_LOADING:
//等待回调
break;
case STATE_RESET:
resetApp();
break;
case STATE_PLAYING:
drawScreen();
break;
}
}
2.初始化游戏:没有全局变量
前面已经了解了一些关于本游戏所用的状态机的构造。下一步是设置资源预加载。正如之前提到的,这个游戏有两个声音——射击声和爆炸声;还有3个图片——玩家、外星人和导弹。
由于前面曾保证不再在此类应用中使用全局变量,因此这里就不再使用全局变量。有了状态机,就拥有了一种让应用程序等待加载资源的机制,不需要使用DOM中window对象的load事件。
在canvasApp()函数中,为游戏设置以下变量。
var appState = STATE_INIT;
还要使用laodCount和itemsToLocal变量,就像之前在音频播放器中使用的方法一样,只不过这里要加载更多项目。
var loadCount= 0;
var itemsToLoad = 0;
变量alienImage、missileImage和playerImage用于在游戏中保存已加载的图像。
var alienImage = new Image();
var missileImage = new Image();
var playerImage = new Image();
变量explodeSound和shootSound用于引用即将加载的HTMLAudioElement对象。
var explodeSound ;
var shootSound;
变量audioType用于保存运行游戏的浏览器所支持的音频文件类型的扩展名。
var audioType;
变量mouseX和mouseY用于保存当前的鼠标指针的x轴和y轴坐标。
var mouseX;
var mouseY;
变量player保存一个动态对象,用于保存玩家飞船的x轴和y轴坐标。
var player = {x:250,y:475};
数组aliens和missiles各自保存一个动态对象列表,用于在画布上显示所有外星人和导弹。
var aliens = new Array();
var missiles = new Array();
下面的5个变量设置了外星人的数量(ALIEN_ROWS、ALIEN_COLS)、起始位置(ALIEN_START_X、ALIEN_STARTY)以及它们在屏幕上的间距(ALIEN SPACING)。
const ALIEN_START_X = 25;
const ALIEN_START_Y = 25;
const ALIEN_ROWS = 5;
const ALIEN_COLS = 8;
const ALIEN_SPACING = 40;
在canvasApp()函数中,还需要为mouseup和mousemove设置事件处理函数。为了创建游戏循环,需要设置调用run()函数的间隔。
theCanvas.addEventListener("mouseup",eventMouseUp, false);
theCanvas.addEventListener("mousemove",eventMouseMove, false);
function gameLoop() {
window.setTimeout(gameLoop, 20);
run()
}
gameLoop();
现在将调用run()函数,并且通过调用与appState值相关联的函数,游戏循环也将启动。
3.不使用全局变量预加载资源
如上面所示,在初始化时将appState设置为STATE_INIT。这意味着,当第一次调用run()函数时,程序将调用initApp()函数。initApp()函数处理了少量的不为人们所见的事情,而且这些都是在Canvas程序环境中完成的。这样处理的结果,至少在本次讨论中是一个好消息,那就是不需要任何的全局变量。
下面的代码使用了相同的策略。使用唯一的事件处理函数,应对所有要加载的资源(itemLoaded())。将itemsToLoad设为5(3个图形和2个声音),并且在函数末尾将appState设为STATE_LOADING。其他的代码都很简单。
function initApp(){
loadCount=0;
itemsToLoad = 5;
explodeSound = document.createElement("audio");
document.body.appendChild(explodeSound);
audioType = supportedAudioFormat(explodeSound);
explodeSound.addEventListener("canplaythrough",itemLoaded,false);
explodeSound.setAttribute("src", "explode1." + audioType);
shootSound = document.createElement("audio");
document.body.appendChild(shootSound);
shootSound.addEventListener("canplaythrough",itemLoaded,false);
shootSound.setAttribute("src", "shoot1." + audioType);
alienImage = new Image();
alienImage.onload = itemLoaded;
alienImage.src = "alien.png";
playerImage = new Image();
playerImage.onload = itemLoaded;
playerImage.src = "player.png";
missileImage = new Image();
missileImage.onload = itemLoaded;
missileImage.src = "missile.png";
appState = STATE_LOADING;
}
在前面的run()函数中,STATE_LOADING没有做任何事。它仅仅等待所有事件被触发。itemLoaded()行为处理函数处理的工作方式与在音频播放器中写的itemLoaded函数非常相似,只不过多了两个另外的功能。
(1)必须移除创建的2个音频对象的事件监听器。这是因为,在有些浏览器中,调用HTMLAudioElement对象的play()方法或者改变HTMLAudioElement的src属性会启动一个load操作,这样就会第二次调用itemLoaded事件处理函数。这会导致程序中出现不可预料的结果。此外,移除对象不需要的事件处理函数总是一个明智的选择。
(2)将appState设置为STATE_RESET,这样就会在下一个间隔中调用run()函数时初始化游戏。
以下是两个另外的功能的代码。
function itemLoaded(event){
loadCount++;
if (loadCount >= itemsToLoad){
shootSound.removeEventListener("canplaythrough",itemLoaded, false);
explodeSound.removeEventListener("canplaythrough",itemLoaded,false);
appState = STATE_RESET;
}
}
4.重置游戏
在run()函数中,STATERESET状态调用resetApp()函数。先调用startLevel()函数,然后将两个声音的音量设置为50%(0.5),最后将appState设置为STATE PLAYING。
function resetApp(){
startLevel();
shootSound.volume = .5;
explodeSound.valume = .5;
appState = STATE_PLAYING;
}
startLevel()函数通过两个嵌套for:next循环进行遍历,根据列数创建出一行行的外星人。每次循环创建一个外星人,将该动态对象加入aliens数组,并添加属性。
- speed:在每次调用drawScreen()函数时,外星人向左或向右移动的像素数。
- x:外星人在屏幕上起始位置的x轴坐标。这个属性等于列数(c)乘以间距ALIEN_ SPACING,再加上ALIEN_START_X。
- y:外星人在屏幕上起始位置的y轴坐标。这个属性等于行数(r)乘以间距ALIEN_SPACING,再加上ALIEN_START_Y。
- width:外星人图像的宽度。
- height:外星人图像的高度。
以下是startLevel()函数的代码。
function startLevel(){
for (var r = 0; r < ALIEN_ROWS; r++){
for( var c= 0; c < ALIEN_COLS; c++){
aliens.push({speed:2,x:ALIEN_START_X+c*ALIEN_SPACING, y:ALIEN_START_Y+r*
ALIEN_SPACING,width:alienImage.width, height:alienImage.height});
}
}
}
5.鼠标控制
在介绍游戏玩法之前,先快速讨论一下鼠标事件处理函数。这些函数用于在游戏中采集用户的输入。如果用户移动鼠标,就调用eventMouseMove()函数。该函数的运行方式与在音频播放器中的函数相似,只是最后两行有差别。这两行用于设置player对象的x属性和y属性。该例曾在canvasApp()函数的变量定义部分创建了player对象。
在drawScreen函数中,将用这两个属性在画布上定位playerImage。
function eventMouseMove(event) {
var x;
var y;
if (event.pageX || event.pageY) {
x = event.pageX;
y = event.pageY;
} else {
x = e.clientX + document.body.scrollLeft +
document.documentElement.scrollLeft;
y = e.clientY + document.body.scrollTop +
document.documentElement.scrollTop;
}
x -= theCanvas.offsetLeft;
y -= theCanvas.offsetTop;
mouseX=x;
mouseY=y; player.x = mouseX;
player.y = mouseY;
}
在玩家按下并释放鼠标按钮时调用eventMouse()函数。这个事件发生,将发射一枚导弹。Missile对象几乎与alien对象一样,都包含speed、x、y、width和height属性。由于是玩家发射的导弹,所以将导弹的x坐标设置为玩家飞船x轴的中心(player.x+.5*player Image.width),y坐标设置为玩家飞船的y轴坐标减导弹的高度(player.y-missileImage.height)。
function eventMouseUp(event){
missiles.push({speed:5, x: player.x+.5*playerImage.width,
y:player.y-missileImage.height,width:missileImage.width,
height:missileImage.height});
下面一行代码最为关键,是当前主题——音频的特定代码。在太空掠夺者游戏的第一次迭代中,只简单调用shootSound的play()函数。在理论上,每当用户单击鼠标左键时都会播放射击的声音。
shootSound.play();
}
6.边框碰撞检测
在进行游戏逻辑的主要部分之前,应该先讨论边框碰撞检测。需要检测玩家发射的导弹与被射击的外星人之间的碰撞。要实现这一点,应创建一个函数来检测两个物体是否重叠。将之命名为hitTest()。
进行检测的类型被称为边框碰撞检测。意思是,忽略位图图形的内部细节,并假设每个对象边界都有一个看不见的边框,只需简单地检测该边框是否与其他物体的边框重叠。
在前面创建的动态对象alien和missile时都有相似的属性:x、y、width和height。因此hitTest()函数可以将它们作为通用对象进行检测,无须关心它们在屏幕上的表现形式。这也意味着,可以在游戏中添加任何类型的对象(外星人首领、重型武器、敌人的导弹等),只要为其创建相似的属性,就可以使用同样的函数对其进行碰撞检测。
函数的工作原理是找到每个对象上、下、左、右的位置,然后检测是否与其他对象对应的值重叠。第8章将会详细讨论边框碰撞检测,这里仅向读者展示太空掠夺者游戏中使用的代码。
function hitTest(image1,image2){
r1left = image1.x;
r1top = image1.y;
r1right = image1.x + image1.width;
r1bottom = image1.y + image1.height;
r2left = image2.x;
r2top = image2.y;
r2right = image2.x + image2.width;
r2bottom = image2.y + image2.height;
retval = false;
if ( (r1left > r2right)|| (r1right < r2left)|| (r1bottom < r2top)||
(r1top > r2bottom)){
retval = false;
} else {
retval = true;
}
return retval;
}
7.运行游戏
现在准备运行游戏。STATE_PLAYING状态调用drawScreen()函数,即太空掠夺者的核心。函数的第一部分只是在屏幕上简单地移动导弹和外星人。移动导弹相当简单。反向遍历数组,使用speed属性更新y属性。如果导弹移动到屏幕顶端上,就使用splice()函数从数组中移除该对象。该函数不会影响循环的长度。如果不这样做,那么在对数组执行splice()函数后元素会被跳过。
for (var i=missiles.length-1; i>= 0;i−−){
missiles[i].y−= missiles[i].speed;
if (missiles[i].y < (0-missiles[i].height)){
missiles.splice(i,1);
}
}
绘制外星人与绘制导弹类似,只有几个点不同。外星人左右移动,当它们抵达画布的侧边时,向下移动20像素,然后反方向移动。反方向可以通过speed属性乘以−1实现。如果外星人向右移动(speed=2),乘以−1后speed = −2,即x轴坐标将会减少,也就是将外星人向左移动。如果外星人抵达画布的左边,speed属性再次乘以−1((−2)×(−1))等于2,即外星人将向右移动。每次调用drawScreen()函数时,外星人的x属性都要加2。
//移动外星人
for (var i=aliens.length−1; i>= 0;i−−){
aliens[i].x += aliens[i].speed;
if (aliens[i].x > (theCanvas.width-aliens[i].width)|| aliens[i].x < 0){
aliens[i].speed *= -1;
aliens[i].y += 20;
}
if (aliens[i].y > theCanvas.height){
aliens.splice(i,1);
}
}
drawScreen()函数中的下一步是检测外星人与导弹之间的碰撞。这部分代码先反向遍历missiles数组,再嵌套循环遍历aliens数组。这将检测每一个导弹与每一个外星人之间是否碰撞。由于已经介绍了hitTest()函数,因此只需讨论当检测到碰撞时会发生什么。首先调用explodeSound对象的play()函数。这是太空掠夺者在本次迭代中的第二行关键代码。它在每次检测到碰撞时会播放(或试图播放)声音。在这之后,将alien和missile对象从对应的数组中移除,然后跳出内层的for:next循环。如果已经没有剩下被射击的外星人,就将appState设置为STATE_RESET,这样会在屏幕上添加更多的外星人,玩家可以继续射击。
missile: for (var i=missiles.length−1; i>= 0;i−−){
var tempMissile = missiles[i]
for (var j=aliens.length-1; j>= 0;j−−){
var tempAlien =aliens[j];
if (hitTest(tempMissile,tempAlien)){
explodeSound.play();
missiles.splice(i,1);
aliens.splice(j,1);
break missile;
}
}
if (aliens.length <=0){
appState = STATE_RESET;
}
}
drawScreen()函数中的最后几行代码是循环遍历missles和aliens数组,将它们绘制在屏幕上。这里用到了context对象的drawImage方法以及之前计算的x和y的值。最后再将玩家的图片绘制到Canvas上。
//绘制导弹
for (var i=missiles.length−1; i>= 0;i−−){
context.drawImage(missileImage,missiles[i].x,missiles[i].y);
}
//绘制外星人
for (var i=aliens.length−1; i>= 0;i−−){
context.drawImage(alienImage,aliens[i].x,aliens[i].y);
}
//绘制玩家
context.drawImage(playerImage,player.x,player.y);
本书事先声明过,太空掠夺者不是一个完整的游戏。这里仅实现了用于播放射击声的发射导弹功能和用于播放爆炸声的碰撞检测功能。