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

C# 台球

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (204投票s)

2009年11月28日

CPOL

8分钟阅读

viewsIcon

816764

downloadIcon

29679

带声音的 C# 台球游戏。

CSharpSnooker

引言

本文旨在分享用 C# 编写台球游戏时的一些有趣发现。虽然我最初的动机是为读者提供一些有用的编程信息,但我同时也希望您能真正享受这款游戏本身。

背景

游戏建立在三个基石之上

  • 碰撞检测/碰撞解决:首先,对于台球游戏来说,拥有碰撞检测并妥善处理碰撞是至关重要的。当球移动时,它们必须始终被限制在边界内,除非落入袋中,否则会留在球台上。当球与其他球或边界碰撞时,您必须知道这一点,并在屏幕上放置碰撞球之前采取措施解决碰撞。碰撞本身并不那么麻烦(例如,您只需检查两个球之间的距离是否小于它们半径之和的两倍)。真正的问题在于决定碰撞球在那个时刻应该处于什么位置,就好像它们是真实的物体一样,并且还要计算它们产生的方向。我费了很大力气才正确地解决碰撞问题,在我放弃自己重新发明轮子后,最终求助于 Google。尽管有很多文章解释了碰撞解决,但我最终使用了 Matthew McDole 的这篇简洁明了的 检测和处理 文章。
  • 快速流畅的图形渲染:起初,我尝试使用定时器来控制渲染过程。每经过一次滴答,就计算球的位置,然后渲染图形。问题在于,通常每次滴答的计算时间都不同,因为有时只有一个球在移动,而有时,则有 10 个球同时碰撞并移动。因此,计算的投入不同。这种差异影响了渲染,看起来像是“断断续续”的。这令人沮丧。例如,如果您看看其他的斯诺克游戏,您会注意到每一次击球都有流畅的渲染。然后,我重构了代码,先进行所有计算,然后创建一个包含帧序列的内存“电影”(每一帧都是球在某个时间点在球台背景上的快照)。当所有球都停止移动后,“电影”就完成了并开始播放。起初,这看起来像是在付出过多的努力,但对于这样的游戏来说,渲染速度是至关重要的。如果我将游戏移植到 XNA 技术可能会更容易,但我不想强迫 CodeProject 用户下载额外的 Visual Studio 包。
  • 逼真的音效:当我终于让图形工作起来时,我注意到缺少了什么。我希望游戏具有声音,使其更加逼真和激动人心。经过一些研究,我发现了一些可以用于台球杆击打主球、球与球碰撞以及其他真实台球游戏声音的 .wav 文件。然后,我尝试使用默认的 System.Media.SoundPlayer 对象来播放它们,但很快就注意到它无法同时播放声音:当您播放一个声音时,所有正在执行的声音都会停止。幸运的是,我找到了出色的 IrrKlang 音频引擎,并解决了这个问题。它有一个非常有趣的 3D 音频引擎,您可以在其中定义声音和 XYZ 坐标。想想一个第一人称射击游戏。您走在一条黑暗的街道上,听到右边传来一声轻微的咆哮声。当您继续走时,声音变得越来越大。再走一点,声音在您的右耳和左耳一样响。然后,声音来自您的右侧。最后,您发现自己被一个狡猾的怪物跟踪,它越来越近,从您的右侧移动到左侧。您可以通过告诉 IrrKlang 引擎播放不同 XYZ 坐标的“roar.wav”声音来做类似的事情,将第一人称射击者(您)视为参考点。在这款游戏中,我使用 3D 音频引擎根据声音源的坐标来播放声音。

游戏

规则

游戏本身是一个简化的斯诺克游戏。它没有 15 个红球,只有 6 个。每个红球得 1 分,而“彩色”球得分从 2 到 7 分(黄=2,绿=3,棕=4,蓝=5,粉=6,黑=7)。

玩家必须使用主球(白球)瞄准击打“目标球”。“目标球”始终在红球和彩球之间交替,只要桌上还有红球。一旦所有红球都被击入袋中,目标球就是得分最低的彩球。如果玩家漏打目标球,或者击打非目标球,则算犯规。如果玩家击入非目标球,则算犯规。如果玩家未能用主球击中任何其他球,则算犯规。只有在没有犯规的情况下,玩家才能得分。犯规点数会判给对手。当所有球(除主球外)都被击入袋中时,游戏结束。

int strokenBallsCount = 0;
foreach (Ball ball in strokenBalls)
{
    //causing the cue ball to first hit a ball other than the ball on
    if (strokenBallsCount == 0 && ball.Points != currentPlayer.BallOn.Points)
        currentPlayer.FoulList.Add((currentPlayer.BallOn.Points < 4 ? 4 :
                                    currentPlayer.BallOn.Points));

    strokenBallsCount++;
}

//Foul: causing the cue ball to miss all object balls
if (strokenBallsCount == 0)
    currentPlayer.FoulList.Add(4);

foreach (Ball ball in pottedBalls)
{
    //causing the cue ball to enter a pocket
    if (ball.Points == 0)
        currentPlayer.FoulList.Add(4);

    //causing a ball not on to enter a pocket
    if (ball.Points != currentPlayer.BallOn.Points)
        currentPlayer.FoulList.Add(currentPlayer.BallOn.Points < 4 ? 4 :
                                   currentPlayer.BallOn.Points);
}

if (currentPlayer.FoulList.Count == 0)
{
    foreach (Ball ball in pottedBalls)
    {
        //legally potting reds or colors
        wonPoints += ball.Points;
    }
}
else
{
    currentPlayer.FoulList.Sort();
    lostPoints = currentPlayer.FoulList[currentPlayer.FoulList.Count - 1];
}

currentPlayer.Points += wonPoints;
otherPlayer.Points += lostPoints;

用户界面

屏幕上有三个重要区域:球台、比分和杆控制。

球台

图 1. 游戏球台显示其许多黄色的边框。

这张桌子是红木模型,覆盖着精细的蓝色绒布。有六个球袋,每个角一个,另外两个在长边中间。

轮到您时,当您将鼠标移到球台上时,鼠标指针会变成瞄准镜(当目标球已选择时)或手(当您必须选择目标球时)。当您按下鼠标左键时,主球将从其原始点运行到选定点。

void HitBall(int x, int y)
{
    //Reset the frames and ball positions
    ClearSequenceBackGround();
    ballPositionList.Clear();

    poolState = PoolState.Moving;
    picTable.Cursor = Cursors.WaitCursor;

    //20 is the maximum velocity
    double v = 20 * (currentPlayer.Strength / 100.0);

    //Calculates the cue angle, and the translate velocity (normal velocity)
    double dx = x - balls[0].X;
    double dy = y - balls[0].Y;
    double h = (double)(Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2)));
    double sin = dy / h;
    double cos = dx / h;
    balls[0].IsBallInPocket = false;
    balls[0].TranslateVelocity.X = v * cos;
    balls[0].TranslateVelocity.Y = v * sin;
    Vector2D normalVelocity = balls[0].TranslateVelocity.Normalize();

    //Calculates the top spin/back spin velocity,
    //in the same direction as the normal velocity, but in opposite angle
    double topBottomVelocityRatio =
        balls[0].TranslateVelocity.Lenght() * (targetVector.Y / 100.0);
    balls[0].VSpinVelocity = new Vector2D(-1.0d * topBottomVelocityRatio *
             normalVelocity.X, -1.0d * topBottomVelocityRatio * normalVelocity.Y);

    //xSound defines if the sound is coming from the left or the right
    double xSound = (float)(balls[0].Position.X - 300.0) / 300.0;
    soundTrackList[snapShotCount] = @"Sounds\Shot01.wav" + "|" + xSound.ToString();

    //Calculates the ball positions as long as there are moving balls
    while (poolState == PoolState.Moving)
        MoveBalls();

    currentPlayer.ShotCount++;
}

比分

比分是一个复古的木制面板,显示两名玩家的比分。此外,它还显示一个闪烁的目标球图像。

private void timerBallOn_Tick(object sender, EventArgs e)
{
    if (playerState == PlayerState.Aiming || playerState == PlayerState.Calling)
    {
        picBallOn.Top = 90 + (currentPlayer.Id - 1) * 58;
        showBallOn = !showBallOn;
        picBallOn.Visible = showBallOn;
    }
}

图 2. 我与电脑对决。

杆控制

杆控制是一个拉丝钢面板,有两个目标:控制杆的力度(上方红线)和控制主球的“旋转”。您可以根据情况使用力度条进行更精确的击打。而且,如果您知道如何做“上旋”和“下旋”,那么旋转控制就很有用。“上旋”,也称为“跟随”,会增加主球的速度,并在主球击中另一球时产生更开阔的角度。“下旋”则会降低主球的速度,并在击中目标球后使主球向后移动。这也会影响击球后的结果角度,通常会使主球沿着曲线移动。

注意:我没有实现“侧旋”,因为我认为它需要太多努力,并且对文章的贡献不大。

图 3. 力度控制和旋转控制。

图 4. 旋转路径。

图 5. 不同旋转的效果:正常(无旋转)、下旋和上旋。
public void ResolveCollision(Ball ball)
{
    // get the mtd
    Vector2D delta = (position.Subtract(ball.position));
    float d = delta.Lenght();
    // minimum translation distance to push balls apart after intersecting
    Vector2D mtd =
      delta.Multiply((float)(((Ball.Radius + 1.0 + Ball.Radius + 1.0) - d) / d));

    // resolve intersection --
    // inverse mass quantities
    float im1 = 1f;
    float im2 = 1f;

    // push-pull them apart based off their mass
    position = position.Add((mtd.Multiply(im1 / (im1 + im2))));
    ball.position = ball.position.Subtract(mtd.Multiply(im2 / (im1 + im2)));

    // impact speed
    Vector2D v = (this.translateVelocity.Subtract(ball.translateVelocity));
    float vn = v.Dot(mtd.Normalize());

    // sphere intersecting but moving away from each other already
    if (vn > 0.0f)
        return;

    // collision impulse
    float i = Math.Abs((float)((-(1.0f + 0.1) * vn) / (im1 + im2)));
    Vector2D impulse = mtd.Multiply(1);

    int hitSoundIntensity = (int)(Math.Abs(impulse.X) + Math.Abs(impulse.Y));

    if (hitSoundIntensity > 5)
        hitSoundIntensity = 5;

    if (hitSoundIntensity < 1)
        hitSoundIntensity = 1;

    double xSound = (float)(ball.Position.X - 300.0) / 300.0;
    observer.Hit(string.Format(@"Sounds\Hit{0}.wav",
       hitSoundIntensity.ToString("00")) + "|" + xSound.ToString());

    // change in momentum
    this.translateVelocity = this.translateVelocity.Add(impulse.Multiply(im1));
    ball.translateVelocity = ball.translateVelocity.Subtract(impulse.Multiply(im2));
}

电影

图 6. 内存中的帧。

每一次击球,都会启动一个新的“电影”。应用程序计算所有运动,并生成一个球的位置列表,只要桌上至少有一个球在移动。当所有球都静止时,球位置列表用于创建内存中的帧,就像电影中的帧一样。当所有帧都创建完成后,电影就会以平滑快速的方式播放。

void DrawSnapShots()
{
    XmlSerializer serializer = 
     new XmlSerializer(typeof(List<ballposition>));
    string path = 
     Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
    using (StreamWriter sw = new StreamWriter(Path.Combine(path,
                             @"Out\BallPositionList.xml")))
    {
        serializer.Serialize(sw, ballPositionList);
    }

    ClearSequenceBackGround();
    int snapShot = -1;

    Graphics whiteBitmapGraphics = null;

    //For each ball, draws an image of that ball 
    //over the pool background image
    foreach (BallPosition ballPosition in ballPositionList)
    {
        if (ballPosition.SnapShot != snapShot)
        {
            snapShot = ballPosition.SnapShot;
            whiteBitmapGraphics = whiteBitmapGraphicsList[snapShot];
        }

        //draws an image of a ball over the pool background image
        whiteBitmapGraphics.DrawImage(balls[ballPosition.BallIndex].Image,
          new Rectangle((int)(ballPosition.X - Ball.Radius),
          (int)(ballPosition.Y - Ball.Radius),
          (int)Ball.Radius * 2, (int)Ball.Radius * 2), 0, 0,
          (int)Ball.Radius * 2, (int)Ball.Radius * 2, GraphicsUnit.Pixel, attr);
    }
}

private void PlaySnapShot()
{
    //Plays an individual frame, by replacing the image of the picturebox with
    //the stored image of a frame
    picTable.Image = whiteBitmapList[currentSnapShot - 1]; ;
    picTable.Refresh();

    string currentSound = soundTrackList[currentSnapShot - 1];

    if (currentSound.Length > 0)
    {
        currentSound += "|0";
        string fileName = currentSound.Split('|')[0];
        Decimal x = -1 * Convert.ToDecimal(currentSound.Split('|')[1]);

        //Plays the sound considering whether the sounds comes from left or right
        soundEngine.Play3D(fileName, 0, 0, (float)x);
    }

    currentSnapShot++;
}

声音引擎

正如我之前提到的,游戏不使用 System.Media.SoundPlayer 对象来播放声音,因为每播放一个新的声音都会“打断”当前的声音。这意味着,您无法同时听到球落入袋中的声音和两个球碰撞的声音。我用 IrrKlang 组件解决了这个问题。此外,我还让声音引擎根据声音源的位置来播放声音。例如,如果一个球落入右上方的球袋,您会听到右耳的声音更大。如果一个球在球台的角落与另一个球相撞,您会听到来自左侧的声音。我在互联网上找到了一些很酷的斯诺克声音,其中一些根据碰撞球的速度而发出柔和或响亮的声音。

图 7. 音效。
if (currentSound.Length > 0)
{
    currentSound += "|0";
    string fileName = currentSound.Split('|')[0];
    Decimal x = -1 * Convert.ToDecimal(currentSound.Split('|')[1]);

    //Plays the sound considering whether the sounds comes from left or right
    soundEngine.Play3D(fileName, 0, 0, (float)x);
}

人工智能

所谓的“幽灵球”在游戏智能方面起着重要作用。当计算机轮到它时,它会寻找所有好的“幽灵球”,以便有更多成功的机会。幽灵球是靠近“目标球”的点,您可以瞄准这些点,以便球能够落入指定的球袋。

private List GetGhostBalls(Ball ballOn)
{
    List ghostBalls = new List();

    int i = 0;
    foreach (Pocket pocket in pockets)
    {
        //distances between pocket and ball on center
        double dxPocketBallOn = pocket.HotSpotX - ballOn.X;
        double dyPocketBallOn = pocket.HotSpotY - ballOn.Y;
        double hPocketBallOn = Math.Sqrt(dxPocketBallOn * 
            dxPocketBallOn + dyPocketBallOn * dyPocketBallOn);
        double a = dyPocketBallOn / dxPocketBallOn;

        //distances between ball on center and ghost ball center
        double hBallOnGhost = (Ball.Radius - 1.0) * 2.0;
        double dxBallOnGhost = hBallOnGhost * (dxPocketBallOn / hPocketBallOn);
        double dyBallOnGhost = hBallOnGhost * (dyPocketBallOn / hPocketBallOn);

        //ghost ball coordinates
        double gX = ballOn.X - dxBallOnGhost;
        double gY = ballOn.Y - dyBallOnGhost;
        double dxGhostCue = balls[0].X - gX;
        double dyGhostCue = balls[0].Y - gY;
        double hGhostCue = Math.Sqrt(dxGhostCue * dxGhostCue + dyGhostCue * dyGhostCue);

        //distances between ball on center and cue ball center
        double dxBallOnCueBall = ballOn.X - balls[0].X;
        double dyBallOnCueBall = ballOn.Y - balls[0].Y;
        double hBallOnCueBall = Math.Sqrt(dxBallOnCueBall * 
            dxBallOnCueBall + dyBallOnCueBall * dyBallOnCueBall);

        //discards difficult ghost balls
        if (Math.Sign(dxPocketBallOn) == Math.Sign(dxBallOnCueBall) && 
        Math.Sign(dyPocketBallOn) == Math.Sign(dyBallOnCueBall))
        {
            Ball ghostBall = new Ball(i.ToString(), null, 
            (int)gX, (int)gY, "", null, null, 0);
            ghostBalls.Add(ghostBall);
            i++;
        }
    }

    return ghostBalls;
}

有些幽灵球可能很难或不可能达到,因为它们位于目标球后面。计算机应该丢弃这些幽灵球。

//discards difficult ghost balls
if (Math.Sign(dxPocketBallOn) == Math.Sign(dxBallOnCueBall) && 
    Math.Sign(dyPocketBallOn) == Math.Sign(dyBallOnCueBall))
{
    Ball ghostBall = new Ball(i.ToString(), null, (int)gX, (int)gY, "", null, null, 0);
    ghostBalls.Add(ghostBall);
    i++;
}

然后,计算机必须从剩余的幽灵球中选择一个(有时计算机幸运,有时不幸运……)。

private Ball GetRandomGhostBall(List ballOnList)
{
    Ball randomGhostBall = null;

    List ghostBalls = new List();

    foreach (Ball ballOn in ballOnList)
    {
        List tempGhostBalls = GetGhostBalls(ballOn);
        foreach (Ball ghostBall in tempGhostBalls)
        {
            ghostBalls.Add(ghostBall);
        }
    }

    int ghostBallCount = ghostBalls.Count;
    if (ghostBallCount > 0)
    {
        Random rnd = new Random(DateTime.Now.Second);
        int index = rnd.Next(ghostBallCount);

        randomGhostBall = ghostBalls[index];
    }
    return randomGhostBall;
}

图 8. 幽灵球。

未来版本

  • 多人游戏功能。
  • 多机功能(待定:WCF、Remoting、Skype 等)。

历史

  • 2009-11-29:首个版本。
  • 2009-12-04:文章更新。
  • 2009-12-09:错误修复。
  • 2009-12-12:改进 AI,错误修复。
© . All rights reserved.