§2.3让我们编写Pong

游戏构思都解决了,并且所有你需要着手的文件也有了。是做实际编码的时候了。首先,注视SpriteBatch类,并且看看如何不费力地管理所有的sprite精灵。SpriteBatch类不仅能按照图像保存时的格式来渲染sprites,还能把它们放大或缩小,给它们重新着色,甚至能旋转它们。

把菜单一起放进来之后,您将会添加球拍,并且在你第一章就看到过的输入功能的帮助下,来移动它们。球自身的移动主要靠一些简单的变量,而且每次你撞击到球拍之一都会把球反弹回来,并且播放声音文件PongBallHit.wav的声音。如果球出了左边或右边的屏幕边界,将播放文件PongBallLost.wav的声音,同时玩家失去一条生命。

一些单元测试被用来确保菜单和基本的游戏工作。然后再添加另外的单元测试来处理复杂的部分,像球拍的侧边撞击球以及微调游戏的玩法。为了支持多玩家,您还将使用一个单元测试来考验操作控制,然后把它们添加到主菜单选项来支持多人模式。在本章的下一部分,您将在Xbox 360上测试整个游戏,并思考如何对游戏做更多的改进。

Sprites精灵

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

要加载所有纹理,使用下列代码行。首当你必须定义这里要使用的纹理:

Texture2D backgroundTexture, menuTexture, gameTexture;

然后在Initialize方法加载一切:

//Load all our content background
Texture=Content.Load<Texture2D>("PongBackground");
menuTexture=Content.Load<Texture2D>("PongMenu");
gameTexture=Content.Load<Texture2D>("PongGame");   

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

// Draw background texture in a separate pass, else it gets messed up
// with our other sprites, the ordering does not really work great.
spriteBatch.Begin();
spriteBatch.Draw(backgroundTexture, new Rectangle(0, 0, width, height), Color.LightGray);
spriteBatch.End();

使用LightGray这个颜色,意味着你让背景稍微变暗了些,以至于和前景项(菜单文本内容和游戏元素)形成较鲜明的对比。如您所见,渲染一个sprite到屏幕上并非只使用一行代码,并且只渲染sprite纹理的一些部分还要更复杂得多。看一下您在游戏中使用的矩形(rectangle):

static readonly Rectangle
XnaPongLogoRect = new Rectangle(0, 0, 512, 110),
MenuSingleplayerRect = new Rectangle(0, 110, 512, 38),
MenuMultiplayerRect = new Rectangle(0, 148, 512, 38),
MenuExitRect = new Rectangle(0, 185, 512, 38),
 GameLifesRect = new Rectangle(0, 222, 100, 34),
GameRedWonRect = new Rectangle(151, 222, 155, 34),
GameBlueWonRect = new Rectangle(338, 222, 165, 34),
 GameRedPaddleRect = new Rectangle(23, 0, 22, 92),
GameBluePaddleRect = new Rectangle(0, 0, 22, 92),
GameBallRect = new Rectangle(1, 94, 33, 33),
GameSmallBallRect = new Rectangle(37, 108, 19, 19);

真是相当多的矩形对象,不过比起诸如导入一些XML数据这样的操作,仅仅使用这些常量值还是简单的了。这里使用静态只读(static readonly)变量取代常量是因为常量不能声明为结构类型的,而且static read-only变量和常量的使用方式一样。您或许会问怎样得到这些值,以及如何确保这些值正确呢。

游戏中的单元测试

从这里我们开始引入单元测试。对于游戏编程来说,单元测试主要是把您的问题分解成易于管理的小问题。即使为这样一个非常简单的游戏编写单元测试也是一种好想法。单元测试可以很好地在屏幕上排列您的纹理,测试音效,以及添加碰撞检测。起初,在我计划写这一章以及Pong游戏的时候没有使用单元测试,但当我一开始编写游戏我就不能自已,等我意识到,我已经写了六个单元测试。比如,彻底检查菜单图像矩形,可以使用下面的单元测试:

public static void TestMenuSprites() {
 StartTest(
    delegate{
        testGame.RenderSprite(testGame.menuTexture,512-XnaPongLogoRect.Width/2,150,XnaPongLogoRect);
        testGame.RenderSprite(testGame.menuTexture,512-MenuSingleplayerRect.Width/2,300,MenuSingleplayerRect);
        testGame.RenderSprite(testGame.menuTexture,512-MenuMultiplayerRect.Width/2,350,MenuMultiplayerRect, Color.Orange);
        testGame.RenderSprite(testGame.menuTexture,512-MenuExitRect.Width/2, 400,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列表,列出在每一帧您想渲染的sprite,每次调用RenderSprite的时候就添加一个新登录。然后在每一帧的最后绘制所有的sprite精灵:

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?”被用来取代“Rectangle”,来定义sourceRect变量。“Rectangle?”的意思是参数类型允许NULL(空)值,并且您可以给这个参数只传递NULL,这样可以创建RenderSprite方法的不使用参数sourceRect的重载版本,来渲染整幅纹理:

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()

虽然对于这个游戏来说并不是非常重要,总之在Windows平台至少得使用1024×768的默认分辨率,您可以把所有Sprites从1024×768重新调整到当前的分辨率。请注意,本游戏的所有纹理以及本书中即将到来的游戏通常都使用1024×768的分辨率。上面的DrawSprites的代码就确保了所有的Sprites可以被正确调整到当前使用的分辨率。比如,在Xbox 360上有多种可能的分辨率,并且强制游戏运行在这些预先您并不知道的分辨率上。因为这个缘故,Xbox 360应该是独立处理分辨率的,如果可能的话它还允许类似HDTV 1080p(1920×1080)这样的格式。

基本上,DrawSprites方法先检查是否有任何Sprite要渲染,如果没有就退出函数。如果有,就使用默认的Alpha混合(AlphaBlend)模式以及从后到前的排序方式,渲染所有的Sprites,同时不保存渲染状态。这就是说,如果您改变了任何XNA的渲染状态,当End方法被调用的时候状态都不会被保存。通常,您总是想使用SaveStateMode.None这种方式,因为它是最快的,而且通过单元测试你能确保调用这个方法渲染出来的一切都能正常工作,并且在某种程度上不会被改变。您也许会思考“所有这些是为了渲染主菜单图像”?如果您按下F5,您将看到如图2-5中所示的屏幕。

图2-5

因为您已经实现了游戏的基本代码,所有Sprite渲染的代码,以及单元测试所需要的一切,您几乎已经完成50% 的工作了。现在您只需要添加游戏图片、操作控制以及简单球体碰撞的代码,这样就完成了。

 添加球和挡板

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

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

这个单元测试比上一个更好地体现了敏捷开发方法学的处理过程。正如您将看到的,您只是看一眼设计概念就高水准的实现了一切。在顶部你看到每个玩家的生命数量,中间是球和每个玩家的球拍,屏幕边缘没有使用任何特殊图像,还有您已经实现了背景。你只要确定理解这些单元测试的处理方式并为。添加它们,按F5之后把如下代码加入到Main方法中,并注释旧的单元测试的代码:   

//PongGame.StartGame();

//PongGame.TestMenuSprites();

PongGame.TestGameSprites();

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

public void ShowLives() {
    // Left players lives
    RenderSprite(menuTexture, 2, 2, GameLivesRect);
    for (int num = 0; num < leftPlayerLives; num++)
        RenderSprite(gameTexture, 2+GameLivesRect.Width+ GameSmallBallRect.Width*num-2, 9, 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-2, 9,GameSmallBallRect);
} // ShowLives()

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

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

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

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

之前,您可能想知道RenderBall和RenderPaddles方法中的那些浮点数(floating-point numbers)是什么,其实这些游戏中需要的新变量是用来跟踪记录当前球和球拍的位置:

///<summary>
///Current paddle positions, 0 means top, 1 means bottom.
 ///</summary>
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>
Vector2 ballPosition = new Vector2(0.5f, 0.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>
Vector2 ballSpeedVector = new Vector2(0, 0);

现在或许您对为什么在这些渲染方法中使用浮点数更加清楚了。使用这种方式您不必处理屏幕坐标、多种分辨率以及检查屏幕边界。球和球拍的位置只在0到1之间,对于x坐标,0意味着在屏幕左边界,而1意味着在屏幕的右边界。y坐标和球拍也是同理:0在屏幕的最顶部,1在屏幕的最底部。您还使用了一个速度向量(speed vector)来更新每一帧球的位置,这将稍后讨论。球拍渲染在屏幕上。把左球拍(红色的)放在左边并向右偏移5%的距离,这样看得更清晰,并且多出来的这一点儿区域是让球能运动到这里,从而让该玩家就死了一次。同样发生在右球拍(蓝色的)放在屏幕右边95%宽度(也就是0.95f)的位置。按F5之后,看一下输出效果(见图2-6):

图2-6

看起来好像游戏几乎立刻做好了。虽然单元测试很不错,并且能很快地给您一个美好的结果,但并不意味着这就已经做好了。还必须处理用户输入以及碰撞检测。

处理用户输入

正如您在第一章所看到的,在XNA中捕捉键盘和Gamepad游戏手柄的输入是相当简单的。单单为它编写一个额外的单元测试有些过份。您已经知道它是如何工作,而且这里也只是想测试一下球拍的控制。所以还不需要写一个新的单元测试。你完全可以使用TestGameSprites测试,或许可以把它重命名为TestSingleplayerGame。单元测试的内容还是一样的,您只需在PongGame的Update方法中修改对输入的处理,并更新球拍的位置:

// Get current gamepad and keyboard states
gamePad = GamePad.GetState(PlayerIndex.One);
gamePad2 = GamePad.GetState(PlayerIndex.Two);
keyboard = Keyboard.GetState();
gamePadUp = gamePad.DPad.Up == ButtonState.Pressed ||gamePad.ThumbSticks.Left.Y > 0.5f;
gamePadDown = gamePad.DPad.Down == ButtonState.Pressed ||gamePad.ThumbSticks.Left.Y < -0.5f;
gamePad2Up = gamePad2.DPad.Up == ButtonState.Pressed ||gamePad2.ThumbSticks.Left.Y > 0.5f;
gamePad2Down = gamePad2.DPad.Down == ButtonState.Pressed ||gamePad2.ThumbSticks.Left.Y < -0.5f;
// Move half way across the screen each second float
moveFactorPerSecond = 0.5f * (float)gameTime.ElapsedRealTime.TotalMilliseconds / 1000.0f;
// Move up and down if we press the cursor or gamepad keys.
if (gamePadUp || keyboard.IsKeyDown(Keys.Up))
    rightPaddlePosition -= moveFactorPerSecond;
 if (gamePadDown ||keyboard.IsKeyDown(Keys.Down))
    rightPaddlePosition += moveFactorPerSecond;
// Second player is either controlled by player 2 or by the computer
if (multiplayer) {
    // Move up and down if we press the cursor or gamepad keys.
    if (gamePad2Up || keyboard.IsKeyDown(Keys.W))
        leftPaddlePosition -= moveFactorPerSecond;
    if (gamePad2Down || keyboard.IsKeyDown(Keys.S))
        leftPaddlePosition += moveFactorPerSecond; } // if
    else {
        // Just let the computer follow the ball position
        float computerChange = ComputerPaddleSpeed * moveFactorPerSecond;
        if (leftPaddlePosition > ballPosition.Y + computerChange)
            leftPaddlePosition -= computerChange;
        else if (leftPaddlePosition < ballPosition.Y - computerChange)
            leftPaddlePosition += computerChange;
} // else
 // Make sure paddles stay between 0 and 1
if (leftPaddlePosition < 0)
    leftPaddlePosition = 0;
if (leftPaddlePosition > 1)
    leftPaddlePosition = 1;
if (rightPaddlePosition < 0)
    rightPaddlePosition = 0;
if (rightPaddlePosition > 1)
    rightPaddlePosition = 1;

您可能注意到这里又有些新变量(multiplayer、gamePad、 gamePad2、keyboard和 ComputerPaddleSpeed),但目前要关心的是改变球拍位置的代码。变量moveFactorPerSecond用来确保单人模式中,球和球拍总是以相同的速度运动,而无论帧的渲染速度。如果是1fps(frame per second),那么moveFactorPerSecond就等于1,如果是10fps,它就等于0.1,如果是100fps,它就是0.01,以此类推。接下来,当“Up”、“Down”键或者游标按键被按下的时候,您将改变右球拍的位置。而左球拍由另一个玩家player 2控制,无论是使用第二个可获得的Gampad还是“W”、“S”键。如果不是多人而是单人游戏模式,那么左球拍就由计算机控制,并以取值0.5f的ComputerPaddleSpeed变量限速,跟着球移动。起初,球的移动比较慢,但每次碰撞都会提升一点速度,并且您也可以使用球拍边缘撞击球来加速。这样计算机就接不到球,您就赢了。为了让新的Update方法工作,需要添加新的变量和常量:

/// <Summary>
/// Ball speed multiplicator, this is how much screen space the ball
/// will travel each second.
/// </summary>
const float BallSpeedMultiplicator = 0.5f;
///<summary>
/// Computer paddle speed. If the ball moves faster up or down than /// this, the computer paddle can't keep up and finally we will win.
 ///</summary>
const float ComputerPaddleSpeed = 0.5f;
///<summary>
///Game modes
///</summary>
enum GameMode { Menu, Game, GameOver, } // enum GameMode
GamePadState gamePad, gamePad2;
KeyboardState keyboard;
bool gamePadUp = false, gamePadDown = false, gamePad2Up = false, gamePad2Down = false;
/// <summary>
/// Are we playing a multiplayer game? If this is false, the computer
/// controls the left paddle.
/// </summary>
bool multiplayer = false;
/// <summary>
/// Game mode we are curently in. Very simple game flow.
///</summary>
GameMode gameMode = GameMode.Menu;
///<summary>
/// Currently selected menu item.
///</summary>
int currentMenuItem = 0;

对于当前的测试,您只需要使用我前面提及的几个变量。但还是要看一下游戏需要的余下变量。变量BallSpeedMultiplicator决定了球有多快,因此也决定了游戏的整体有多快。GameMode用来处理当前您可能在的所有三种游戏模式。您可能是刚开始游戏,正处于菜单界面,或者正在游戏中。当您处在游戏中时,如果一个玩家输了,那么模式将被修改成游戏结束(GameOver)状态,并显示获胜者。虽然您现在还不需要,但菜单是您必须处理的最后一部分输入操作,所以看一看吧:

// Show screen depending on our current screen mode
if (gameMode == GameMode.Menu) {
    // Show menu
    RenderSprite(menuTexture,512-XnaPongLogoRect.Width/2, 150, XnaPongLogoRect);
    RenderSprite(menuTexture,512-MenuSingleplayerRect.Width/2, 300, MenuSingleplayerRect,currentMenuItem == 0 ? Color.Orange : Color.White);
    RenderSprite(menuTexture,512-MenuMultiplayerRect.Width/2, 350, MenuMultiplayerRect,currentMenuItem == 1 ? Color.Orange : Color.White);
    RenderSprite(menuTexture,512-MenuExitRect.Width/2, 400, MenuExitRect,currentMenuItem == 2 ? Color.Orange : Color.White);
    if ((keyboard.IsKeyDown(Keys.Down) ||gamePadDown) && remDownPressed == false)
    {
        currentMenuItem = (currentMenuItem + 1)%3;
    }
    // else if
    else if ((keyboard.IsKeyDown(Keys.Up) ||gamePadUp) &&remUpPressed == false)
    {
        currentMenuItem = (currentMenuItem + 2)%3; }
    //else if
    else if ((keyboard.IsKeyDown(Keys.Space) ||keyboard.IsKeyDown(Keys.LeftControl) ||keyboard.IsKeyDown(Keys.RightControl) ||keyboard.IsKeyDown(Keys.Enter) ||gamePad.Buttons.A == ButtonState.Pressed ||gamePad.Buttons.Start == ButtonState.Pressed || // Back or Escape exits our game keyboard.IsKeyDown(Keys.Escape) || gamePad.Buttons.Back == ButtonState.Pressed) && remSpaceOrStartPressed == false && remEscOrBackPressed == false)
    {
         // Quit app.
        if (currentMenuItem == 2 || keyboard.IsKeyDown(Keys.Escape) || gamePad.Buttons.Back == ButtonState.Pressed)
        {
             this.Exit();
        } // if
        else
        { //
            Start game . handle game, etc. ..
        }

这里有一些新的变量,像remDownPressed或者gamePadUp,它们用来让输入处理更容易些。额外的细节可以查看本章的源代码。下一章将详细讨论输入辅助类(Input helper class),它将进一步简化该过程。这些就是所有您在Pong游戏中需要知道的关于输入处理的内容。如果您再次执行单元测试,您能看到和前一个测试相同的输出屏幕,不过现在您可以控制球拍了。 Collision Testing碰撞检测为了从中间移动球,单元测试TestSingleplayerGame要调用下面的方法。球的移动到随机位置(这里你定义了至少四种随机方向):

///<summary>
/// Start new ball at the beginning of each game and when a ball is lost.
/// </summary>
public void StartNewBall() {
    ballPosition = new Vector2(0.5f, 0.5f);
    Random rnd = new Random((int)DateTime.Now.Ticks);
    int direction = rnd.Next(4);
    ballSpeedVector = direction == 0 ? new Vector2(1, 0.8f) :direction == 1 ? new Vector2(1, -0.8f) :direction == 2 ? new Vector2(-1, 0.8f) :new Vector2(-1, -0.8f);
} // StartNewBall()

在Update方法中,球位置的更新基于变量ballSpeedVector:

// Update ball position ballPosition += ballSpeedVector * moveFactorPerSecond * BallSpeedMultiplicator;

如果现在使用这个单元测试开始游戏,球将从中心移动出屏幕,这可不够酷。这就是为什么您需要碰撞检测。看一下图2-7所示的构思,并且为碰撞代码添加一些改进。回过头去看之前设计的构思图,并且基于新想法和知识进行改进,有时是很有意义的,这次就是其中之一。这里有三种碰撞可能发生:

图2-7

碰撞屏幕边界——在屏幕顶边和底边

碰撞球拍——把球弹回给对手

碰撞球拍后面的屏幕边界,失去一条命。该状况发生,玩家丢掉一条命,并用StartNewBall方法重新设置球

 您可以继续使用TestSingleplayerGame来检测碰撞,不过创建一些新的测试很容易,并且可以对每一个问题来测试。面对这种问题,单元测试再一次显示出它的长处。现在您已经有了一个清晰的概念要做什么,只是还不知道要怎么去做。那就写一个单元测试来实现它吧:

public static void TestBallCollisions()
{
    StartTest(
        delegate{
            // Make sure we are in the game and in singleplayer mode
            testGame.gameMode = GameMode.Game;
            testGame.multiplayer = false;
            testGame.Window.Title ="Xna Pong - Press 1-5 to start collision tests";
            // Start specific collision scene based on the user input.                 // First test, just collide with screen border
                testGame.ballPosition = new Vector2(0.6f, 0.9f);
                testGame.ballSpeedVector = new Vector2(1, 1);
            } // if
            else if (testGame.keyboard.IsKeyDown(Keys.D2)) {
                // Second test, straight on collision with right paddle
                testGame.ballPosition = new Vector2(0.9f, 0.6f);
                testGame.ballSpeedVector = new Vector2(1, 1);
                testGame.rightPaddlePosition = 0.7f; } // if
            else if (testGame.keyboard.IsKeyDown(Keys.D3)) {
                // Thrid test, straight on collision with left paddle
                testGame.ballPosition = new Vector2(0.1f, 0.4f);
                testGame.ballSpeedVector = new Vector2(-1, -0.5f);
                testGame.leftPaddlePosition = 0.35f;
            } // if
            else if (testGame.keyboard.IsKeyDown(Keys.D4)) {
                // Advanced test to check if we hit the edge of the right paddle
                testGame.ballPosition = new Vector2(0.9f, 0.4f);
                testGame.ballSpeedVector = new Vector2(1, -0.5f);
                testGame.rightPaddlePosition = 0.29f; } // if
            else if (testGame.keyboard.IsKeyDown(Keys.D5)) {
                // Advanced test to check if we hit the edge of the right paddle
                testGame.ballPosition = new Vector2(0.9f, 0.4f);
                testGame.ballSpeedVector = new Vector2(1, -0.5f);
                testGame.rightPaddlePosition = 0.42f;
            } // if
            // Show lifes
            testGame.ShowLives();
            // Ball in center   
            testGame.RenderBall();
            // Render both paddles
            testGame.RenderPaddles();
        }
    );
} // TestBallCollisions ()

此处的想法是通过按下1-5数字键来建立自定义碰撞检测场景。比如,如果1被按下,球将移动到(0.6, 0.9)位置,它靠近屏幕的底部中心。球的速度向量被设置成(1, 1)以确保它向屏幕边界移动,在这个地方球应该像构思中描述的那样反弹。如果按下4或5,高级球拍测试被启动,以检测是否撞到了右球拍的边缘,此处比起其他简单的碰撞检测需要更多的微调。碰撞检测在PongGame的Update方法中进行。现在您可以开始测试。显然,如果现在开始的话,它还不能正常工作,因为您还没有实现任何的碰撞检测。球是否碰撞了屏幕的顶边和底边是最简单的测试。所有下列代码添加到Update方法中,位置在您为下一帧更新球的之前:新球的之前:

check top and bottom screen border
if (ballPosition.Y < 0 ||ballPosition.Y > 1)
{
    ballSpeedVector.Y = -ballSpeedVector.Y;
    // Move ball back into screen space
    if (ballPosition.Y < 0) ballPosition.Y = 0;
    if (ballPosition.Y > 1) ballPosition.Y = 1;
} // if

这里重点部分就是颠倒球速度向量的y分量。有时候变量moveFactorPerSecond在下一帧中的值比在当前帧中的值要小一些。这样球就会跑出屏幕边界,您就得在每一帧中颠倒速度向量的y分量。要修正这个问题,您就得确保球的位置始终处于屏幕范围内而不跑出去。对于球拍也要做同样的调整。球拍的碰撞检测稍微更复杂一点。如果您只想检测屏幕顶边和底边的碰撞,现在只要按下F5进行测试。为了测试球拍碰撞,就得构建边界盒(bounding box)以执行相交检测(intersection test),它可以用到XNA中的BoundingBox类。BoundingBox使用Vector3结构体,并且工作于3D空间,不过在Pong游戏的2D空间中,您恰好可以忽略z坐标值而始终把它设置为0:

// Check for collisions with the paddles.
 // Construct bounding boxes to use the intersection helper method.
Vector2 ballSize = new Vector2(GameBallRect.Width / 1024.0f, GameBallRect.Height / 768.0f);
BoundingBox ballBox = new BoundingBox(new Vector3(ballPosition.X - ballSize.X / 2,ballPosition.Y - ballSize.Y / 2, 0),new Vector3(ballPosition.X + ballSize.X / 2,ballPosition.Y + ballSize.Y / 2, 0));
Vector2 paddleSize = new Vector2(GameRedPaddleRect.Width / 1024.0f,GameRedPaddleRect.Height / 768.0f);
BoundingBox leftPaddleBox = new BoundingBox(new Vector3(-paddleSize.X/2,leftPaddlePosition-paddleSize.Y/2, 0),new Vector3(+paddleSize.X/2,leftPaddlePosition+paddleSize.Y/2, 0));
BoundingBox rightPaddleBox = new BoundingBox(new Vector3(1-paddleSize.X/2,rightPaddlePosition-paddleSize.Y/2, 0),new Vector3(1+paddleSize.X/2,rightPaddlePosition+paddleSize.Y/2, 0));
// Ball hit left paddle?
if (ballBox.Intersects(leftPaddleBox)) {
 &n        ballSpeedVector.X = -ballSpeedVector.X; // Increase speed a little ballSpeedVector *= 1.05f;
    // Did we hit the edges of the paddle?
    if (ballBox.Intersects(new BoundingBox(new Vector3(leftPaddleBox.Min.X- 0.01f,leftPaddleBox.Min.Y - 0.01f, 0),new Vector3(leftPaddleBox.Min.X + 0.01f,leftPaddleBox.Min.Y + 0.01f, 0))))
    // Bounce of at a more difficult angle for the other player
    ballSpeedVector.Y = -2;
    else if (ballBox.Intersects(new BoundingBox(new Vector3(leftPaddleBox.Min.X - 0.01f,leftPaddleBox.Max.Y - 0.01f, 0),new Vector3(leftPaddleBox.Min.X + 0.01f, leftPaddleBox.Max.Y + 0.01f, 0))))
    // Bounce of at a more difficult angle for the other player
    ballSpeedVector.Y = +2;
    // Move away from the paddle
    ballPosition.X += moveFactorPerSecond * BallSpeedMultiplicator;
} // if
} // if

边界盒的构建方式相类于在RenderBall和RenderPaddles方法中被处理的渲染代码。不过这里的边界检测代码有点更复杂,并且当球撞倒球拍边缘时正好以一种快速的卑劣的方式来提高球速。不过这些让游戏更加有趣了。用于左球拍的的代码和右球拍的完全一样;您只需用右球拍的变量替换掉所有左球拍的变量,并把移动方向代码取反。您必须为游戏做的最后一件事就是处理所有的游戏进行,以及最终的碰撞测试是为了检测当球运动到玩家球拍后面的时候,玩家会失去一条命。这个代码非常简单,而且您还可以直接处理某个玩家是否已经失去了所有生命,这个时候游戏就结束了。显示“红方胜”或“蓝方胜”的消息在Draw方法处理的。如果用户按下Space或Escape键,他将返回主菜单,并且游戏重新开始:

// Ball lost?
if (ballPosition.X < -0.065f) {
    // Play sound
    soundBank.PlayCue("PongBallLost");
    // Reduce number of lives
    leftPlayerLives -- ;
    // Start new ball
    StartNewBall(); } // if
else if (ballPosition.X > 1.065f) {
    // Play sound
    soundBank.PlayCue("PongBallLost");
    // Reduce number of lives
    rightPlayerLives -- ;
    // Start new ball StartNewBall();
   } // if
// If either player has no more lives, the other player has won!
if (gameMode == GameMode.Game && (leftPlayerLives == 0 || rightPlayerLives == 0)) {
    gameMode = GameMode.GameOver;
    StopBall();
} // if

好了,这就是游戏最难的部分;除了边界盒的碰撞检测稍微有点复杂,游戏余下的部分都很简单,可以很容易就实现。您还学习了一些单元测试,以及如何以更有效率的方式处理Sprite。现在您可以按下F5来测试游戏,并且对碰撞做些微调(如图2-8所示)。以按下F5来测试游戏,并且对碰撞做些微调(如图2-8所示)。

 图2-8

添加声音特效

要给您的游戏添加声音,通常只要放下几个.wav文件到项目中,然后回放它们。在XNA中,不支持加载.wav文件,原因是Xbox 360和Windows平台使用不同的声音和音乐格式。为了解决这个问题,微软发明了一个叫做XACT的工具,它在DirectX SDK和Xbox 360SDK中早就可以得到了。

XACT是Microsoft Cross-Platform Audio Creation Tool的缩写。XNA也使用这个工具,并且微软决定让它成为播放声音文件的唯一方式。虽然对于添加特效、做大量调整以及在一个地方管理所有声音文件,XACT是一个非常好的工具,但对于这样一个游戏项目来说,它就有点把事情过于复杂化了。本书有完整一章内容关于XACT;可以参考第9章。对于Pong游戏,您只需要两个简单的声音文件:

PongBallHit.wav 每次碰撞边界或球拍的时候都会播放这个文件,也用作更换菜单选项的音效

PongBallLost.wav 在玩家本能及时接球,而失去一条命时播放

要给您的游戏添加这些文件,您必须创建一个新的XACT项目。可以在“开始→所有程序→Microsoft XNA Game StudioExpress→Tools”找到XACT。在新的XACT项目中,点击菜单“Wave Banks→New Wave Bank”添加一个Wave Bank,再点击菜单“Sound Banks→New Sound Bank”添加一个Sound Bank。然后把这两个.wav文件拖放到新的XACT Wave Bank窗口中,接下来把两个新的Wave Bank项拖放到Sound Bank窗口,然后再拖放到Cues窗口。如果对你有困惑或者出现问题了,可以参考第9章的更多细节。下面这些是XACT用来处理声音的主要组件:

Wave Bank 存储所有的.wav文件,这里除了.wav文件以外,不能添加其他任何文件。不支持.mp3、.wma或者其他.wav以外的任何格式。导入压缩文件也不是可选项。Windows的ACPCM和Xbox 360的XMA压缩是可以的,但必须遵循一些规则,这些规则在第9章被描述。

Sound Bank 主要用来在cue的帮助下在游戏中回放声音。这里您可以通过改变音量和音高(pitch),添加分类,附加音效(RPC)来修改声音设置。您还可以在这里定义多声道。不过通常只是在这里设置音量;

Sound Cues 用来播放声音。一个Sound Cue至少要有一个Sound Bank和它对应,但可以把多个声音文件分配给一个cue里,并设置一些规则——是否要随机播放某个声音,是否一次只能播放一个此类的声音,以及声音替换的规则。这里的cue名称非常重要,它被用来在游戏中访问cue,并最终播放声音。

你要把PongBallHit的音量设置成-8,PongBallLost设置成-4,默认值-12太小了,而撞击声响了点儿,这样在游戏中降低音量听起来就更好一些。余下部分可以使用默认值,并且把这个项目保存成PongSound.xap文件。再把该文件添加到 XNA Studio 项目中,它就可以利用XNA内容管道自动地编译和构建所有文件,而且可同时应用于Windows平台和Xbox 360平台。

另外,您还要确保这两个wav文件要和PongSound.xap文件放在相同的文件夹内,否则内容管道可能找不到这些文件,也就无法构建您的XACT项目。回放声音的代码相当简单,只要确保声音能正常播放,音量也合适即可。这里是用于声音的简单单元测试:

public static void TestSounds()
{
    StartTest(delegate
        {
            if (testGame.keyboard.IsKeyDown(Keys.Space))
                testGame.soundBank.PlayCue("PongBallHit");
            if (testGame.keyboard.IsKeyDown(Keys.LeftControl))
                testGame.soundBank.PlayCue("PongBallLost");
        }
        );
} // TestSounds()

现在,你所有必须要做的是给游戏实现声音效果,只要在您想播放声音的地方添加几行代码即可。首先,给菜单和每一次球和屏幕边界或球拍碰撞的时候添加音效(查看前面的碰撞检测)。然后,在玩家失去一条命的时候像这样添加PongBallLost音效:

// Ball lost?
if (ballPosition.X < -0.065f)
{
    // Play sound sound
    Bank.PlayCue("PongBallLost");
    // Reduce life
    leftPlayerLives -- ;
    // Start new ball
    StartNewBall();
} // if
else if (ballPosition.X > 1.065f)
{
    // Play sound
    soundBank.PlayCue("PongBallLost");
    // Reduce life
    rightPlayerLives -- ;
    // Start new ball
    StartNewBall();
} // if

添加这些代码行之后,您可以重新启用单元测试TestSingleplayerGame来检查一下声音能否被正确地播放。对于更复杂的游戏需要一个更好的系统在播放声音的时候进行检测,而对于大多数的简单游戏,直接使用PlayCue方法就可以工作很好了,这个方法只是在需要的时候播放声音并且保存Cue。您也可以自己创建并管理Sound Cue;这样的好处是您可以停止播放或者继续开始等等。


发布时间:2008/6/19 下午3:13:36  阅读次数:11245

2006 - 2024,推荐分辨率 1024*768 以上,推荐浏览器 Chrome、Edge 等现代浏览器,截止 2021 年 12 月 5 日的访问次数:1872 万 9823 站长邮箱

沪 ICP 备 18037240 号-1

沪公网安备 31011002002865 号