4.5 Tetris,Tetris,Tetris!


     关于辅助类和游戏组件已经讨论很多了,现在我们就来编写一个很酷的游戏。正是借助于这些辅助类,在这个新的游戏中,我们才能非常容易地在屏幕上输出文本,绘制
sprites,处理用户输入以及播放声音特效。

     在深入到Tetris游戏逻辑细节之前,仔细考虑一下游戏元素的布置会很有帮助,就像您在前几章所做的那样。起初我们并不是把所有的游戏组件都输出到屏幕上,而是只显示背景边框,看看即将输出那些内容。这里的背景再一次沿用之前的太空背景图(我保证,这将是最后一次)。背景边框是一个新的素材,并且有两种显示模式(如图4-7所示)。它用来区分不同的游戏组件,这样可以让这些组件输出到屏幕上更好看。由于左右两边的显示尺寸不一样,如果使用同一个素材的话会很难看,所以需要把该素材修改成不同的尺寸来使用。

图4-7

4-7

渲染背景

     此处再次使用SpriteHelper类来渲染这些背景边框,并使用下面的单元测试:

public static void TestBackgroundBoxes()
{
    TestGame.Start(
"TestBackgroundBoxes",
        
delegate
        
{
            
// Render background
            TestGame.game.background.Render();
            
// Draw background boxes for all the components
            TestGame.game.backgroundBigBox.Render(new Rectangle(
                (
512 - 200- 1540 - 12400 + 23, (768 - 40+ 16));
            TestGame.game.backgroundSmallBox.Render(
new Rectangle(
                (
512 - 480- 1540 - 10290 - 30300));
            TestGame.game.backgroundSmallBox.Render(
new Rectangle(
                (
512 + 240- 1540 - 10290 - 30190));
        }
);
}
 // TestBackgroundBoxes()

     该测试的输出如图
4-8所示:

图4-8

4-8

     您可能会问为什么右边的边框小一些,而且这些值是如何取得的。其实这里的值都是随意定义的,它们会在最终的游戏里做最合理的调整。First, the background is drawn in the unit test because you will not call the Draw method of TetrisGame if you are in the unit test (otherwise the unit tests won’t work anymore later when the game is fully implemented).

     左上角的区域用来显示下一个要显示的方块,中间的区域用来显示Tetris网格,右上角的区域用来显示记分板。

处理网格

     现在我们来填充上述边框的内容。首先从主要组件TetrisGrid开始,它负责显示整个游戏的网格区域。它还处理用户的输入、移动下落的方块以及显示所有既有的数据。在讨论游戏组件的小节中已经说了TetrisGrid类使用了哪些方法,在渲染网格之前您应该检查一下该类定义的一些常量:

Constants

     还有很多其它有趣的常量,不过现在您只需要网格的尺寸,这里定义了
12列和20行。借助于Block.png素材(就是一个正方形方块),您可以很方便地在Draw方法中绘制出完整的网格区域:

// Calc sizes for block, etc.
int blockWidth = gridRect.Width / GridWidth;
int blockHeight = gridRect.Height / GridHeight;
for (int x = 0; x < GridWidth; x++)
    
for (int y = 0; y < GridHeight; y++)
    
{
        game.BlockSprite.Render(
new Rectangle(
             gridRect.X 
+ x * blockWidth,
             gridRect.Y 
+ y * blockHeight,
             blockWidth 
- 1, blockHeight - 1),
             
new Color(606060128)); // Empty Color
    }
 // for for

     其中变量
gridRect是在主类中传递过来的,用来定义绘制网格的区域。它和背景边框使用的是同一个矩形区域,当然会稍微小一点点以便更好地填充背景。这里您首先要做的就是计算方块的宽度与高度,然后遍历数组,使用SpriteHelper.Render方法来绘制方块,并使用半透明黑色,来展现一个空的背景网格。如图4-9所示。正是因为使用了游戏组件,在单元测试中您都不用去写所有这些代码。单元测试只绘制了背景边框,然后调用TetrisGrid.Draw方法来显示结果(参见TestEmptyGrid单元测试)。
图4-9

4-9

方块类型

     在向网格上渲染东西之前,您需要考虑一下游戏中都将使用哪些种类的方块。标准的Tetris游戏有7种方块类型,它们都是由四块更小的方块彼此连接在一起组合而成的(如图4-10所示)。其中最受欢迎的当然是直线型的,因为它可以同时消去四行方块,而且得分也最多。

图4-10

4-10

     这些方块类型必须在TetrisGrid类中定义。方法之一就是使用枚举来定义所有可能的方块类型。该枚举还定义了空类型的方块,可以应用于整个网格,因为网格中的每一个小方格要么包含预定义方块的一个部分,要么为空。下面来看一下TetrisGrid类中定义的其它常量:

/// <summary>
/// Block types we can have for each new block that falls down.
/// </summary>

public enum BlockTypes
{
    Empty,
    Block,
    Triangle,
    Line,
    RightT,
    LeftT,
    RightShape,
    LeftShape,
}
 // enum BlockTypes
/// <summary>
/// Number of block types we can use for each grid block.
/// </summary>

public static readonly int NumOfBlockTypes =
            EnumHelper.GetSize(
typeof(BlockTypes));
/// <summary>
/// Block colors for each block type.
/// </summary>

public static readonly Color[] BlockColor = new Color[]
{
    
new Color( 606060128 ), // Empty, color unused
    new Color( 5050255255 ), // Line, blue
    new Color( 160160160255 ), // Block, gray
    new Color( 2555050255 ), // RightT, red
    new Color( 25525550255 ), // LeftT, yellow
    new Color( 50255255255 ), // RightShape, teal
    new Color( 25550255255 ), // LeftShape, purple
    new Color( 5025550255 ), // Triangle, green
}
// Color[] BlockColor
/// <summary>
/// Unrotated shapes
/// </summary>

public static readonly int[][,] BlockTypeShapesNormal = new int[][,]
{
    
// Empty
    new int[,] 0 } },
    
// Line
    new int[,] 010 }010 }010 }010 } },
    
// Block
    new int[,] 11 }11 } },
    
// RightT
    new int[,] 11 }10 }10 } },
    
// LeftT
    new int[,] 11 }01 }01 } },
    
// RightShape
    new int[,] 011 }110 } },
    
// LeftShape
    new int[,] 110 }011 } },
    
// LeftShape
    new int[,] 010 }111 }000 } },
}
// BlockTypeShapesNormal

     其中BlockTypes就是之前我们讨论的方块类型枚举,它包含了所有可能的类型,并用于NextBlock游戏组件中随机生成新方块。网格区域在开始时都被空类型的方块填充,网格的定义如下:

/// <summary>
/// The actual grid, contains all blocks,
/// including the currently falling block.
/// </summary>

BlockTypes[,] grid = new BlockTypes[GridWidth, GridHeight];

     另外,NumOfBlockTypes则向您展示了枚举类的好处,您可以很方便地知道BlockTypes枚举中有多少种类型。

     接下来为每种方块类型定义了颜色,这些颜色在生成NextBlock的预览时使用,也用于渲染整个网格。每一个小方格都包含一个方块类型,您可以把枚举类型转换为整型来方便地使用BlockColor,就像在Draw方法中那样:

BlockColor[(int)grid[x,y]]

     最后定义方块形状,这看起来有些复杂,尤其是当您考虑旋转这些方块时。此处使用BlockTypeShapes来实现,它是一个保存了所有方块及其旋转的大数组,在TetrisGrid初始化时进行计算。

     当向网格添加一个新方块时,您可以把方块的每一个部分分别添加到网格上,这个操作在方法AddRandomBlock中进行。这里使用floatingGrid数组来保存每当Update方法被调用时网格的哪些部分需要向下移动(参考下一小节“自由下落”,您不能让所有东西都下落):

// Randomize block type and rotation
currentBlockType = (int)nextBlock.SetNewRandomBlock();
currentBlockRot 
= RandomHelper.GetRandomInt(4);
// Get precalculated shape
int[,] shape = BlockTypeShapes[currentBlockType,currentBlockRot];
int xPos = GridWidth/2-shape.GetLength(0)/2;
// Center block at top most position of our grid
currentBlockPos = new Point(xPos, 0);
// Add new block
for ( int x=0; x<shape.GetLength(0); x++ )
    
for ( int y=0; y<shape.GetLength(1); y++ )
        
if ( shape[x,y] > 0 )
        
{
            
// Check if there is already something
            if (grid[x + xPos, y] != BlockTypes.Empty)
            
{
                
// Then game is over dude!
                gameOver = true;
                Sound.Play(Sound.Sounds.Lose);
            }
 // if
            else
            
{
                grid[x 
+ xPos, y] = (BlockTypes)currentBlockType;
                floatingGrid[x 
+ xPos, y] = true;
            }
 // else
        }
 // for for if

     首先要确定添加哪种类型的方块。此处使用NextBlock类中的一个辅助方法来实现,该方法随机产生一个方块类型,并返回“下一个方块”窗口中显示的方块类型。另外,方块的旋转也是随机的,该操作则借助RandomHelper辅助类来实现。

     有了这些数据,现在您就可以在网格的顶端中央位置显示计算出来的形状。两个for循环遍历整个shape二维数组,它添加形状的每个部分直到和已有的网格数据相冲突。如果这样的话,游戏就结束了,并播放失败的声音。当方块一直堆积到网格的顶端时,您也就无法再添加新的方块了。

     现在,您可以在网格上显示新的方块了。但它如果一直停留在顶部就没什么意思了,它应该往下落。

自由下落

     使用单元测试TestFallingBlockAndLineKill来测试当前方块的自由下落。每次调用TetrisGridUpdate方法时,当前活动的方块都会被更新,不过这样的操作并不经常发生。在游戏第一级的时候,每隔1000毫秒(即1秒)调用一次Update方法。此时要检查当前方块是否还可以向下移动:

// Try to move floating stuff down
if (MoveBlock(MoveTypes.Down) == false ||
    movingDownWasBlocked)
{
    
// Failed? Then fix floating stuff, not longer moveable!
    for ( int x=0; x<GridWidth; x++ )
        
for ( int y=0; y<GridHeight; y++ )
            floatingGrid[x,y] 
= false;
    Sound.Play(Sound.Sounds.BlockFalldown);
}
 // if
movingDownWasBlocked = false;

     大部分的游戏逻辑都是在MoveBlock方法中处理的,它会检测往某个特定方向移动是否可行。如果无法再移动,那它就会被固定住,然后清空floatingGrid数组,并播放方块落地的声音。

     清空floatingGrid数组之后,就没有活动的方块可以移动了,然后使用下面的代码检测是否消除了一行方块:

// Check if we got any moveable stuff,
// if not add new random block at top!
bool canMove = false;
for ( int x=0; x<GridWidth; x++ )
    
for ( int y=0; y<GridHeight; y++ )
        
if ( floatingGrid[x,y] )
            canMove 
= true;
if (canMove == false)
{
    
int linesKilled = 0;
    
// Check if we got a full line
    for ( int y=0; y<GridHeight; y++ )
    
{
        
bool fullLine = true;
        
for ( int x=0; x<GridWidth; x++ )
            
if ( grid[x,y] == BlockTypes.Empty )
            
{
                fullLine 
= false;
                
break;
            }
 // for if
        
// We got a full line?
        if (fullLine)
        
{
            
// Move everything down
            for ( int yDown=y-1; yDown>0; yDown-- )
                
for ( int x=0; x<GridWidth; x++ )
                    grid[x,yDown
+1= grid[x,yDown];
            
// Clear top line
            for ( int x=0; x<GridWidth; x++ )
                grid[
0,x] = BlockTypes.Empty;
            
// Add 10 points and count line
            score += 10;
            lines
++;
            linesKilled
++;
            Sound.Play(Sound.Sounds.LineKill);
        }
 // if
    }
 // for
    
// If we killed 2 or more lines, add extra score
    if (linesKilled >= 2)
        score 
+= 5;
    
if (linesKilled >= 3)
        score 
+= 10;
    
if (linesKilled >= 4)
        score 
+= 25;
    
// Add new block at top
    AddRandomBlock();
}
 // if

     首先判断是否有活动的正在移动的方块。如果没有,则进入if语句块,检测是否有一行被填充并销毁该行。要判断某行是否被填充,您可以先假设它是填充的,然后检查该行是否有空类型的方块。然后,如果该行没有被填充,则检查下一行。如果该行被填充了,则移除它,并将其上面的所有行向下移动一行。这样,玩家就会得到10分,并听到该行被消除的声音。

     如果玩家消除不止一行的方块,还会得到更多的奖励分。然后,调用AddRandomBlock方法在顶部生成一个新方块。

处理输入

     借助于Input辅助类,处理用户的输入不再是什么难事,您可以很容易地检测到是否按下了键盘或者游戏手柄。BaseGame类中处理了按下EscapeBack键的操作,此时将退出游戏。不过,在该Tetris游戏中您只需要四个键,使用左右键进行左右移动,向上键则用来旋转方块,使用向下键,或者空格键,或者A键,可以让方块更快速地下落。

     与检测自由下落来判断是否可以向下移动类似,您还要检测是否可以向左右移动。这部分代码在TetrisGameUpdate方法中实现,因为您需要每一帧都检测用户的输入,而不只是更新TetrisGrid的时候,后者每隔1000毫秒执行一次。之前这部分代码是放在TetrisGridUpdate方法中的,但为了提升用户体验,这些代码被转移了,并被大量地改进,可以让玩家通过连续敲击键盘快速地进行左右移动。

     现在,您已经学习了很多辅助性的代码,而且可以初步运行Tetris游戏了。不过,您应该多多注意MoveBlock方法,因为它是Tetris游戏中最重要的部分。另一个非常重要的方法是RotateBlock,它用来检测方块是否可以旋转,您可以自己查看一下源代码。可以使用TetrisGame类中的单元测试来学习这些方法是如何工作的:

Move block

     这里有三种类型的移动:向左、向右和向下,每种移动都放在单独的程序块中进行检测。在详细研究这个方法之前,有两点需要注意。首先,在方法之前定义了一个movingDownWasBlocked变量。使用该变量是为了加快检测方块是否落地的过程,它作为类级别的变量可以方便Update方法的调用(可能是若干帧之后),and make the gravity code you saw earlier update much faster than in the case when the user doesn’t want to drop the block down right here. 这是游戏非常重要的部分,因为如果每个方块落地时立即被固定,那么游戏会变得很难,并且随着游戏速度变快以及网格被填得越来越满,游戏的乐趣也就消失了。

     接下来使用另一个技巧来简化判断的过程,即暂时把当前方块从网格上移除。这样您就可以很容易地检测新位置是否可用,因为当前位置已经不会再有阻碍了。另外还使用了几个辅助变量来存储新的位置数据,这样负责检测方块四个部分的代码就简化了一些。如果您改变了方块类型以及组成每个方块的小方块的数量,那么也要相应地更改该方法。

     当一切准备就绪之后,您要在三段代码块中检测方块的临时位置是否可用。通常都是可用的,此时newPosNum数组就保存了四个新的值。如果少于三个值的话,变量anythingBlocking就被设置为真。这样的话,原有的方块位置信息被重新保存起来,数组gridfloatingGrid都保持不变。

     但如果移动是可行的,方块位置信息将被更新,并清空floatingGrid数组。最后,把该方块添加到gridfloatingGrid数组中,就可以在新位置上添加该方块了。同时,用户还可听到很小的方块移动的声音,这样该方法也就结束了。

测试

     使用TetrisGrid类中新增的代码,现在您可以测试TetrisGame类中的单元测试了。除了之前您见过的单元测试之外,还有两个更重要的测试游戏逻辑的单元测试:
  • TestRotatingBlock:测试TetrisGrid类的RotateBlock方法
  • TestFallingBlockAndKillLine:测试自由下落以及用户输入
     您经常会根据对游戏的最新更改而去修改旧有的单元测试,这是很显然的。例如,之前介绍的TestBackgroundBoxes单元测试很简单,但在实现以及测试游戏组件的时候这些背景边框的布局以及位置都会发生很大的变化,这个时候就要修改该单元测试以反映这些变化。例子之一就是记分板,它被背景边框包围起来,但在知道记分板到底有多大之前,您需要确定其中的内容以及这些内容要占据的空间。当写完TestScoreboard方法之后,您会发现记分板比“NextBlock”区域小得多。

     游戏测试的另一部分就是要不断地检查游戏的bug以及改进游戏代码。前面做的几个游戏都很简单,在完成初始版本之后您只需要做很小的改进。但Tetris游戏要复杂的多,您会花很多时间来修正并改进它。

     最后要测试的就是在Xbox 360平台上运行该游戏,具体的步骤在第一章已经介绍过了。如果您写了新的代码,还要确保在Xbox 360平台下编译它们。您也不能调用非托管程序集,而且.NET 2.0 Framework中的某些类和方法也无法在Xbox 360上使用。