《XNA高级编程:Xbox 360和Windows》2-3(1)


2.3开始编写Pong

     游戏构思方面的问题都解决了,并且需要的文件也有了,是时候做些实际的编码工作了。首先查看一下SpriteBatch类,看看如何容易地管理所有的sprites。SpriteBatch类不仅能按照图像保存时的格式来渲染sprites,还能把它们放大或缩小,给它们重新着色,甚至能旋转它们。

     当把菜单加进来之后,您就可以添加球拍了,并借助第一章处理键盘输入的方式来移动它们。球的运动主要是靠一些简单的变量,而且每次球撞击球拍之后还能反弹回来并发出声音文件PongBallHit.wav的声音。如果球出了左边或右边的屏幕边界,将播放声音文件PongBallLost.wav的声音,同时玩家就死了一回。

     我们将会使用一些单元测试来确保菜单和最基础的游戏可以正常工作,然后再添加更多的单元测试来测试更加复杂的部分,比如用球拍的侧边来撞击球以及微调游戏的玩法。为了支持多玩家,您还将使用一个单元测试来测试操作,然后把它们添加到菜单选项来支持多人对战模式。

     在本章的后面您将在Xbox 360平台上测试整个游戏,并思考如何对游戏做更多的改进。

Sprites

     正如在第一章中看到的,SpriteBatch类用来在屏幕上直接渲染您的纹理素材。因为您还没有任何的辅助类,您仍将使用相同的方式来渲染所有的东西。有关辅助类可以让您每天的游戏编程生活更容易的信息,可以参考第三章。在您的Pong游戏中您将使用两个层:空间背景,它通过加载PongBackground.dds纹理文件来实现;菜单文本内容和游戏元素(球和球拍),分别使用菜单纹理和游戏元素的纹理来呈现。

     首先要定义您要使用的纹理变量,如下所示:

private Texture2D backgroundTexture, menuTexture, gameTexture;

然后在Initialize方法中加载所需要的东西:

// Load all our content
this.backgroundTexture = this.content.Load<Texture2D>("SpaceBackground");
this.menuTexture = this.content.Load<Texture2D>("PongMenu");
this.gameTexture = this.content.Load<Texture2D>("PongGame");

最后您可以使用在第一章中学到的,使用SpriteBatch的方法来渲染游戏背景:

this.spriteBatch.Begin();
this.spriteBatch.Draw(this.backgroundTexture,
                     
new Rectangle(00, width, height), 
                     Color.LightGray);
this.spriteBatch.End();

     这里使用LightGray这个颜色是为了让背景稍微变暗些,这样背景就可以和前景项(菜单文本内容和游戏元素)形成比较鲜明的对比。正如您看到的,渲染一个sprite到屏幕上并非只使用了一行代码,而且要渲染sprite纹理的一些部分还会变得更加复杂。看看游戏中您要使用的矩形(rectangle)对象:

private static readonly Rectangle
            XnaPongLogoRect 
= new Rectangle(00512110),
            MenuSingleplayerRect 
= new Rectangle(011051238),
            MenuMultiplayerRect 
= new Rectangle(014851238),
            MenuExitRect 
= new Rectangle(018651238),
            GameLifesRect 
= new Rectangle(022210034),
            GameRedWonRect 
= new Rectangle(15122215534),
            GameBlueWonRect 
= new Rectangle(33822216534),
            GameRedPaddleRect 
= new Rectangle(2302292),
            GameBluePaddleRect 
= new Rectangle(002292),
            GameBallRect 
= new Rectangle(1943333),
            GameSmallBallRect 
= new Rectangle(371081919);

     使用的矩形对象还真的挺多,不过相对于类似导入XML数据这样的操作而言使用这些常量值已经简单多了。这里使用静态只读(static readonly)变量而不使用常量(const)变量是因为常量变量不能声明为结构类型的,而且它们的使用方式是一样的。您或许会问怎样得到这些值,又如何确保这些值是正确的。

在游戏中使用单元测试

     从这里我们开始使用单元测试。对于游戏编程来说单元测试主要是把您的问题分解成易于管理的小问题,即使为这样一个非常简单的游戏编写单元测试也是一种好想法。单元测试可以很好地在屏幕上排列您的纹理素材,测试音效,并添加碰撞检测。起初,在我写这一章以及Pong游戏的时候并未打算使用单元测试,但当我一开始编程我就控制不住自己,等我意识到的时候我已经写了六个单元测试了,我不得不承认既然这样就这样继续走下去吧,也没有必要把这些有用的单元测试都删掉。

     比如,测试菜单图像的矩形对象可以使用下面的单元测试:

public static void TestMenuSprites()
{
  StartTest(
    
delegate
    
{
      testGame.RenderSprite(testGame.menuTexture,
        
512-XnaPongLogoRect.Width/2150,
        XnaPongLogoRect);
      testGame.RenderSprite(testGame.menuTexture,
        
512-MenuSingleplayerRect.Width/2300,
        MenuSingleplayerRect);
      testGame.RenderSprite(testGame.menuTexture,
        
512-MenuMultiplayerRect.Width/2350,
        MenuMultiplayerRect, Color.Orange);
      testGame.RenderSprite(testGame.menuTexture,
        
512-MenuExitRect.Width/2400,
        MenuExitRect);
   }
);
}
 // TestMenuSprites()

请注意:这并不是本书最终的单元测试代码,您只需要使用它的基本思想,这个委托(delegate)包含在Draw方法中每一帧都要执行的代码。

     您可能会问:StartTest是什么?testGame和RenderSprite又是什么?它们都从哪儿来?好,它是传统的游戏编码方式和使用单元测试的敏捷开发之间主要的不同点之一,所有这些方法目前还不存在。和您设计游戏一样,您也可以写下您想怎样进行测试,在这个例子中就是显示游戏Logo和三个菜单选项(单人模式、多人模式和退出)。

     写好一个单元测试,纠正所有的语法错误,您就可以立即编译您的代码开始测试——按下F5,您会发现一连串的错误,这些错误都要一步一步地解决,然后才可以开始进行单元测试。静态单元测试通常不使用Assert方法,但可以添加一些代码,当某些值不是预期的值时以便抛出异常。对于您的单元测试,您只需要查看屏幕的输出结果来测试,然后修改RenderSprite方法直到一切都按您所想的方式工作。

     下一章将详细讨论单元测试。对于这个Pong游戏,您只需要继承PongGame类,然后在您的单元测试中添加一个委托来执行自定义代码:

delegate void TestDelegate();
class TestPongGame : PongGame
{
    TestDelegate testLoop;
    
public TestPongGame(TestDelegate setTestLoop)
    
{
        testLoop 
= setTestLoop;
    }
 // TestPongGame(setTestLoop)
    protected override void Draw(GameTime gameTime)
    
{
        
base.Draw(gameTime);
        testLoop();
    }
 // Draw(gameTime)
}
 // class TestPongGame

     现在您就可以写上面的那个StartTest方法了,先创建一个TestPongGame类的实例,然后调用TestPongGame的Run方法执行Draw方法里的自定义testLoop代码:

static TestPongGame testGame;
static void StartTest(TestDelegate testLoop)
{
    
using (testGame = new TestPongGame(testLoop))
    
{
        testGame.Run();
    }
 // using
}
 // StartTest(testLoop)

这里使用静态实例testGame是为了让编写单元测试更简单,但如果您在其他地方使用容易引起混乱,因为只有调用StartTest之后这个值才有意义,在后面的章节中您会看到更好的实现方式。

     现在第一版本中的单元测试的两个错误已经解决了,唯独还没有RenderSprite方法,这里只要能让单元测试能正确执行,添加一个空方法也是可以的:

public void RenderSprite(Texture2D texture, int x, int y,
            Rectangle sourceRect, Color color)
{
    
// TODO
}
 // RenderSprite(texture, rect, sourceRect)
public void RenderSprite(Texture2D texture, int x, int y,
            Rectangle sourceRect)
{
    
// TODO
}
 // RenderSprite(texture, rect, sourceRect)

     添加上述的这两个方法之后您就可以执行前面的TestMenuSprites方法了,那要怎样做呢?如果使用TestDriven.Net,您只要点击右键然后选择“Start Test”就可以了。不过,XNA Game Studio Express不支持插件,所以您得修改Program.cs文件中的Main方法来自己编写单元测试:

static void Main(string[] args)
{
    
// PongGame.StartGame();
    PongGame.TestMenuSprites();
}
 // Main(args)

正如您所看到的,我把StartGame方法单独提取了出来,这样可以让Main方法更容易阅读,而且可以容易地在多个单元测试之间来回变换。StartGame代码如下:

public static void StartGame()
{
    
using (PongGame game = new PongGame())
    
{
        game.Run();
    }
 // using
}
 // StartGame()

     现在按下F5,单元测试的代码将取代原始的游戏代码而执行。因为RenderSprite方法不包含任何代码,所以您只能看到PongGame的Draw方法中画出的背景图,现在您来添加代码把菜单加上去。虽然您知道了如何渲染Sprite,但是每一次调用RenderSprite方法都要重新启动并结束SpriteBatch,这样的效率还是非常低的。您可以在每一帧创建一个简单的您想渲染的sprite列表,每次调用RenderSprite的时候就添加这样的一个列表,然后在每一帧的最后把它们一起全都渲染出来:

class SpriteToRender
{
    
public Texture2D texture;
    
public Rectangle rect;
    
public Rectangle? sourceRect;
    
public Color color;
    
public SpriteToRender(Texture2D setTexture, Rectangle setRect,
                Rectangle
? setSourceRect, Color setColor)
    
{
        texture 
= setTexture;
        rect 
= setRect;
        sourceRect 
= setSourceRect;
        color 
= setColor;
    }
 // SpriteToRender(setTexture, setRect, setColor)
}
 // SpriteToRender
List<SpriteToRender> sprites = new List<SpriteToRender>();

     顺便说一句:上边的这些代码包括单元测试代码都是放在PongGame类中的。通常您想重用您的代码并且在将来扩展您的游戏,那么把这些分别放在不同的类中更好。为了让事情简单些,而且以后您也不会再使用这些代码,就可以以尽可能快的方式来编写代码。很明显这并不是最规范的编码方式,不过它可以最快地、最有效率地让您的单元测试代码跑起来,然后您再重构您的代码让它更规范而且可以被重用。多亏了单元测试,您始终有一个强大的工具确保在多次变更代码之后仍能执行相同的功能。

     在上面的代码中,您也许注意到使用了“Rectangle?”来定义变量sourceRect,而没有使用“Rectangle”。“Rectangle?”的意思是您可以给这个参数传递NULL(空)值,这样可以创建不使用参数sourceRect的RenderSprite方法的重载版本来渲染整幅纹理:

public void RenderSprite(Texture2D texture, Rectangle rect,
            Rectangle
? sourceRect, Color color)
{
    sprites.Add(
new SpriteToRender(texture, rect, sourceRect, color));
}
 // RenderSprite(texture, rect, sourceRect, color)

     在Draw方法的最后调用的DrawSprites方法也不是很复杂:

public void DrawSprites()
{
    
// No need to render if we got no sprites this frame
    if (sprites.Count == 0)
        
return;
    
// Start rendering sprites
    spriteBatch.Begin(SpriteBlendMode.AlphaBlend,
              SpriteSortMode.BackToFront, SaveStateMode.None);
    
// Render all sprites
foreach (SpriteToRender sprite in sprites)
{
        spriteBatch.Draw(sprite.texture,
            
// Rescale to fit resolution
            new Rectangle(
                  sprite.rect.X 
* width / 1024,
                  sprite.rect.Y 
* height / 768,
                  sprite.rect.Width 
* width / 1024,
                  sprite.rect.Height 
* height / 768),
                  sprite.sourceRect, sprite.color);
    }

    
// We are done, draw everything on screen with help of the end method.
    spriteBatch.End();
    
// Kill list of remembered sprites
    sprites.Clear();
}
 // DrawSprites()

     请注意,这个Pong游戏以及本书中的其他游戏所使用的纹理通常都使用的是1024×768的分辨率。所以虽然对于这个游戏来说并不是非常重要,但是如果您使用Windows平台的话至少得使用1024×768的分辨率,您可以把所有Sprites从1024×768重新调整到当前的分辨率,上面的DrawSprites方法就确保了所有的Sprites可以被重新调整到当前使用的分辨率。比如,在Xbox 360上有多种分辨率应用方案,但预先您并不知道,所以Xbox 360处理分辨率应该是相对独立的,如果可能的话它还允许使用类似HDTV 1080p(1920×1080)这样的格式。

     基本上,DrawSprites方法先检查是否有Sprite要渲染,如果没有就退出,如果有,就使用Alpha混合(AlphaBlend)模式以及从后到前的排序方式渲染所有的Sprites,同时不保存渲染状态,这就是说,如果您改变了任何XNA的渲染状态,当End方法被调用的时候状态都不会被保存。通常,您总是想使用SaveStateMode.None这种方式,因为它是最快的,而且在您单元测试过程中能确保调用这个方法渲染出来的一切都能正常工作,并且在某种程度上不会被改变。

     您也许会想“这样就可以渲染出菜单了?”,如果您按下F5,您将看到图2-5中所示的屏幕。因为您已经实现了最基本的代码,所有渲染Sprite的代码,以及单元测试所需要的一切,您几乎已经完成50% 的工作了。现在您只需要添加处理游戏元素图片、操作以及球碰撞的代码,这样就完成了。
图2-5
图2-5

添加球和球拍

     要添加球、球拍和其他游戏组件您需要使用另一个叫TestGameSprites的单元测试:

public static void TestGameSprites()
{
    StartTest(
        
delegate
        
{
            
// Show lives
            testGame.ShowLives();
            
// Ball in center
            testGame.RenderBall();
            
// Render both paddles
            testGame.RenderPaddles();
        }
);
}
 // TestGameSprites()

     这个单元测试比上一个更好地体现了敏捷开发方法学的处理过程。正如您将看到的,您只是瞟一眼设计概念就实现了所有东西:在顶部是每个玩家的生命数量,中间是球和玩家的球拍,屏幕边缘没有使用任何特殊图像,还有您已经实现了背景图。

     您只要理解这些单元测试的处理方式,添加它们,然后把如下代码加入到Main方法中并注释之前的代码:

//PongGame.StartGame();
//PongGame.TestMenuSprites();
PongGame.TestGameSprites();

     此时按下F5您会得到三条错误信息,因为TestGameSprites中三个新的方法还没有实现,当您看到这三条错误之后您就确切地知道接下来要干什么。当它们都被实现了,并测试过了,那这个单元测试就完成了,您就可以接着进行游戏的下一个部分。我要提醒一点:这种方式确实使整个处理过程更直截了当,而且看起来好像您从头到尾都是事先设计好的,但正如您之前所知的您仅仅是写了一页纸的游戏构思而已,而其他东西是随着您从最顶层的设计到最底层的实现过程中逐步设计并创建出来的。看看新增的三个方法:

public void ShowLives()
{
    
// Left players lives
    RenderSprite(menuTexture, 22, GameLivesRect);
    
for (int num = 0; num < leftPlayerLives; num++)
    
{
        RenderSprite(gameTexture, 
2 + GameLivesRect.Width +
                  GameSmallBallRect.Width 
* num - 29,
                  GameSmallBallRect);
    }

    
// Right players lives
    int rightX = 1024 - GameLivesRect.Width - GameSmallBallRect.Width * 3 - 4;
    RenderSprite(menuTexture, rightX, 
2, GameLivesRect);
    
for (int num = 0; num < rightPlayerLives; num++)
    
{
        RenderSprite(gameTexture, rightX 
+ GameLivesRect.Width +
                  GameSmallBallRect.Width 
* num - 29,
                  GameSmallBallRect);
    }

}
 // ShowLives()

ShowLives方法只是为两个玩家显示“Lives:”文本内容,并添加使用小球数量作为玩家的生命数量。RenderBall方法更简单:

public void RenderBall()
{
    
this.RenderSprite(this.gameTexture,
     (
int)((0.5f + 0.9f * this.ballPosition.X) * 1024- GameBallRect.Width / 2,
     (
int)((0.02f + 0.96f * this.ballPosition.Y) * 768- GameBallRect.Height / 2,
     GameBallRect);
}
 // RenderBall()

最后使用RenderPaddles方法在当前位置显示左右两个球拍:

public void RenderPaddles()
{
    
this.RenderSprite(this.gameTexture,
        (
int)(0.05f * 1024- GameRedPaddleRect.Width / 2,
        (
int)((0.06f + 0.88f * this.leftPaddlePosition) * 768) – 
                GameRedPaddleRect.Height 
/ 2,
        GameRedPaddleRect);
    
this.RenderSprite(this.gameTexture,
        (
int)(0.95f * 1024- GameBluePaddleRect.Width / 2,
        (
int)((0.06f + 0.88f * this.rightPaddlePosition) * 768) – 
                GameBluePaddleRect.Height 
/ 2,
        GameBluePaddleRect);
}
 // RenderPaddles()

     您可能会觉得奇怪,RenderBall和RenderPaddles方法中的那些浮点数(floating-point numbers)是哪来的,实际上它们是游戏中定义的用来跟踪记录当前球和球拍位置的变量:

/// <summary>
/// Current paddle positions, 0 means top, 1 means bottom.
/// </summary>

private float leftPaddlePosition = 0.5f, rightPaddlePosition = 0.5f;
/// <summary>
/// Current ball position,again from 0 to 1, 0 is left and top, 1 is bottom and right.
/// </summary>

private Vector2 ballPosition = new Vector2(0.5f0.5f);
/// <summary>
/// Ball speed vector, randomized for every new ball.
/// Will be set to Zero if we are in menu or game is over.
/// </summary>

private Vector2 ballSpeedVector = new Vector2(00);

     现在或许您对为什么这些渲染方法中使用浮点数更加清楚了,使用这种方式您不必处理屏幕坐标、多种分辨率以及检查屏幕边界。球和球拍的位置只在0到1之间,对于x坐标来说0意味着在屏幕左边界,而1意味着在屏幕的右边界,y坐标也是相同的道理:0在屏幕的最顶部,1在屏幕的最底部。您还使用了一个速度向量(speed vector)来更新每一帧球的位置,这将稍后进行讨论。

     对于球拍,我们把左边的球拍(红色的)放在左边并向右偏移5%的距离,这样看得更清晰,并且多出来的这块距离是让球能运动到这里,从而该玩家就死了一次,同样右边的红色球拍放在屏幕右边95%(也就是0.95f)的距离。看一下最终的输出(图2-6):
图2-6
图2-6

该图看起来好像游戏已经差不多做好了,虽然单元测试能很快地给您一个非常不错的结果,但并不意味着这就已经做好了,您还得处理用户输入以及球的碰撞。
posted @ 2009-01-28 14:40  JulioZou  阅读(2002)  评论(6编辑  收藏  举报