辅助类——Breakout游戏

Breakout游戏

好极了,本章谈论了很多辅助类,最终是时候把它们派上些用场了。这里我将跳过游戏的构思阶段,Breakout游戏大体上说只是一个Pong游戏缩略版本,它只有单人模式,面对的是一墙砖块。最初Breakout游戏是由Nolan Bushnell和Steve Wozniak发明的,并在1976年由Atari公司发行。在这个早期版本中,它就像Pong游戏一样仅仅是个黑白游戏,但为了让它更让人振奋,给显示器上蒙了一层透明条纹来给砖块上色(如图3-13所示)。

1
图 3-13

实际上,您将通过复用一些Pong的组件以及本章学到的辅助类,走一条相似的路。Breakout是一个比Pong更加复杂的游戏;它有很多level关卡,而且有相当大的改进空间。例如,Arkanoid是Breakout的一个克隆,并且在20世纪80-90年代有许多游戏都是基于这个游戏创意,它们添加了武器、更好的图形特效,以及许多砖块摆放位置不同的level关卡。

正如图3-14中所见,BreakoutGame类以和上一章的Pong类相似的方式构建。sprite处理被忽略是因为现在是使用SpriteHelper类完成的。其它一些内部方法和调用也被某些辅助类取代了。例如,StartLevel方法基于当前level值产生一个新的随机level值,这里你将使用RandomHelper类产生这些随机值。

2
图 3-14

请注意,在这个类中还可以看到许多测试方法。在下一章中将对辅助类做一些类似的改进,下一章将介绍BaseGame和类对处理游戏类的TestGame类,尤其是单元测试变得更加简单和更有条理。

看一看图3-15对接下来几页将要开发的Breakout游戏有一个快速概览。它相当有趣,肯定比Pong游戏更有可玩性,无论如何Pong游戏只有两个人玩的时候才有意思。Breakout游戏使用了相同的背景纹理和两个来自于Pong项目的声音文件,不过你也为球板(paddle)添加了一个新纹理(BreakoutGame.png)、球、砖块,以及胜出level关卡(BreakoutVictory.wav)和打碎砖块(BreakoutBlockKill.wav)的新的声音文件。

3
图 3-15

Breakout中的单元测试

在开始从头到尾复制/粘贴上一个项目的代码、使用新的辅助类以及绘制新的游戏元素之前,应该考虑一下游戏以及你可能遇到的问题。当然,您可以继续前进,并且实现游戏,但后面可能会困难很多,比如检测碰撞,它是这个游戏最难的部分。单元测试帮助您解决,至少是提供了一种简单的方式,检查游戏的所有基本部分,帮助您组织代码,强制你只编写真正需要的东西。就如以前一样,先从游戏最直观的部分开始,并测试它,然后添加更多的单元测试直到你都完成,最后把一切组合起来并测试最终的游戏。

下面是Breakout游戏中单元测试的要点概览;本章的更多内容是察看完整的源代码。你还没有TestGame类,所以您仍然得使用上一章用过的同一种单元测试。查看下一章,有更好的方式做静态单元测试。你只有三个单元测试,但它们会被使用和修改很多次,就像我实现游戏的时候那样。

  • TestSounds -只是一个快速测试以检查项目中所有新增的声音文件。按下space键、Alt键、Control键和Shift键来播放声音。我还在播放下一个声音之前添加了一个小小的暂停,这样更容易听清楚。这个测试被用来检查为这个游戏新建的XACT项目。

  • TestGameSprites -这个测试最初用来测试SpriteHelper类,不过后来所有代码都被移到了游戏类的Draw方法中。该测试还用来初始化游戏中的所有砖块;这部分代码被移到了构造器中,它将被展示在本章结尾。这告诉你结尾处的复杂测试并不重要,因为该测试现在只有4行代码,重点是利用测试让您编写游戏的生活更加简单。每当需要的时候,可以复制/粘贴单元测试有用的部分到你的代码。静态单元测试也没有必要像辅助类的动态单元测试那样完整,因为您只在构建和测试游戏的时候使用它们。当游戏运行,您就不再需要这些静态单元测试,除非游戏的测试部分在后面的时间还有需要。

  • TestBallCollisions -就像上一章检测球的碰撞那样是最有用的单元测试。这里,你要检测碰撞是否如预期的发生在屏幕边缘和球板上。要完成这一点只需要一些小改动。然后就是更加复杂的砖块碰撞代码,这个稍后将详细说明。你甚至可能想出更多的方法来检测碰撞,如果你喜欢还可以改进游戏。例如,把球发射到砖块墙的后面,看看它能否正确的打碎所有砖块,就很有意义。

Breakout级别

因为你要用到很多Pong游戏里现成的东西,所以可以跳过那些相似或者相同的代码。就现在来说,您应该关注的是那些新变量:

/// <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];

首先你定义砖块有多少列和你能拥有砖块数量的最大值;在第一level关卡不会填满砖块行,只使用砖块最大数量的10%。球板定位也比在Pong游戏中的简单一些,因为你只有一个玩家。之后保存当前游戏的level级别和得分,这些是新内容。在Pong游戏中每个玩家只有三个球,如果所有球丢失游戏就结束了。在Breakout中,玩家从level 1开始,直到他最后丢掉球为止,可以不断地向上升级。这里不会得到高分,也没有任何游戏字体,所以级数和分数就直接在窗口的标题栏上进行更新。

接下来定义所有的砖块;最重要的数组就是blocks,它会告诉您哪个砖块当前被使用。Blocks在每一级开始之前初始化,然而blockPositions和blockBoxes只在游戏类的构造器中初始化一次;blockPositions用来确定待渲染砖块的中心位置,blockBoxes用来确定砖块的碰撞检测的边界盒(bounding box)。要注意的是,无论这些数组还是位置数值都没有使用屏幕坐标系。所有的位置数据被保存为0-1的格式:0代表左边或者顶部,1代表右边或者底部。这种方式可以使游戏独立于分辨率,并且使得渲染和碰撞检测都更容易。

level级别是在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循环里,你只是依照level级别重新填充整个砖块数组的值。在level 1中,level值设为0,并且只填充10%的砖块。RandomHelper.GetRandomInt(10)方法返回0-9范围内的值,这样小于1的概率只有10%。在level 1中,这个概率就上升到20%,直到你到达在level 10或更高,那就是100%了。实际上游戏没有上限,只要想玩就可以一直玩下去。

然后,你清除底下三行的砖块,让游戏开始的几级容易一些。在level 3 的时候,只有2行被移除,在level 5的时候就只移除1行,一直到level 7用到所有的行。

不同于Pong,在新游戏开始的时候球的速度向量。球停留在球板上,直到用户按下space键或者A键。然后球朝着一个随机位置弹离球板,球会在砖块墙、屏幕边界以及球板之间来回运动,直到所有的砖块都被打碎了玩家就通了一关,或者玩家由于没有接到球而输了。

最后,更新窗口的标题栏来显示到目前为止玩家的到达的关卡数和得分。在这个非常简单的游戏中,玩家每打碎一个砖块只得到1分;达到100分就非常棒了。但就如我之前所说,游戏没有限制。尽量取得更高的分数来体验游戏的快乐。

游戏循环

在Pong中游戏循环非常简单,主要包含用户输入以及碰撞检测代码。Breakout就稍微复杂一些,因为你必须处理球的两种状态。一种状态是球停留在球板上,等待用户按下space键;另一种状态是游戏进行中,必须要检测球与屏幕边界、球板、每个砖块之间的碰撞。

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");
    // 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

首先检查球是否还没有启动。如果球的位置没有变更,就把它放在玩家的球板中心。然后检查space键或者A键如果被按下,就同时启动球(你只要随机化ballSpeedVector的值,球就弹向砖块墙)。

最重要的方法是CheckBallCollisions,这个方法稍后再仔细察看。然后就像在Pong游戏中那样更新球,并检查是否没有接到球。如果玩家没有接到球,游戏结束,玩家可以从第1级重新开始。

最后,检查是否所有砖块都被打碎了以及关卡背完成。如果所有砖块被打碎,则播放胜利的音效,并且开始下一关卡。玩家看到屏幕上出现一条“You Won!”的信息(见Draw方法),按下space键就可以进入下一关卡了。

绘制游戏

归功于SpriteHelper类,Breakout 游戏的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类型的参数,或者x、y两个float类型的参数,并且从0-1的格式(你在游戏中使用的格式)重新缩放定位到1024×768的分辨率。然后,SpriteHelper类的Draw方法再把所有的一切从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按照你拥有的列数步进(如果你记得正确,是14列)。也可以试着把常量NumOfColumns的值改成20,场景就会有更多的砖块。

最后,如果玩家升级了或者输了,就会在屏幕上渲染对应的消息。然后,调用SpriteHelper类的DrawSprites方法渲染所有的游戏元素输出到屏幕上。看看对应的单元测试中是如何渲染砖块、球板和游戏信息的,我就是从单元测试开始的,然后才去实现游戏。

碰撞检测

Breakout游戏的碰撞检测比只要检测球拍和屏幕边界的Pong游戏更复杂一点。最复杂的部分就是,球撞击砖块的时候能正确地反弹回来。完整的检测代码请查看本章的源代码。

像上一个游戏一样,也有一个带有边界盒(bounding box)的球、屏幕边界和球板。砖块是新元素,并且为了检测每一个碰撞,每一帧你都要检测所有的砖块。如图3-16是游戏中砖块发生的的一个碰撞示例:

4
图 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 outta here, only handle 1 block at a time
      break;
    } // if
} // for for if
原文地址:https://www.cnblogs.com/AlexCheng/p/2120264.html