所有辅助类和游戏组件讨论得够多了。是编写下一个酷游戏的时候了。归功于可以利用小游戏引擎的许多类,在屏幕上书写文本、绘制精灵、播放声音现在很简单。
在进入Tetris游戏逻辑之前,以你在前几个游戏所作的相同方式,考虑所有游戏元素的定位是很有益处的。你只要展示一下背景盒以便于理解将要显示些什么,而不是在屏幕上绘制所有的游戏组件。对于背景,你再一次使用了宇宙背景(我承诺这将是最后一次)。背景盒是一个新纹理,并且以两种模式存在(见图 4-7)。通常分离游戏组件,让每个组件更适应于屏幕。你也可能正好对游戏的两个部分都复用同样的box,不过因为它们之间的外观比例迥异,可能看上去既不适合背景盒,也不适合别的游戏组件,这些组件太小,但是也需要背景盒图像,只是小上一号。
要渲染这些屏幕上的box,你再次使用了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) - 15, 40 - 12, 400 + 23, (768 - 40) + 16)); TestGame.game.backgroundSmallBox.Render(new Rectangle( (512 - 480) - 15, 40 - 10, 290 - 30, 300)); TestGame.game.backgroundSmallBox.Render(new Rectangle( (512 + 240) - 15, 40 - 10, 290 - 30, 190)); }); } // TestBackgroundBoxes()
这个单元测试将产生如图 4-8所示的输出。
你可能要问右边的box是较小的一个,我从哪里得到所有这些值?好的,我只是以任意值开始,然后改进这些值直到一切适应于最终的游戏。首先,在单元测试中绘制背景,因为如果你是在单元测试中,就不会调用TetrisGame类的Draw方法(否则在游戏彻底完成的时候,单元测试再也不能运行)。
然后三个box被绘制。左上方的box用来展示下一个砖块。中间的box显示当前的Tetris 网格。最后,右边的box被用来显示记分板。先前你应该也看到了它们的单元测试。
是时候填充所有box的内容了。从主组件:TetrisGrid开始。这个类负责显示整个Tetris Grid。处理输入和移动下落的砖块,也显示所有现行的数据。讨论游戏组件时,你已经看到了用在TetrisGrid类中的方法。在渲染Grid之前,你应该查看TetrisGrid类中首先定义的常量:
#region Constants public const int GridWidth = 12; public const int GridHeight = 20; ..
有一系列更有趣的常量,不过现在你只需要Grid的尺寸。所以在你的 Tetris field有12列20行。借助于Block.png纹理,它只是一个简单的方形砖块,你现在能用Draw方法简单地绘制整个grid:
// 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(60, 60, 60, 128)); // Empty color } // for for
gridRect变量被作为一个参数从主类中被传递给Draw方法,以指定你想绘制grid的区域。它和你用在背景盒的矩形一样,只是稍小一点以适应。你在这里要做的第一件事就是计算每个将要绘制的砖块的宽度和高度。然后遍历整个数组,借助于SpriteHelper.Render方法使用一种半透明的暗色绘制每个砖块来展示一个空的grid场地。如图 4-9所示可以看到看上去什么样子。因为你实际上使用游戏组件你也无须在单元测试中作所有的代码。单元测试仅仅绘制背景盒,然后调用TetrisGrid.Draw 方法来显示结果(见TestEmptyGrid 单元测试)
在你为渲染新grid做有益之事之前,你应该考虑游戏中的砖块类型。标准的Tetris游戏有7种砖块类型;它们都有彼此相连的4个小砖块组成(如图 4-10)。最受欢迎的砖块类型是直线型,因为它能销毁多达4行,如果你深得其精髓。
这些砖块类型必须在TetrisGrid类中定义。这样做的一种方式是使用一个保存了所有可能的砖块种类的枚举数类型。这个枚举也包含了一个空砖块类型,以允许你对于整个grid也可以使用这个数据结构,因为每个grid砖块能保含任意预定义砖块类型的一部分,或者空的部分。看一看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( 60, 60, 60, 128 ), // Empty, color unused new Color( 50, 50, 255, 255 ), // Line, blue new Color( 160, 160, 160, 255 ), // Block, gray new Color( 255, 50, 50, 255 ), // RightT, red new Color( 255, 255, 50, 255 ), // LeftT, yellow new Color( 50, 255, 255, 255 ), // RightShape, teal new Color( 255, 50, 255, 255 ), // LeftShape, purple new Color( 50, 255, 50, 255 ), // Triangle, green }; // Color[] BlockColor /// <summary> /// Unrotated shapes /// </summary> public static readonly int[][,] BlockTypeShapesNormal = new int[][,] { // Empty new int[,] { { 0 } }, // Line new int[,] { { 0, 1, 0 }, { 0, 1, 0 }, { 0, 1, 0 }, { 0, 1, 0 } }, // Block new int[,] { { 1, 1 }, { 1, 1 } }, // RightT new int[,] { { 1, 1 }, { 1, 0 }, { 1, 0 } }, // LeftT new int[,] { { 1, 1 }, { 0, 1 }, { 0, 1 } }, // RightShape new int[,] { { 0, 1, 1 }, { 1, 1, 0 } }, // LeftShape new int[,] { { 1, 1, 0 }, { 0, 1, 1 } }, // Triangle new int[,] { { 0, 1, 0 }, { 1, 1, 1 }, { 0, 0, 0 } }, }; // BlockTypeShapesNormal
BlockTypes 是我们谈到过的enum枚举类型;它包含所有可能的砖块类型,也在NextBlock游戏组件中被用于随机生成新砖块。最初的所有grid区域被空的砖块类型所填充。grid被定义为:
/// <summary> /// The actual grid, contains all blocks, /// including the currently falling block. /// </summary> BlockTypes[,] grid = new BlockTypes[GridWidth, GridHeight];
顺便提一下,NumOfBlockTypes显示了枚举类的用途。你很容易决定BlockTypes 枚举有多少条目。
接下来是每个砖块类型的颜色被定义了。这些颜色可以用NextBlock来预览,而且渲染整个grid也能看到。每个grid有一个block type,通过转换emun为一个int型数,你也易于使用砖块颜色,这些代码被写在Draw方法中:
BlockColor[(int)grid[x,y]]
最后,砖块形状被定义,看上去有点更复杂了,特别是如果你考虑到必须允许这些砖块的零件要旋转。借助于BlockTypeShapes这么做,它是一个大数组所有可能的砖块旋转在TetrisGrid的构造器中。
要给Tetris grid添加一个新砖块,你只要给你的grid添加砖块的每一个零件,AddRandomBlock 方法做这件事。每一次update方法被调用,都调用floatingGrid方法,以维持了一个独立列表,来记忆grid中那些必须向下移动的部分(见下一片断,“Gravity”;你不是仅仅让一切落下):
// 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 类的一个辅助方法,它随机化下一个砖块类型,并且返回被显示在NextBlock 窗口的上一个砖块类型。旋转也被随机化;对RandomHelper 类说“hi”。
有了这些数据,现在你能得到预先计算好的形状,并且把它放到grid的顶部中心。两个for 循环迭代贯穿于整个形状。它添加了每一个合法的形状零件。直到你碰到任何已存在于grid的数据。万一发生了Game Over,你会听到失败的声音。如果砖块堆到达grid顶部,你不会添加任何新砖块。
现在你在grid中有了新砖块,不过只在顶部那里看到它是令人生厌的,它常常应该落下。
为了测试当前砖块的重力,使用了TestFallingBlockAndLineKill 单元测试。每一次你调用TetrisGrid类的update方法,活动砖块会被更新,它不是非常频繁调用的。在第一个level关卡,每1000ms(每秒)才调用一次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;
大多数Tetris逻辑在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 语句块”,就检查是否一个完整行被充满,并且能被销毁。要决定是否一行被填充,你假定它是填充的,然后检查这行的任何砖块是否为空。你知道这行没有完全被填满,继续检查下一行。如果这行被填满,通过拷贝其上所有行下移来删除它。这个位置有一个漂亮的爆炸可能发生。总之,玩家每销毁1行得到10分,你能听到行销毁的声音。
如果玩家能销毁的多于一行,他就得到更多分数的回报。最后是你之前看过的AddRandomBlock 方法,它用来在顶部创建一个新砖块。
归功于 Input 辅助类,处理用户输入本身再也不是一个大任务了。你能容易地检查是否有一个箭头或者手柄按键仅仅被按,还是被持续按下,。Escape 和 Back 键在 BaseGame 类中被处理,允许你退出游戏。除此之外,在 Tetris 游戏中,你只需要4个键。为了向左、右移动,使用了相应的箭头按键。 up 箭头按键被用来旋转当前砖块,down 箭头按键、space 键或者 A 键用来让砖块更快地落下。
类似于重力检查,察看是否你能下移砖块,同样检查被作用于察看是否你能让当前砖块左移或者右移。只有当检查结果为true,你才实际地移动砖块;因为你想对每一帧的玩家输入做检查,不仅仅是当更新TetrisGrid的时候,正如你前面学到的,更新TetrisGrid可能每1000ms才发生,所以代码被放在TetrisGame 类的 Update方法。在前面提到的在TetrisGrid类的 Update方法中的代码不过是为了改善用户移动时的体验,并且通过击打多次箭头按键允许你大大改进砖块的左右移动速度。
好了,关于所有的支援代码,你已经学了很多,你几乎要在第一时间运行Tetris 游戏了。但是你应该看一看MoveBlock 辅助方法,因为它是最完整的,并且是Tetris 游戏最重要的部分。另一个重要方法是RotateBlock 方法,它以相似的方式测试一个砖块能否被旋转。你可以亲自察看Tetris 游戏的源代码。请使用TetrisGame类中的单元测试来看这些方法是如何工作的:
#region Move block public enum MoveTypes { Left, Right, Down, } // enum MoveTypes /// <summary> /// Remember if moving down was blocked, this increases /// the game speed because we can force the next block! /// </summary> public bool movingDownWasBlocked = false; /// <summary> /// Move current floating block to left, right or down. /// If anything is blocking, moving is not possible and /// nothing gets changed! /// </summary> /// <returns>Returns true if moving was successful, otherwise false</returns> public bool MoveBlock(MoveTypes moveType) { // Clear old pos for ( int x=0; x<GridWidth; x++ ) for ( int y=0; y<GridHeight; y++ ) if ( floatingGrid[x,y] ) grid[x,y] = BlockTypes.Empty; // Move stuff to new position bool anythingBlocking = false; Point[] newPos = new Point[4]; int newPosNum = 0; if ( moveType == MoveTypes.Left ) { for ( int x=0; x<GridWidth; x++ ) for ( int y=0; y<GridHeight; y++ ) if ( floatingGrid[x,y] ) { if ( x-1 < 0 || grid[x-1,y] != BlockTypes.Empty ) anythingBlocking = true; else if ( newPosNum < 4 ) { newPos[newPosNum] = new Point( x-1, y ); newPosNum++; } // else if } // for for if } // if (left) else if ( moveType == MoveTypes.Right ) { for ( int x=0; x<GridWidth; x++ ) for ( int y=0; y<GridHeight; y++ ) if ( floatingGrid[x,y] ) { if ( x+1 >= GridWidth || grid[x+1,y] != BlockTypes.Empty ) anythingBlocking = true; else if ( newPosNum < 4 ) { newPos[newPosNum] = new Point( x+1, y ); newPosNum++; } // else if } // for for if } // if (right) else if ( moveType == MoveTypes.Down ) { for ( int x=0; x<GridWidth; x++ ) for ( int y=0; y<GridHeight; y++ ) if ( floatingGrid[x,y] ) { if ( y+1 >= GridHeight || grid[x,y+1] != BlockTypes.Empty ) anythingBlocking = true; else if ( newPosNum < 4 ) { newPos[newPosNum] = new Point( x, y+1 ); newPosNum++; } // else if } // for for if if ( anythingBlocking == true ) movingDownWasBlocked = true; } // if (down) // If anything is blocking restore old state if ( anythingBlocking || // Or we didn't get all 4 new positions? newPosNum != 4 ) { for ( int x=0; x<GridWidth; x++ ) for ( int y=0; y<GridHeight; y++ ) if ( floatingGrid[x,y] ) grid[x,y] = (BlockTypes)currentBlockType; return false; } // if else { if ( moveType == MoveTypes.Left ) currentBlockPos = new Point( currentBlockPos.X-1, currentBlockPos.Y ); else if ( moveType == MoveTypes.Right ) currentBlockPos = new Point( currentBlockPos.X+1, currentBlockPos.Y ); else if ( moveType == MoveTypes.Down ) currentBlockPos = new Point( currentBlockPos.X, currentBlockPos.Y+1 ); // Else we can move to the new position, lets do it! for ( int x=0; x<GridWidth; x++ ) for ( int y=0; y<GridHeight; y++ ) floatingGrid[x,y] = false; for ( int i=0; i<4; i++ ) { grid[newPos[i].X,newPos[i].Y] = (BlockTypes)currentBlockType; floatingGrid[newPos[i].X,newPos[i].Y] = true; } // for Sound.Play(Sound.Sounds.BlockMove); return true; } // else } // MoveBlock(moveType) #endregion
你能做3种移动:左、右、下。这些移动的每一种都在独立的代码块中被处理,来看是否可获得左、右、下的数据,以及是否可能移到那里。在深入这个方法的细节之前,有两件事应该被提及。首先,有一个辅助变量叫movingDownWasBlocked ,被定义在方法之上。有这个变量的原因是为了加快检查当前砖块是否到达地面过程的速度,并且该变量在类这一级中储存以便于稍后让Update方法拾起(可能是几帧之后),并且当用户不想掉落砖块的地方,使得你早先看到的重力代码的刷新大大加速。这是游戏中非常重要的一个部分,因为如果每个砖块到达地面的时候就立刻被固定,游戏就变得非常困难,乐趣就丢失了,当游戏变得越快 grid变得越充满。
然后你使用另一个技巧来简化检查过程,即通过临时从grid中删除当前砖块。以这种方式你能简单察看一个新位置是否可行,因为你的当前位置不再阻塞。代码也使用了几个辅助变量来储存新位置,并且代码被简化了一点因为只计算4个砖块零件。如果你改变了砖块类型、砖块零件数目,你应该也要修改这个方法。
在设置好一切之后,你察看一个新的虚拟砖块位置是否在三个代码块中可行。通常它是可行的,并且在newPosNum 数组中以4个新值终结。如果获得的值少于三个,你就知道有阻塞了,无论如何anythingBlocking 变量被设为true了。在这种情况,旧砖块位置被恢复,grid和floatingGrid 数组都保持原状。
不过万一移动尝试是成功的,砖块位置就被更新,你清空floatingGrid,并且最后再次经由grid和floatingGrid 数组,把砖块添加到新位置。用户也听到一个非常安静的砖块移动声音,你在方法中这么做的。
在TetrisGrid类中所有的新代码你现在可以在TetrisGame类中进行单元测试。额外要测试的是你先前看到的对于游戏逻辑两个最重要的单元测试:
TestRotatingBlock, 它测试TetrisGrid类的RotateBlock方法。
TestFallingBlockAndKillLine, 它被用来测试gravity重力和你刚刚所学的用户输入。
要根据游戏需求做出最新修改,从而你显然应该经常追溯旧单元测试来更新它们。例如,你前面看到的TestBackgroundBoxes 单元测试非常简单,不过当执行和测试游戏组件时,背景盒的层次规划和位置改变相当大,并且它的更新必须根据修改的反映。这样的一个例子就是记分板,它被背景盒所环绕,在你能知道记分板多大之前,你必须知道什么内容,以及它们要消耗多大空间。举个例子,编写TestScoreboard 方法之后,很明显记分板比起NextBlock 背景盒一定要小得多。
游戏测试的另一个部分是不断地检查bug,改善游戏代码。前几个游戏相当简单,在第一次运行之后你只需要较小的改动,不过Tetris 要复杂得多,你要花上几小时修正和改进它。
最后一件事,你可能在Xbox 360上测试游戏的运行 ----只要在工程项目中选择Xbox 360控制台,就尝试在Xbox 360上运行了。所有要做的步骤在第一章里说明过了,万一在Xbox 360上不正常工作,它还有一个有用的故障检修片断。如果你写了新代码你应该确保也在Xbox360上时不时地编译之。你不被容许编写任何调用非托管配件的interop代码,某些.Net 2.0 Framework 类和方法在Xbox 360上缺少。