3.8 Breakout游戏
前面介绍了很多辅助类,现在是时候使用它们了。这里我将跳过游戏的构思阶段,
Breakout
游戏只有单人模式,对手则是砖块,所以可以把它说成是
Pong
游戏缩略版本。最初
Breakout
游戏是由
Nolan Bushnell
和
Steve Wozniak
发明的,并在
1976
年由
Atari
公司发行。在这个早期版本中,它仅仅是个黑白游戏,就像
Pong
游戏一样。但为了让它更有意思,给显示器蒙了一层透明条纹来给砖块上色(如图
3-13
所示)。
图3-13
您也将做一个这样的游戏,并使用一些
Pong
的游戏组件以及前面介绍的辅助类。
Breakout
比
Pong
游戏更加复杂,它可以设置很多级别,而且有很大的改进空间。例如,
Arkanoid
就是
Breakout
游戏的克隆版本,并且在
20
世纪
80
到
90
年代有一大批的游戏都是基于这个创意,它们添加了武器系统、更好的图形特效,以及通过变换砖块的摆放位置来设置不同的级别。
从图
3-14
中可以看到,
BreakoutGame
类的结构很像上一章的
Pong
类,同时使用
SpriteHelper
类取代上一章的
sprite
操作方式,其它一些内部方法和调用也换成了对应的辅助类。例如,
StartLevel
方法根据当前
level
值产生新的随机
level
值,这里就使用了
RandomHelper
类产生这些随机值。
图3-14
注意,这个类中还包含了一些测试方法,在下一章中将对它们和辅助类做一些改进,下一章主要介绍了
BaseGame
和
TestGame
类,它们使对游戏类的处理,尤其是单元测试变得更加简单,而且更有条理。
图
3-15
展示了接下来要做的
Breakout
游戏的总体样子,它非常有趣,肯定会比
Pong
游戏更能让人多玩几次,因为
Pong
游戏只有两个人玩的时候才比较有意思。
Breakout
游戏使用了
Pong
游戏项目中的背景素材和两个声音文件,另外还为球板(
paddle
)、球和砖块使用了一个新的素材
BreakoutGame.png
,添加过关(
BreakoutVictory.wav
)和打碎砖块(
BreakoutBlockKill.wav
)声效。
图3-15
Breakout中的单元测试
在开始复制
/
粘贴上一章项目的代码、使用新的辅助类以及绘制新的游戏元素之前,您应该仔细考虑一下整个游戏以及可能遇到的问题。当然,您可以不用这么做,而直接开始实现游戏,但后面可能会遇到很难解决的问题,比如碰撞检测,它也是这个游戏最难的部分。单元测试可以帮助您,可以很容易地检查游戏的基本功能,帮助您组织代码,只编写真正需要的东西。就如以前一样,先从游戏最直观的部分开始,并测试它,然后添加更多的单元测试直到所有部分都完成,最后把所有部分组合起来并测试最终的游戏。
下面是
Breakout
游戏中使用的单元测试的简单介绍,更详细的内容可以查看本章的源代码。此时还没有
TestGame
类,所以您还得像上一章那样来进行单元测试,或者作为更好的选择,查看下一章中的相关内容来做静态单元测试。此处只有三个单元测试,但它们会被反反复复地使用和修改。
- TestSounds:测试项目新增的几个声音特效,按下空格键、Alt键、Control键和Shift键来播放不同的声音。我还在播放下一个声音之前添加了一个小小的停顿,这样更容易听清楚。这个测试用来检查为这个游戏新建的XACT项目。
- TestGameSprites:这个测试最初是用来测试SpriteHelper类的,不过后来所有代码都被移到了游戏类的Draw方法中。它还用来初始化游戏中的砖块,这部分代码被移到了构造函数中。这个测试告诉您的重点并不是去写一个复杂的测试,因为该测试本身只有4行代码,重点是利用测试让您编写游戏的生活更加简单。需要的时候您可以从这些单元测试中复制一些有用的代码来用。静态单元测试没有必要像动态单元测试那样完美,因为您只在构建和测试游戏的时候使用它们。当游戏能运行之后,您将不再需要这些静态单元测试,除非后面还需要它们随时对游戏进行测试。
- TestBallCollisions:就像上一章一样碰撞检测是最有用的单元测试。此处要检测球接触屏幕边缘和球板时是否真的会发生碰撞,这部分只要使用上一章的代码并做些较小的改动就行了。然后就是更加复杂的砖块碰撞检测代码,这个稍后将详细介绍。另外,您还可以想出很多其他的方法来检测碰撞,如果愿意的话还可以对游戏进行一些改进。例如,可以试着把球发射到砖块墙的后面,看看它能否把所有的砖块都打碎。
Breakout的级别
因为
Breakout
要使用的很多东西在
Pong
游戏里都是现成的,所以可以跳过那些相似的或者完全相同的实现代码,您要关注的是
Breakout
游戏里的那些新的变量:
///
<summary>
///
How many block columns and rows are displayed?
///
</summary>
const
int
NumOfColumns
=
14
,NumOfRows
=
12
;
///
<summary>
///
Current paddle positions, 0 means left, 1 means right.
///
</summary>
float
paddlePosition
=
0.5f
;
///
<summary>
///
Level we are in and the current score.
///
</summary>
int
level
=
0
, score
=
-
1
;
///
<summary>
///
All blocks of the current play field. If they are
///
all cleared, we advance to the next level.
///
</summary>
bool
[,] blocks
=
new
bool
[NumOfColumns, NumOfRows];
///
<summary>
///
Block positions for each block we have, initialized in Initialize().
///
</summary>
Vector2[,] blockPositions
=
new
Vector2[NumOfColumns, NumOfRows];
///
<summary>
///
Bounding boxes for each of the blocks, also precalculated and checked
///
each frame if the ball collides with one of the blocks.
///
</summary>
BoundingBox[,] blockBoxes
=
new
BoundingBox[NumOfColumns, NumOfRows];
首先定义砖块的最大列数和行数,以决定砖块数量的最大值,在第一级的时候不会填满砖块,只使用砖块最大数量的
10%
。球板位置的计算也比
Pong
游戏中的简单一些,因为这里只有一个玩家。还要保存当前游戏的级别以及得分,这些在
Pong
游戏中是没有的。在
Pong
游戏中每个玩家只有三条命,当三条命都没了游戏就结束了。而在
Breakout
中,玩家从第一级开始,可以不断地向上升级,直到输了球为止。这里分数不会非常高,也不用处理任何游戏字体,所以级数和分数就直接在窗口的标题栏上进行更新。
接下来定义砖块,最重要的一个数组就是
blocks
,它会告诉您当前使用哪个砖块。
Blocks
在每一级开始之前初始化,而
blockPositions
和
blockBoxes
只在游戏类的构造函数中初始化一次,其中
blockPositions
用来计算渲染砖块的中心位置,
blockBoxes
用来确定砖块的碰撞检测的边界盒(
bounding box
)。要注意的是,这些数据都没有使用屏幕坐标系统,所有的位置数据在
0-1
范围内,
0
代表左边或者顶部,
1
代表右边或者底部。这种方式可以使游戏独立于分辨率,而且使渲染碰撞检测更加容易。
级别是在
StartLevel
方法中产生的,这个方法在游戏开始以及升级的时候被调用:
void
StartLevel()
{
//
Randomize levels, but make it more harder each level
for
(
int
y
=
0
; y
<
NumOfRows; y
++
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
blocks[x, y]
=
RandomHelper.GetRandomInt(
10
)
<
level
+
1
;
//
Use the lower blocks only for later levels
if
(level
<
6
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
blocks[x, NumOfRows
-
1
]
=
false
;
if
(level
<
4
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
blocks[x, NumOfRows
-
2
]
=
false
;
if
(level
<
2
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
blocks[x, NumOfRows
-
3
]
=
false
;
//
Halt game
ballSpeedVector
=
Vector2.Zero;
//
Wait until user presses space or A to start a level.
pressSpaceToStart
=
true
;
//
Update title
Window.Title
=
"
XnaBreakout - Level
"
+
(level
+
1
)
+
"
- Score
"
+
Math.Max(
0
, score);
}
//
StartLevel
在第一个
for
循环里,根据当前级别重新设置砖块数组的值。在第
1
级中,
level
等于
0
,只使用
10%
的砖块。
RandomHelper.GetRandomInt(10)
方法返回值在
0-9
范围内,小于
1
的概率只有
10%
。在第
2
级,这个概率就上升到
20%
,当到达第
10
级或者更高,那就是
100%
了。实际上游戏没有上限,只要想玩就可以一直玩下去。
然后清除底下三行的砖块,这样游戏开始的几级就会容易一些。在第三级的时候,会移除
2
行,在第
5
级的时候就只移除
1
行,到了
7
级以后所有的行都显示出来。
和
Pong
游戏不同的是,在新游戏开始的时候球并不马上运动,球停留在球板上,直到用户按下空格键或者
A
键才开始运动。然后球朝着一个随机方向离开球板,在砖块、屏幕边缘以及球板之间来回运动,当所有的砖块都被打碎了玩家就赢了,或者玩家没有接到球而输了。
最后,更新窗口的标题栏来显示到目前为止玩家的级数和得分。在这个简单的版本中,玩家每打碎一个砖块只得到
1
分,达到
100
分就非常棒了。但就如我之前所说的,游戏没有限制,尽量取得更高的分数来体验游戏的快乐。
游戏循环
Pong
游戏中的循环非常简单,包含了大部分的用户输入以及碰撞检测代码。
Breakout
的循环就稍微复杂一些,因为这里要处理球的两种不同状态。一种状态是球静止在球板上,等待用户按下空格键来开始游戏;另一种状态是游戏开始之后,此时要检测球与屏幕边缘、球板和砖块之间的碰撞。
Update
方法中的大部分结构都和上一章的很像,处理第二个玩家的代码被去掉了,同时也增加了一些新的代码:
//
Game not started yet? Then put ball on paddle.
if
(pressSpaceToStart)
{
ballPosition
=
new
Vector2(paddlePosition,
0.95f
-
0.035f
);
//
Handle space
if
(keyboard.IsKeyDown(Keys.Space)
||
gamePad.Buttons.A
==
ButtonState.Pressed)
{
StartNewBall();
}
//
if
}
//
if
else
{
//
Check collisions
CheckBallCollisions(moveFactorPerSecond);
//
Update ball position and bounce off the borders
ballPosition
+=
ballSpeedVector
*
moveFactorPerSecond
*
BallSpeedMultiplicator;
//
Ball lost?
if
(ballPosition.Y
>
0.985f
)
{
//
Play sound
soundBank.PlayCue(
"
PongBallLost
"
);
//
Show lost message, reset is done above in StartNewBall!
lostGame
=
true
;
pressSpaceToStart
=
true
;
//
Play sound
soundBank.PlayCue(
"
PongBallLost
"
);
//
Game over, reset to level 0
level
=
0
;
StartLevel();
//
Show lost message
lostGame
=
true
;
}
//
if
//
Check if all blocks are killed and if we won this level
bool
allBlocksKilled
=
true
;
for
(
int
y
=
0
; y
<
NumOfRows; y
++
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
if
(blocks[x, y])
{
allBlocksKilled
=
false
;
break
;
}
//
for for if
//
We won, start next level
if
(allBlocksKilled
==
true
)
{
//
Play sound
soundBank.PlayCue(
"
BreakoutVictory
"
);
lostGame
=
false
;
level
++
;
StartLevel();
}
//
if
}
//
else
首先检查是否已经开始游戏了,如果没有,重设球的位置,把它放在球板中心。然后检查用户是否按下了空格键或者
A
键,如果按下就开始游戏(随机设置
ballSpeedVector
的值并把球向砖块发射出去)。
其中最重要的一个方法就是
CheckBallCollisions
,这个方法稍后再介绍。然后就像
Pong
游戏那样更新球的位置,并检查是否没有接到球。如果没有接到球,游戏结束,玩家可以从第
1
级重新开始。
最后,检查是否所有砖块都被打碎了,如果是,则播放胜利的音效,此时屏幕上出现“
You Won!
”信息(在
Draw
方法中),按下空格键就可以进入下一级了。
绘制游戏
使用
SpriteHelper
类以后
Draw
方法变得更加简洁了:
protected
override
void
Draw(GameTime gameTime)
{
//
Render background
background.Render();
SpriteHelper.DrawSprites(width, height);
//
Render all game graphics
paddle.RenderCentered(paddlePosition,
0.95f
);
ball.RenderCentered(ballPosition);
//
Render all blocks
for
(
int
y
=
0
; y
<
NumOfRows; y
++
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
if
(blocks[x, y])
block.RenderCentered(blockPositions[x, y]);
if
(pressSpaceToStart
&&
score
>=
0
)
{
if
(lostGame)
youLost.RenderCentered(
0.5f
,
0.65f
,
2
);
else
youWon.RenderCentered(
0.5f
,
0.65f
,
2
);
}
//
if
//
Draw all sprites on the screen
SpriteHelper.DrawSprites(width, height);
base
.Draw(gameTime);
}
//
Draw(gameTime)
首先渲染背景。这里不必先清空背景,因为背景纹理会填充整个游戏背景。为了确保其他所有游戏元素都能在背景之上渲染出来,要在渲染其他元素之前先把背景画出来。
接下来画球板和球,因为使用了
SpriteHelper
类中的
RenderCentered
方法,这个操作就很简单了。这个方法有三个重载版本,如下所示:
public
void
RenderCentered(
float
x,
float
y,
float
scale)
{
Render(
new
Rectangle(
(
int
)(x
*
1024
-
scale
*
gfxRect.Width
/
2
),
(
int
)(y
*
768
-
scale
*
gfxRect.Height
/
2
),
(
int
)(scale
*
gfxRect.Width),
(
int
)(scale
*
gfxRect.Height)));
}
//
RenderCentered(x, y)
public
void
RenderCentered(
float
x,
float
y)
{
RenderCentered(x, y,
1
);
}
//
RenderCentered(x, y)
public
void
RenderCentered(Vector2 pos)
{
RenderCentered(pos.X, pos.Y);
}
//
RenderCentered(pos)
RenderCentered
方法接收一个
Vector2
类型的参数,或者两个
float
类型的参数,并按照
1024×768
的分辨率按比例缩放计算位置。然后,
SpriteHelper
类的
DrawSprites
方法再把所有的游戏元素从
1024×768
的分辨率按比例缩放到当前的屏幕分辨率。这听起来很复杂,但它用起来很方便。
接下来渲染本级中的所有砖块,这也很简单,因为砖块的位置已经在构造函数中计算好了。下面是初始化砖块位置的代码:
//
Init all blocks, set positions and bounding boxes
for
(
int
y
=
0
; y
<
NumOfRows; y
++
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
{
blockPositions[x, y]
=
new
Vector2(
0.05f
+
0.9f
*
x
/
(
float
)(NumOfColumns
-
1
),
0.066f
+
0.5f
*
y
/
(
float
)(NumOfRows
-
1
));
Vector3 pos
=
new
Vector3(blockPositions[x, y],
0
);
Vector3 blockSize
=
new
Vector3(
GameBlockRect.X
/
1024.0f
, GameBlockRect.Y
/
768
,
0
);
blockBoxes[x, y]
=
new
BoundingBox(
pos
-
blockSize
/
2
,
pos
+
blockSize
/
2
);
}
//
for for
其中变量
blockBoxes
用于存储砖块的边界盒数据,用于碰撞检测,这个稍后介绍。位置计算也很简单,
x
坐标范围从
0.05
到
0.95
,也可以把常量
NumOfColumns
的值改成
20
,这样就会有更多的砖块。
最后,如果玩家升级了或者输了,就会在屏幕上渲染对应的消息。然后,调用
SpriteHelper
类的
DrawSprites
方法渲染所有的游戏元素输出到屏幕上。看看对应的单元测试中是如何渲染砖块、球板和游戏信息的,我就是从单元测试开始的,然后才去实现游戏。
碰撞检测
Breakout
游戏中的碰撞检测要比
Pong
游戏稍微复杂一点,因为
Pong
游戏中只要检测与挡板和屏幕边缘的碰撞。这里最复杂的部分就是,当球撞击砖块的时候能正确地反弹回来。完整的检测代码请查看本章的源代码。
像上一个游戏一样,这里也有球、屏幕边缘和球板等元素。只有砖块是新的元素,并且为了检测每一个碰撞,每一帧都要检测所有这些砖块。图
3-16
是撞击砖块的碰撞检测示意图:
图3-16
仔细看一下砖块的碰撞检测代码。屏幕边缘和球板的碰撞检测与
Pong
游戏中的基本上一样,而且可以使用
TestBallCollisions
单元测试来检查。而在检测砖块的碰撞时,要检测所有砖块,看看球的边界盒是否和砖块的边界盒发生了碰撞。实际的游戏代码还要稍微复杂一些,因为要检测撞上了边界盒的哪一边,球要向哪个方向反弹。不过其余代码和整体思想都是一样的。
//
Ball hits any block?
for
(
int
y
=
0
; y
<
NumOfRows; y
++
)
for
(
int
x
=
0
; x
<
NumOfColumns; x
++
)
if
(blocks[x, y])
{
//
Collision check
if
(ballBox.Intersects(blockBoxes[x, y]))
{
//
Kill block
blocks[x, y]
=
false
;
//
Add score
score
++
;
//
Update title
Window.Title
=
"
XnaBreakout - Level
"
+
(level
+
1
)
+
"
- Score
"
+
score;
//
Play sound
soundBank.PlayCue(
"
BreakoutBlockKill
"
);
//
Bounce ball back
ballSpeedVector
=
-
ballSpeedVector;
//
Go outa here, only handle 1 block at a time
break
;
}
//
if
}
//
for for if