65.9K
CodeProject 正在变化。 阅读更多。
Home

XNA 台球俱乐部

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (128投票s)

2010年2月5日

CPOL

17分钟阅读

viewsIcon

339973

downloadIcon

8492

支持WCF的多人XNA游戏,适用于Windows平台。

XNASnookerClub_SRC

目录

1. 引言

这是我第一个XNA应用程序,尽管它最初是移植自我之前的Windows Forms游戏

我花了大约2个月的时间来创建这个XNA游戏,其中包含许多有趣的功能,我认为可以写两篇文章。但相反,我决定尽量保持文章简短。此外,许多游戏概念已经在另一篇文章中涵盖,因此如果您觉得内容有缺失,请参考C# 台球文章。

如果您是开发者,我希望这里解释的概念有一天能对您有所帮助。如果您是沉迷的游戏玩家,我的目标是让您花费大量时间在此游戏中获得乐趣。

为了更好地解释,我上传了一段视频到YouTube(我和电脑对战),希望它能为您节省理解这里所 presenting概念的时间。

2. 致谢

有些人让我的生活变得轻松了许多。特别感谢 **Matthew McDole** 的碰撞检测和处理文章。此外,在理解了 **Sacha Barber** 的WCF / WPF 聊天应用程序之后,WCF部分也变得容易了许多,即使过了几年,它看起来仍然很酷、很棒。

非常感谢我可爱的10岁侄女 **Ana Beatriz**,她是游戏的忠实粉丝,并帮助我测试了为期两周的多人模式。

特别感谢我的朋友 **Rohit Dixit**,他目前正在开发Gaming 123网站,用于托管许多免费游戏,包括 **XNA 台球**。Rohit正在开发房间功能,以便登录的玩家可以互相连接并玩游戏。

3. 系统要求

系统要求取决于您想做什么。

XNA客户端

XNA简介

如果您是 .NET 开发者,XNA 应该是编写游戏的第一个选择,即使您没有使用该工具的经验。作为一个新手,我曾对它抱有偏见。但转向 XNA 比我想象的要容易得多。当一项技术无法跟上您的创意时,就会令人沮丧,但 XNA 不同。在开发游戏时,您不会问“能否用 XNA 实现?”,而是问“我需要学习哪种技术才能用 XNA 实现?”。毫不夸张地说。我认为任何顶级商业游戏都可以用 XNA 制作,您只需要知道如何使用正确的技术,并且,如果您是为 PC 开发,您可能需要一块好的显卡。

XNA控件

与 Windows Forms 和 WPF 不同,XNA 框架缺乏“UI控件”的概念。因此,您需要自己实现 UI 控件。好消息是,我只需要两种控件:单选按钮和命令按钮。因为我想保持非常简单,所以这两种控件都只是在屏幕上渲染一个字符串。

图 1. XNA 控件(XNARadioButton 和 XNAButton)在主菜单中

请注意,XNARadioButton 以“[ ]”字符串开头。这是因为用户可以将其选中。一旦被选中,“[ ]”会变成“[x]”。

同样,命令按钮(XNAButton)也只是一个字符串。当用户将鼠标移到它上面时,它会被高亮显示。

简单高效,但也许我以后会用更吸引人的控件替换它们。

图 2. XNA 控件图

音效

为了在游戏中 Sound,您必须使用 Microsoft Cross-Platform Audio Creation Tool (XACT) 插入原始的 .wav 文件。

图 3. Microsoft Cross-Platform Audio Creation Tool (XACT)

一旦游戏构建完成,.wav 文件会自动转换为 .xnb 文件,然后 XNA 就可以播放它们了。

根据游戏中发生的事件,可以播放不同的 Sound。

图 4. XNA 台球俱乐部播放的 Sound

虚拟键盘

当用户进入游戏时,会要求他们输入玩家姓名,然后会出现虚拟键盘。这可以通过标准的键盘按键或键按下事件来完成,但我选择创建了这个虚拟键盘,它看起来效果很好。

图 5. 虚拟键盘

一件很重要的事情是,键盘由单个精灵组成。当鼠标移到某个键上时,VisualKeyboard 类会识别出该键,然后主游戏类会在键盘精灵下方高亮显示一个黄色的精灵。此外,一个柔和的“点击” Sound 表明按键已被选中。

char? GetChar(Vector2 position)
{
    char? ret = null;
    int i = 0;
    foreach (char c in KEYS1)
    {
        if (position.X >= keys1StartPosition.X && 
            position.X <= keys1StartPosition.X + 
              i * keysHSpace + keySize.X &&
            position.Y >= keys1StartPosition.Y && 
            position.Y <= keys1StartPosition.Y + keySize.Y)
        {
            ret = c;
            observer.HighLightKey(new Vector2(keys1StartPosition.X + 
                                  i * keysHSpace, 
                                  keys1StartPosition.Y));
            break;
        }
        i++;
    }
    if (ret == null)
    {
        i = 0;
        foreach (char c in KEYS2)
        {
            //more code goes here...
        }

玩家照片

游戏允许您替换默认的“无可用照片”图像。只需将某个图像复制到剪贴板(在 Windows 资源管理器中,选择文件,然后按 Ctrl+C,或者如果您愿意,可以通过某个图形应用程序(如 Paint.Net)选择图像区域),然后在登录屏幕中单击玩家图片框。

图 6. 玩家照片,从剪贴板复制

请注意,原始图像的左右两侧插入了垂直的黑色条。这是因为长宽比(宽度/高度)与目标图片的长宽比不同。否则,顶部和底部将插入水平黑色条。这样就保留了原始的宽高比。

这里的代码涵盖了图像如何从剪贴板复制并传输到游戏中的纹理。

System.Windows.Forms.IDataObject iDataObject = 
               System.Windows.Forms.Clipboard.GetDataObject();
Drawing.Bitmap sourceBitmap = null;
if (iDataObject.GetDataPresent(System.Windows.Forms.DataFormats.FileDrop))
{
    string[] fileNames = iDataObject.GetData(
      System.Windows.Forms.DataFormats.FileDrop, true) as string[];
    if (fileNames.Length > 0)
    {
        string photoPath = fileNames[0];
        try
        {
            sourceBitmap = new Drawing.Bitmap(photoPath);
        }
        catch { }
    }
}
else if (iDataObject.GetDataPresent(System.Windows.Forms.DataFormats.Bitmap))
{
    sourceBitmap = (Drawing.Bitmap)iDataObject.GetData(
                       System.Windows.Forms.DataFormats.Bitmap);
}
 
//if the bitmap doesn't get loaded,
//then or the file is corrupted or is of a wrong type,
//so we'll just ignore that image.
if (sourceBitmap != null)
{
    float sourcePictureRatio = 
         (float)sourceBitmap.Width / (float)sourceBitmap.Height;
    float targetPictureRatio = (float)team1Player1PictureRectangle.Width / 
         (float)team1Player1PictureRectangle.Height;
 
    Drawing.Image targetImage = new Drawing.Bitmap(
      team1Player1PictureRectangle.Width, 
      team1Player1PictureRectangle.Height);
    Drawing.Image resizedImage = null;
 
    //resizing to fit into the target rectangle
    using (Drawing.Graphics g = Drawing.Graphics.FromImage(targetImage))
    {
        g.Clear(Drawing.Color.Black);
        if (sourcePictureRatio < targetPictureRatio)
        {
            float scale = (float)team1Player1PictureRectangle.Height / 
                          (float)sourceBitmap.Height;
            //resizedImage = new Drawing.Bitmap(sourceBitmap, 
            //   new Drawing.Size((int)(sourceBitmap.Width * scale),
            //   (int)(sourceBitmap.Height * scale)));
            resizedImage = new Drawing.Bitmap(sourceBitmap, 
              new Drawing.Size((int)(sourceBitmap.Width * scale), 
              (int)(sourceBitmap.Height * scale)));
            g.DrawImage(resizedImage, new Drawing.Point((
              targetImage.Size.Width - resizedImage.Width) / 2, 0));
        }
        else
        {
            float scale = (float)team1Player1PictureRectangle.Width / 
                          (float)sourceBitmap.Width;
            resizedImage = new Drawing.Bitmap(sourceBitmap, 
              new Drawing.Size((int)(sourceBitmap.Width * scale), 
              (int)(sourceBitmap.Height * scale)));
            g.DrawImage(resizedImage, new Drawing.Point(0, 
              (targetImage.Size.Height - resizedImage.Height) / 2));
        }
    }
 
    targetImage.Save("tempImage");
    signInPictureSprite.Texture = 
      Texture2D.FromFile(GraphicsDevice, "tempImage");
    Texture2D texture = signInPictureSprite.Texture;
    signInPlayer.ImageByteArray = new byte[4 * texture.Width * texture.Height];
    signInPlayer.Texture = signInPictureSprite.Texture;
    texture.GetData<byte>(signInPlayer.ImageByteArray);
    clickHereSprite.Texture = null;
}
System.Windows.Forms.Clipboard.Clear();

球的渲染

如果游戏尽可能真实地呈现,那将是很好的。尽管这是一个 2D 游戏,但利用一些技术可以渲染出真实(或接近真实!)的台球游戏。

球的渲染是这个游戏的一个关键部分。如果您看一下下面的表格,您会发现渲染不是一次性完成的,而是分几个步骤进行的。

这是球之前的台布。

然后,我们必须放置由四个灯(每个灯在台球桌的角落)投下的阴影。

现在,这是球在背景上,没有平滑处理。请注意,边框有点“像素化”。

最后,我们在球周围应用“alpha 混合”技术,使其更平滑。
图 7. 使用不同图层渲染球

说到这里,就到了 MoveBalls 函数,它负责计算某个瞬间(快照)的球的位置。

请注意,这是游戏的灵魂所在,因为它必须解决碰撞(球与球之间,球与边界之间)、根据当前速度和摩擦系数计算球的速度、计算垂直旋转速度,以及决定球是否还在移动。

private List<SnookerCore.BallPosition> MoveBalls()
{
    //Instantiate a new ball position list, 
    //that will be returned at the end of the function.
    List<SnookerCore.BallPosition> ballPositionList = 
                        new List<SnookerCore.BallPosition>();

    //Flag indicating that the program is still calculating
    //the positions, that is, the balls are still in an inconsistent state.
    calculatingPositions = true;

    foreach (Ball ball in ballSprites)
    {
        if (Math.Abs(ball.X) < 5 && Math.Abs(ball.Y) < 5 && 
            Math.Abs(ball.TranslateVelocity.X) < 10 && 
            Math.Abs(ball.TranslateVelocity.Y) < 10)
        {
            ball.X =
            ball.Y = 0;

            ball.TranslateVelocity = new Vector2(0, 0);
        }
    }

    bool conflicted = true;

    //process this loop as long as some balls are still colliding
    while (conflicted)
    {
        conflicted = false;

        bool someCollision = true;
        while (someCollision)
        {
            foreach (Ball ball in ballSprites)
            {
                foreach (Pocket pocket in pockets)
                {
                    bool inPocket = pocket.IsBallInPocket(ball);
                }
            }

            someCollision = false;
            foreach (Ball ballA in ballSprites)
            {
                if (ballA.IsBallInPocket)
                {
                    ballA.TranslateVelocity = new Vector2(0, 0);
                }

                //Resolve collisions between balls and the diagonal borders.
                //the diagonal borders are the borders near the pockets.
                foreach (DiagonalBorder diagonalBorder in diagonalBorders)
                {
                    if (diagonalBorder.Colliding(ballA) && !ballA.IsBallInPocket)
                    {
                        diagonalBorder.ResolveCollision(ballA);
                    }
                }

                //Resolve collisions between balls 
                //and each of the 6 borders in the table
                RectangleCollision borderCollision = RectangleCollision.None;
                foreach (TableBorder tableBorder in tableBorders)
                {
                    borderCollision = tableBorder.Colliding(ballA);

                    if (borderCollision != RectangleCollision.None && 
                        !ballA.IsBallInPocket)
                    {
                        someCollision = true;
                        tableBorder.ResolveCollision(ballA, borderCollision);
                    }
                }

                //Resolve collisions between balls
                foreach (Ball ballB in ballSprites)
                {
                    if (ballA.Id.CompareTo(ballB.Id) != 0)
                    {
                        if (ballA.Colliding(ballB) && 
                            !ballA.IsBallInPocket && !ballB.IsBallInPocket)
                        {
                            if (ballA.Points == 0)
                            {
                                strokenBalls.Add(ballB);
                            }
                            else if (ballB.Points == 0)
                            {
                                strokenBalls.Add(ballA);
                            }

                            while (ballA.Colliding(ballB))
                            {
                                someCollision = true;
                                ballA.ResolveCollision(ballB);
                            }
                        }
                    }
                }

                //If the ball entered the pocket, it must be stopped
                if (ballA.IsBallInPocket)
                {
                    ballA.TranslateVelocity = new Vector2(0, 0);
                    ballA.VSpinVelocity = new Vector2(0, 0);
                }

                //Calculate ball's translation velocity (movement) 
                //as well as the spin velocity.
                //The friction coefficient is used to decrease ball's velocity
                if (ballA.TranslateVelocity.X != 0.0d ||
                    ballA.TranslateVelocity.Y != 0.0d)
                {
                    float signalXVelocity = 
                      ballA.TranslateVelocity.X >= 0.0f ? 1.0f : -1.0f;
                    float signalYVelocity = 
                      ballA.TranslateVelocity.Y >= 0.0f ? 1.0f : -1.0f;
                    float absXVelocity = Math.Abs(ballA.TranslateVelocity.X);
                    float absYVelocity = Math.Abs(ballA.TranslateVelocity.Y);

                    Vector2 absVelocity = new Vector2(absXVelocity, absYVelocity);

                    Vector2 normalizedDiff = new Vector2(absVelocity.X, absVelocity.Y);
                    normalizedDiff.Normalize();

                    absVelocity.X = absVelocity.X * (1.0f - friction) - 
                                    normalizedDiff.X * friction;
                    absVelocity.Y = absVelocity.Y * (1.0f - friction) - 
                                    normalizedDiff.Y * friction;

                    if (absVelocity.X < 0f)
                        absVelocity.X = 0f;

                    if (absVelocity.Y < 0f)
                        absVelocity.Y = 0f;

                    float vx = absVelocity.X * signalXVelocity;
                    float vy = absVelocity.Y * signalYVelocity;

                    if (float.IsNaN(vx))
                        vx = 0;

                    if (float.IsNaN(vy))
                        vy = 0;

                    ballA.TranslateVelocity = new Vector2(vx, vy);
                }

                //Calculate ball's translation velocity (movement) 
                //as well as the spin velocity.
                //The friction coefficient is used to decrease ball's velocity
                if (ballA.VSpinVelocity.X != 0.0d || ballA.VSpinVelocity.Y != 0.0d)
                {

                    float signalXVelocity = 
                      ballA.VSpinVelocity.X >= 0.0f ? 1.0f : -1.0f;
                    float signalYVelocity = 
                      ballA.VSpinVelocity.Y >= 0.0f ? 1.0f : -1.0f;
                    float absXVelocity = Math.Abs(ballA.VSpinVelocity.X);
                    float absYVelocity = Math.Abs(ballA.VSpinVelocity.Y);

                    Vector2 absVelocity = new Vector2(absXVelocity, absYVelocity);

                    Vector2 normalizedDiff = new Vector2(absVelocity.X, absVelocity.Y);
                    normalizedDiff.Normalize();

                    absVelocity.X = absVelocity.X - normalizedDiff.X * friction / 1.2f;
                    absVelocity.Y = absVelocity.Y - normalizedDiff.Y * friction / 1.2f;

                    if (absVelocity.X < 0f)
                        absVelocity.X = 0f;

                    if (absVelocity.Y < 0f)
                        absVelocity.Y = 0f;

                    ballA.VSpinVelocity = new Vector2(absVelocity.X * signalXVelocity, 
                                          absVelocity.Y * signalYVelocity);
                }
            }

            //Calculate the ball position based on both the ball's
            //translation velocity and vertical spin velocity.
            foreach (Ball ball in ballSprites)
            {
                ball.Position += new Vector2(ball.TranslateVelocity.X + 
                                             ball.VSpinVelocity.X, 0f);
                ball.Position += new Vector2(0f, ball.TranslateVelocity.Y + 
                                             ball.VSpinVelocity.Y);
            }
        }

        MoveBall(false);
        conflicted = false;
    }

    double totalVelocity = 0;
    foreach (Ball ball in ballSprites)
    {
        totalVelocity += ball.TranslateVelocity.X;
        totalVelocity += ball.TranslateVelocity.Y;
    }

    calculatingPositions = false;

    //If no balls are moving anymore, then the poos
    //state is set to "awaiting shot",
    //so that the game can wait for another shot from the same player of from the
    //other player.
    if (poolState == PoolState.MovingBalls && totalVelocity == 0)
    {
        if (poolState == PoolState.MovingBalls)
        {
            MoveBall(true);
            poolState = PoolState.AwaitingShot;
        }
    }

    ballPositionList = GetBallPositionList();

    //Return the resulting ball position list, so that
    //it can be added to the current snapshot.
    return ballPositionList;
}

杆的移动

在我看来,游戏中一个很酷的功能是杆的移动。

杆在近距离移动到主球周围(请注意杆在台球桌上投下的阴影)。

杆离开主球,准备击球。

杆围绕主球自由移动,沿着鼠标指针移动的方向。一旦玩家选择了目标,杆就会锁定在该方向,暂时离开主球,然后朝着期望的方向击球。

杆还在台球桌上投下阴影。此外,它在台球桌背景上也有平滑的渲染。

游戏模式

单人模式

想象一下您发布了游戏,并且只有多人模式。现在,想象一个可怜的玩家,总是请求他的/她的朋友来一局……而这个问题的解决方案是……

人工智能

……实现某种人工智能,来挑战玩家的智力。所以,即使您的游戏 intended 是多人模式,您也应该考虑拥有单人模式。玩家喜欢挑战。但是,如果您的人工智能走得太远,并且您的游戏变得如此“智能”,以至于您的玩家最终因为彻底的羞辱和失望而放弃您的游戏呢?

级别:简单、普通和困难

最后一个问题的答案是:将挑战分解为级别。就像大多数游戏一样,有三个难度级别:简单、普通和困难。它们之间的区别很简单:给定数量的“幻影球”,计算机随机选择其中一个,并在内部模拟一杆。然后它计算结果(该杆赢得的分数和失去的分数)。在这种模式下,很容易击败电脑。对于**简单**模式,计算机只有一次机会。在**普通**模式下,计算机生成 10 次模拟,并计算哪一次结果最好。但在**困难**模式下,计算机模拟多达 20 杆。所以,您必须成为一名非常出色的玩家才能击败它(如果您成功了,请稍后告诉我……)。

人工智能策略

我必须承认我从未接受过人工智能方面的培训。事实上,我甚至不知道这是否可以称为人工智能,但计算机在轮到它玩游戏时实际做的是随机模拟多杆,并决定哪一杆更好。关键点在于这个:

//decide whether this shot was better then the current best shot
if (shot.LostPoints < teams[playingTeamID - 1].BestShot.LostPoints || 
    shot.WonPoints > teams[playingTeamID - 1].BestShot.WonPoints)
{
    teams[playingTeamID - 1].BestShot.LostPoints = shot.LostPoints;
    teams[playingTeamID - 1].BestShot.WonPoints = shot.WonPoints;
    teams[playingTeamID - 1].BestShot.Position = shot.Position;
    teams[playingTeamID - 1].BestShot.Strength = shot.Strength;
}

完整的函数请参见下面的代码片段。

/// <summary>
/// Generate computer shot, based on the difficulty level and on the current
/// ball positions, as well as on random variables
/// </summary>
private void GenerateComputerShot()
{
    cueDistance = 0;

    List<Ball> auxBalls = new List<Ball>();

    //Saving current ball positions in auxBalls list,
    //so that it could be restored later,
    //after the simulations
    auxBalls.Clear();
    foreach (Ball b in ballSprites)
    {
        Ball auxBall = new Ball(null, null, null, null, 
             new Vector2(b.Position.X, b.Position.Y), 
             new Vector2((int)Ball.Radius, (int)Ball.Radius), b.Id, null, 0);
        auxBall.IsBallInPocket = b.IsBallInPocket;
        auxBalls.Add(auxBall);
    }

    //In order to decide whether the simulation was a good shot or not,
    //we must store the scores both before and after the simulations.
    int lastPlayerScore = teams[playingTeamID - 1].Points;
    int lastOpponentScore = teams[awaitingTeamID - 1].Points;
    int player1Score = teams[0].Points;
    int player2Score = teams[1].Points;
    string ballOnId = teams[playingTeamID - 1].BallOn.Id;

    int newPlayerScore = -1;
    int newOpponentScore = 1000;

    //This lines show the AI strategy: first, the simulations
    //aim to win points. If it's not possible, they aim not to loose
    //points. In the end, it try the "despair" mode, 
    //using reflected (mirrored) ghost balls technique.
    teams[playingTeamID - 1].Attempts++;
    if (teams[playingTeamID - 1].AttemptsToWin < maxComputerAttempts)
    {
        teams[playingTeamID - 1].AttemptsToWin++;
    }
    else if (teams[playingTeamID - 1].AttemptsNotToLose < maxComputerAttempts)
    {
        teams[playingTeamID - 1].AttemptsNotToLose++;
    }
    else
    {
        teams[playingTeamID - 1].AttemptsOfDespair++;
    }

    teams[playingTeamID - 1].Points = lastPlayerScore;
    teams[awaitingTeamID - 1].Points = lastOpponentScore;
    foreach (Ball b in ballSprites)
    {
        if (b.Id == ballOnId)
        {
            teams[playingTeamID - 1].BallOn = b;
            break;
        }
    }
    teams[0].Points = player1Score;
    teams[1].Points = player2Score;

    bool despair = (teams[playingTeamID - 1].AttemptsOfDespair > 0);

    UpdateGameState(GameState.TestShot);
    TestShot shot = GenerateRandomTestComputerShot(despair);
    teams[playingTeamID - 1].LastShot = shot;

    //If it's game over, there's no shot to simulate!
    if (shot == null) // Game Over
    {
        teams[playingTeamID - 1].BestShot = null;
        UpdateGameState(GameState.GameOver);
    }
    else
    {
        //Calculate positions and exit only when the balls are in a
        //consistent state.
        while (poolState == PoolState.MovingBalls)
        {
            MoveBalls();
        }

        calculatingPositions = false;

        //Calculate last shot data, including scores.
        ProcessFallenBalls();

        newPlayerScore = teams[playingTeamID - 1].Points;
        newOpponentScore = teams[awaitingTeamID - 1].Points;

        shot.WonPoints = newPlayerScore - lastPlayerScore;
        shot.LostPoints = newOpponentScore - lastOpponentScore;
        cueSprite.NewTarget = new Vector2(shot.Position.X, shot.Position.Y);

        double dx = ballSprites[0].DrawPosition.X - shot.Position.X;
        double dy = ballSprites[0].DrawPosition.Y - shot.Position.Y;
        double h = Math.Sqrt(dx * dx + dy * dy);
        teams[playingTeamID - 1].FinalCueAngle = (float)Math.Acos(dx / h);


        //decide whether this shot was better then the current best shot
        if (shot.LostPoints < teams[playingTeamID - 1].BestShot.LostPoints ||
            shot.WonPoints > teams[playingTeamID - 1].BestShot.WonPoints)
        {
            teams[playingTeamID - 1].BestShot.LostPoints = shot.LostPoints;
            teams[playingTeamID - 1].BestShot.WonPoints = shot.WonPoints;
            teams[playingTeamID - 1].BestShot.Position = shot.Position;
            teams[playingTeamID - 1].BestShot.Strength = shot.Strength;
        }

        int i = 0;
        foreach (Ball b in ballSprites)
        {
            Ball auxB = auxBalls[i];
            b.Position = new Vector2(auxB.Position.X, auxB.Position.Y);
            b.IsBallInPocket = auxB.IsBallInPocket;
            i++;
        }

        if (newPlayerScore > lastPlayerScore ||
            newOpponentScore == lastOpponentScore && 
            (teams[playingTeamID - 1].AttemptsToWin >= maxComputerAttempts) ||
            teams[playingTeamID - 1].AttemptsOfDespair > maxComputerAttempts
            )
        {
            teams[playingTeamID - 1].BestShotSelected = true;
            teams[playingTeamID - 1].LastShot = teams[playingTeamID - 1].BestShot;
        }
    }

    teams[playingTeamID - 1].Points = lastPlayerScore;
    teams[awaitingTeamID - 1].Points = lastOpponentScore;
    teams[0].Points = player1Score;
    teams[1].Points = player2Score;
    foreach (Ball b in ballSprites)
    {
        if (b.Id == ballOnId)
        {
            teams[playingTeamID - 1].BallOn = b;
            break;
        }
    }

    int j = 0;
    foreach (Ball b in ballSprites)
    {
        Ball auxB = auxBalls[j];
        b.Position = new Vector2(auxB.Position.X, auxB.Position.Y);
        b.IsBallInPocket = auxB.IsBallInPocket;
        j++;
    }

    //Proceed with a real shot using the best simulated shot data
    if (teams[playingTeamID - 1].BestShotSelected && 
        teams[playingTeamID - 1].BestShot != null)
    {
        hitPosition = new Vector2(teams[playingTeamID - 1].BestShot.Position.X, 
                      teams[playingTeamID - 1].BestShot.Position.Y);
        cueSprite.NewTarget = new Vector2(teams[playingTeamID - 1].BestShot.Position.X + 
                              poolRectangle.X - 7, 
                              teams[playingTeamID - 1].BestShot.Position.Y + 
                              poolRectangle.Y - 7);

        ghostBallSprite.Position = new Vector2(hitPosition.X + poolRectangle.X - 7, 
                                   hitPosition.Y + poolRectangle.Y - 7);

        teams[playingTeamID - 1].Strength = teams[playingTeamID - 1].BestShot.Strength;
        this.playerState = PlayerState.Aiming;

        teams[playingTeamID - 1].LastShot = teams[playingTeamID - 1].BestShot;

        UpdateCuePosition(0, (int)teams[playingTeamID - 1].BestShot.Position.X + 
                          poolRectangle.X, 
                          (int)teams[playingTeamID - 1].BestShot.Position.Y + 
                          poolRectangle.Y);

        poolState = PoolState.PreparingCue;
    }
    teams[playingTeamID - 1].IsRotatingCue = true;
}

绝望模式

如果计算机的所有尝试都以失败告终(即得分损失),那么它将获得最后一次机会。这就是我称之为最终“绝望模式”的东西。在这种模式下,计算机将射向反射(或镜像)的幻影球

幻影球

图 8. 幻影球

当计算机进行击球时,它并非盲目进行。台球桌上的每个位置都不能随意瞄准,因为那样效率太低。这就是为什么计算机将精力集中在“幻影球”上:它们被计算为与目标球只有一个接触点的圆,这样,如果您从幻影球中心画一条直线到目标球的位置,这条直线将包含目标球的中心点(参见上图)。

在上图 8 中,幻影球由球附近的白色圆圈表示。它们像台球桌上的任何其他球一样,由相同的 Ball 类实现。不同之处在于它们不会被渲染。

反射幻影球

如前所述,“绝望”模式下,只能瞄准反射幻影球。这意味着计算机有机会创造一个替代的击球,可能会产生更好的结果。

一旦瞄准了反射幻影球,主球路径就会在台球桌边界处“镜像”,因此主球不会击中反射幻影球,而是击中真正的幻影球。

图 9. 反射幻影球

下面的一段代码展示了部分实现。

foreach (Ball ballOn in ballOnList)
{
    List<ball> tempGhostBalls = GetGhostBalls(ballOn, false);
    if (!despair)
    {
        foreach (Ball ghostBall in tempGhostBalls)
        {
            ghostBalls.Add(ghostBall);
        }
    }
    else
    {
        //reflected by the top border
        Ball mirroredBall = new Ball(null, null, null, null, 
        new Vector2((int)(ballOn.X - Ball.Radius), (int)(-1.0 * ballOn.Y)), 
        new Vector2((int)Ball.Radius, (int)Ball.Radius), "m1", null, 0);
        tempGhostBalls = GetGhostBalls(mirroredBall, despair);
        foreach (Ball ghostBall in tempGhostBalls)
        {
            ghostBalls.Add(ghostBall);
        }

杆移动模拟

我们刚刚看到的人工智能的“点睛之笔”是让计算机的行为有点像人。通常,人类玩家会看看可能性,计算,移动球杆,再次计算,将球杆移回最后位置,然后稍作停留后才击球。这正是计算机在游戏中做的事情。轮到它时,您会看到球杆四处移动,这里瞄一下,那里瞄一下,然后才击球。

您会发现,在**简单**模式下,计算机随意击球。而在**困难**模式下,计算机通常需要更长的时间来击球,因为它在“思考”最佳的可能性。

多人模式

尽管 XNA 台球俱乐部中的多人模式 intended 是在不同机器上运行,但也可以在同一台机器上运行 WCF 服务和两个 XNA 客户端,如下面的图片所示。

WCF服务/WCF客户端

尽管与电脑对战可能很有趣,但由于 Windows Communication Foundation 的存在,两名(人类)玩家也可以互相玩 XNA 台球俱乐部。但客户端并不直接连接。相反,它们依赖于运行在不同应用程序中的 WCF 服务来广播消息。

值得一提的是,在我完成阅读 Sacha Barber 的精彩文章《WCF / WPF 聊天应用程序》之后,我在这项 WCF 实现上的工作变得容易多了。因此,您会在这两个实现之间发现相似之处。在 Sacha 的聊天应用程序中,客户端通过加入同一个 WCF 服务来建立连接。对话是通过客户端发送消息,然后由该服务广播给已加入聊天的用户来完成的。而在 XNA 台球俱乐部中,客户端连接到台球服务,该服务随后广播比赛动作、得分、Sound 等。这种台球玩家之间的交流也是一种对话形式。

WCF 服务提供了多种托管选项:控制台、Windows Forms、WPF、Windows 服务、IIS、自托管……这是一件非常棒的事情。WCF 台球服务由一个控制台应用程序托管。控制台应用程序的好处在于其简单性。它们易于创建和运行,并且在您需要向输出写入消息时非常方便。

static void Main(string[] args)
{
    // Get host name
    String strHostName = Dns.GetHostName();
 
    // Find host by name
    IPHostEntry iphostentry = Dns.GetHostEntry(strHostName);
 
    // Enumerate IP addresses
    int nIP = 0;
    foreach (IPAddress ipaddress in iphostentry.AddressList)
    {
        Console.WriteLine("Server IP: #{0}: {1}", ++nIP, ipaddress);
    }
 
    //Concatenates the configuration address 
    //with the ip obtained from this server
    uri = new Uri(string.Format(
            ConfigurationManager.AppSettings["address"], strHostName));
 
    ServiceHost host = new ServiceHost(typeof(SnookerService), uri);
    host.Opened += new EventHandler(host_Opened);
    host.Closed += new EventHandler(host_Closed);
    host.Open();
    Console.ReadLine();
    host.Abort();
    host.Close();
}

图 10. WCF 服务正在运行
服务代理

XNA 台球俱乐部 VS2008 解决方案被分成两个应用程序层:核心项目和 XNA 项目。WCF 客户端位于核心层。它负责与 WCF 服务建立连接,以及发送和接收来自服务的数据。

WCF 客户端通过代理类知道如何与 WCF 服务通信。这个类可以使用 **svcutil** 命令行实用工具自动生成,或者使用 Visual Studio 2008 中的 **添加服务引用** 菜单生成。

一旦代理创建完成,客户端就可以开始调用服务方法了。但是,更好的做法是应用 **服务代理** 模式。服务代理是客户端上的一个组件,它封装了代理方法并执行额外的处理,以便进一步将客户端代码与服务分开。在我们的例子中,服务代理是一个驻留在核心项目中的单例类。

服务合同

WCF 服务类是 ISnooker 接口的实现。

[ServiceContract(SessionMode = SessionMode.Required, 
        CallbackContract = typeof(ISnookerCallback))]
interface ISnooker
{
    [OperationContract(IsOneWay = true, 
     IsInitiating = false, IsTerminating = false)]
    void Play(ContractTeam team, ContractPerson name, Shot shot);
 
    [OperationContract(IsOneWay = false, 
     IsInitiating = true, IsTerminating = false)]
    ContractTeam[] Join(ContractPerson name);
 
    [OperationContract(IsOneWay = true, 
     IsInitiating = false, IsTerminating = true)]
    void Leave();
}

首先,修饰接口的 ServiceContract 属性定义了服务可用的操作,这些操作会在服务接收到来自客户端的请求时被调用。会话模式被设置为 Required,这指示合同需要维护会话(这就是为什么可以进行对话)。

CallbackContract 为双工 MEP(消息交换模式)建立了基础设施,这就是它允许服务主动向客户端发送消息(例如,关于一个刚被玩家打出的球的通知)的方式。

**Join** 操作允许客户端加入游戏。如果 WCF 端当前的游戏已经有两名玩家,则拒绝加入操作。

作为对 Join 操作的响应,服务返回有关当前队伍和玩家的信息。

当玩家击打主球时,XNA 应用程序开始在一个存储在 Shot 类中的结构中记录球的位置移动。只要台球桌上有移动的球,这个记录就会继续。

一旦玩家完成一杆(即,当**所有**球都静止时),就会调用 **Play** 操作,因此 WCF 客户端调用代理中的 Shot,并提供一个包含记录的球移动序列、Sound 和得分的 Shot 参数。

Join 操作相反,当 XNA 客户端退出时,它会调用 **Leave** 操作,该操作会向游戏参与者广播一个回调,表明其中一个伙伴已离开。

回调

正如我们在上一节中看到的,ISnookerCallbackContractISnookerCallback。这意味着,当客户端调用台球服务端的某个操作时,它也必须预期服务会使用 ISnookerCallback 中定义的方法回调 XNA 客户端。

interface ISnookerCallback
{
    [OperationContract(IsOneWay = true)]
    void ReceivePlay(ContractTeam team, 
         ContractPerson person, Shot shot);
   
    [OperationContract(IsOneWay = true)]
    void UserEnter(ContractTeam team, ContractPerson person);
 
    [OperationContract(IsOneWay = true)]
    void UserLeave(ContractTeam team, ContractPerson person);
}

请注意下图中,正在运行的 WCF 服务如何处理请求并将消息广播回游戏参与者。

图 11. WCF 服务处理请求/响应
出杆/进杆

如前所述,球的移动通过 Shot 数据合同发送/接收到/从服务。请注意下方的类,除了快照列表之外,Shot 还有更多信息,例如得分以及一个 GameOver 字段,指示玩家在刚刚那一杆之后是否赢得了比赛。

[DataContract]
public class Shot
{
    #region private members
    ...
    #endregion
 
    #region constructors
    ...
    #endregion
 
    #region public members
 
    [DataMember]
    public int TeamId
    {
        get { return teamId; } 
        set { teamId = value; }
    }
 
    [DataMember]
    public List<snapshot> SnapshotList
    {
        get { return snapshotList; } 
        set { snapshotList = value; }
    }
 
    [DataMember]
    public int CurrentTeamScore
    {
        get { return currentTeamScore; } 
        set { currentTeamScore = value; }
    }
 
    [DataMember]
    public int OtherTeamScore
    { 
        get { return otherTeamScore; } 
        set { otherTeamScore = value; }
    }
 
    [DataMember]
    public bool HasFinishedTurn
    {     
        get { return hasFinishedTurn; } 
        set { hasFinishedTurn = value; }
    }
 
    [DataMember]
    public bool GameOver
    {
        get { return gameOver; } 
        set { gameOver = value; }
    }
    #endregion
}

出杆数据由 outgoingShot 对象(Shot 类)持有,并且每次玩家准备击球时,由 XNA 客户端创建。

每次球在台球桌上移动时,XNA 客户端都会创建一个新的 Snapshot,其中包含给定时间点台球桌上所有球的位置,以及在该瞬间发生的 Sound。然后,这个新的 Snapshot 被添加到 outgoingShot 中,如下所示。

private static void CreateSnapshot(List<SnookerCore.BallPosition> newBPList)
{
    if (rbtMultiPlayer.Checked)
    {
        List<SnookerCore.Snapshot> currentSnapshotList = 
                                  outgoingShot.SnapshotList.ToList();
        SnookerCore.Snapshot newSnapshot = new Snapshot();
        newSnapshot.ballPositionList = newBPList.ToArray();
        newSnapshot.snapshotNumber = currentSnapshotNumber;
        newSnapshot.sound = GameSound.None;
        currentSnapshotList.Add(newSnapshot);
        outgoingShot.SnapshotList = currentSnapshotList.ToArray();
        currentSnapshotNumber++;
    }
}

在多人模式下,XNA 客户端上发生的所有事情都必须列入 outgoingShot,以便稍后发送给另一位玩家。但是,outgoingShot 在击球完成、所有球静止并且得分计算完毕之前不会发送,如下所示。

//Here goes the "fade out" effect when the ball enters
//the pocket and then disappears
someFalling = ProcessSomeFalling();
//If all balls have fallen, we should prepare 
//for sending the outgoing shot info
if (!someFalling)
{
    if (lastPoolState == PoolState.MovingBalls)
    {
        if (!fallenBallsProcessed)
        {
            //Calculate both scores for the fallen balls,
            //restore illegal potted balls to their correct positions, etc.
            ProcessFallenBalls();
            foreach (Ball b in ballSprites)
            {
                b.DrawPosition = b.Position;
            }
            //Get the final position of each ball on the table
            //and add the snapshot to the outgoing shot
            CreateSnapshot(GetBallPositionList());

            //Occurrs only in multiplayer mode. In singleplayer there's no
            //need to broadcast outgoing shots.
            if (rbtMultiPlayer.Checked)
            {
                logList.Add(string.Format("Player {0} sent {1} snapshots", 
                     contractPerson.Name, outgoingShot.SnapshotList.Count()));
                //We should propagate both scores
                outgoingShot.CurrentTeamScore = teams[0].Points;
                outgoingShot.OtherTeamScore = teams[1].Points;

                //Now it's up to the WCF service 
                //to receive and broadcast this player's shot
                SnookerServiceAgent.GetInstance().Play(contractTeam, 
                                                  contractPerson, outgoingShot);

                //We must reset the outgoing shot for the next turn
                ResetOutgoingShot();
            }
        }
    }
}

当这个 outgoingShot 对象到达 WCF 服务时,它只是被广播给其他游戏玩家。

/// <summary>
/// Broadcasts the list of ball positions to all the <see cref="Common.Person">
/// Person whos name matches the to input parameter
/// by looking up the person from the internal list of players
/// and invoking their SnookerEventHandler delegate asynchronously.
/// Where the MyEventHandler() method is called at the start of the
/// asynch call, and the EndAsync() method at the end of the asynch call
/// </summary>
/// <param name="to">The persons name to send the message to</param>
/// <param name="msg">The message to broadcast to all players</param>
public void Play(ContractTeam team, ContractPerson person, Shot shot)
{
    Console.WriteLine(string.Format("Receiving shot information from team " + 
                      "{0}, player {1}, with {2} snapshots.", 
                      team.Id, person.Name, shot.SnapshotList.Count));
    SnookerEventArgs e = new SnookerEventArgs();
    e.msgType = MessageType.ReceivePlay;
    e.team = team;
    e.person = person;
    e.shot = shot;
    BroadcastMessage(e);
    Console.WriteLine(string.Format("Shot information has been broadcasted"));
}

接收到进杆时,XNA 客户端必须完全重现另一位玩家电脑上发生的一切。这包括相同的球位置、相同的帧率、相同的得分、相同的进球、连贯的回合轮换和连贯的比赛结束处理。

一旦 XNA 客户端完全重播了进杆,就必须确保两个 XNA 客户端的状态完全相同。如果未能实现这种一致性,两位玩家将面临球位置不一致、回合轮换错误、得分错误等问题。

下面的代码说明了处理进杆的关键部分。

//Since both players receive the incoming shot
//from the WCF service, we must reproduce the shot only
//if it was made by the other player
if (contractTeam.Id != playingTeamID)
{
    if (currentIncomingShot == null)
    {
        if (incomingShotList.Count > 0)
        {
            //Since we might receive more than one incoming shot,
            //we pick only the first shot in the queue, and then
            //dequeue it.
            currentIncomingShot = incomingShotList[0];
            incomingShotList.RemoveAt(0);
        }
    }

    if (currentIncomingShot != null)
    {
        //Now we have to reproduce the other player's movement
        //snapshot-by-snapshot, exactly as it was seen by the other player.
        if (currentSnapshotNumber <= currentIncomingShot.SnapshotList.Length)
        {
            //The movement delay is also exactly the same as the other
            //player. The snapshots are processed in the same frame rate as the
            //original player.
            if (movingBallDelay <== 0)
            {
                movingBallDelay = maxMovingBallDelay;

                //Reproducing the ball positions inside the current snapshot,
                //we should check whether the ball was potted or not.
                foreach (BallPosition bp in 
                  currentIncomingShot.SnapshotList[currentSnapshotNumber].ballPositionList)
                {
                    ballSprites[bp.ballIndex].Position = new Vector2(bp.x, bp.y);
                    ballSprites[bp.ballIndex].IsBallInPocket = bp.isBallInPocket;
                }

                //The game wouldn't be that cool if your machine couldn't reproduce
                //the sound exactly as the other player's did, isn't it?
                GameSound sound = 
                  currentIncomingShot.SnapshotList[currentSnapshotNumber].sound;
                if (sound != GameSound.None)
                {
                    soundBank.PlayCue(sound.ToString());
                }
                currentSnapshotNumber++;
            }
        }
        else
        {
            //Once all ball movements have been processed,
            //the incoming scores are applied. Notice that
            //the XNA client that is playing is responsible 
            //for calculating both scores.
            //The receiving XNA client, on the other hand, just
            //accepts those scores.
            teams[0].Points = currentIncomingShot.CurrentTeamScore;
            teams[1].Points = currentIncomingShot.OtherTeamScore;

            //"GameOver" is a flag of the incoming shot data.
            //In this line, the user's machine is checking whether it
            //has lost the game
            if (currentIncomingShot.GameOver)
            {
                UpdateGameState(GameState.GameOver);
            }
            else if (currentIncomingShot.HasFinishedTurn)
            {
                //Now it's time to change turns.
                playingTeamID = (playingTeamID == 1) ? 2 : 1;
                awaitingTeamID = (playingTeamID == 1) ? 2 : 1;
                logList.Add(string.Format("Team {0} is ready to play", playingTeamID));
                logList.Add(string.Format("Team {0} is waiting", awaitingTeamID));

                //Decide which one is the next ball
                teams[playingTeamID - 1].BallOn = GetRandomRedBall();

                if (teams[playingTeamID - 1].BallOn == null)
                    teams[playingTeamID - 1].BallOn = GetMinColouredball();
            }
            currentSnapshotNumber = 0;
            currentIncomingShot = null;
        }
    }
}

5. 结论

如果您有耐心读到这里,我想感谢您。请下载应用程序并进行评估。无论您喜欢与否,请不要忘记在文章末尾留下您的评论,我将不胜感激。写和测试它都非常有趣。我也祝您玩得开心。

历史

  • 2010-02-05:第一个版本。
  • 2010-02-06:添加了 YouTube 图像。
  • 2010-02-20:进杆/出杆 - 详细且带注释的解释。
  • 2010-03-06:人工智能和物理学 - 详细且带注释的解释。
© . All rights reserved.