XNA 台球俱乐部






4.99/5 (128投票s)
支持WCF的多人XNA游戏,适用于Windows平台。
目录
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. 系统要求
系统要求取决于您想做什么。
- 也许您是一个好奇的人,想下载完整的源代码来查看实现,那么您需要以下软件:
- Microsoft .NET Framework 3.5 (2.7 MB,仅安装程序)
- Visual Studio 2008 (Express 版本 2.58 MB,仅安装程序) 或更高版本
- Microsoft XNA Game Studio 3.1 (73.2 MB)
- 但也许您只是想下载 EXE 文件来试用,然后以后(或不)再下载源代码,这种情况下,您应该拥有:
- Microsoft .NET Framework 3.5 (2.7 MB,仅安装程序)
- Microsoft XNA Framework Redistributable 3.1 (7.3 MB)
XNA客户端
XNA简介
如果您是 .NET 开发者,XNA 应该是编写游戏的第一个选择,即使您没有使用该工具的经验。作为一个新手,我曾对它抱有偏见。但转向 XNA 比我想象的要容易得多。当一项技术无法跟上您的创意时,就会令人沮丧,但 XNA 不同。在开发游戏时,您不会问“能否用 XNA 实现?”,而是问“我需要学习哪种技术才能用 XNA 实现?”。毫不夸张地说。我认为任何顶级商业游戏都可以用 XNA 制作,您只需要知道如何使用正确的技术,并且,如果您是为 PC 开发,您可能需要一块好的显卡。
XNA控件
与 Windows Forms 和 WPF 不同,XNA 框架缺乏“UI控件”的概念。因此,您需要自己实现 UI 控件。好消息是,我只需要两种控件:单选按钮和命令按钮。因为我想保持非常简单,所以这两种控件都只是在屏幕上渲染一个字符串。
请注意,XNARadioButton
以“[ ]”字符串开头。这是因为用户可以将其选中。一旦被选中,“[ ]”会变成“[x]”。
同样,命令按钮(XNAButton
)也只是一个字符串。当用户将鼠标移到它上面时,它会被高亮显示。
简单高效,但也许我以后会用更吸引人的控件替换它们。
音效
为了在游戏中 Sound,您必须使用 Microsoft Cross-Platform Audio Creation Tool (XACT) 插入原始的 .wav 文件。
一旦游戏构建完成,.wav 文件会自动转换为 .xnb 文件,然后 XNA 就可以播放它们了。
根据游戏中发生的事件,可以播放不同的 Sound。
虚拟键盘
当用户进入游戏时,会要求他们输入玩家姓名,然后会出现虚拟键盘。这可以通过标准的键盘按键或键按下事件来完成,但我选择创建了这个虚拟键盘,它看起来效果很好。
一件很重要的事情是,键盘由单个精灵组成。当鼠标移到某个键上时,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)选择图像区域),然后在登录屏幕中单击玩家图片框。
请注意,原始图像的左右两侧插入了垂直的黑色条。这是因为长宽比(宽度/高度)与目标图片的长宽比不同。否则,顶部和底部将插入水平黑色条。这样就保留了原始的宽高比。
这里的代码涵盖了图像如何从剪贴板复制并传输到游戏中的纹理。
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 混合”技术,使其更平滑。 |
说到这里,就到了 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 中,幻影球由球附近的白色圆圈表示。它们像台球桌上的任何其他球一样,由相同的 Ball
类实现。不同之处在于它们不会被渲染。
反射幻影球
如前所述,“绝望”模式下,只能瞄准反射幻影球。这意味着计算机有机会创造一个替代的击球,可能会产生更好的结果。
一旦瞄准了反射幻影球,主球路径就会在台球桌边界处“镜像”,因此主球不会击中反射幻影球,而是击中真正的幻影球。
下面的一段代码展示了部分实现。
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();
}
服务代理
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** 操作,该操作会向游戏参与者广播一个回调,表明其中一个伙伴已离开。
回调
正如我们在上一节中看到的,ISnooker
的 CallbackContract
是 ISnookerCallback
。这意味着,当客户端调用台球服务端的某个操作时,它也必须预期服务会使用 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 服务如何处理请求并将消息广播回游戏参与者。
出杆/进杆
如前所述,球的移动通过 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:人工智能和物理学 - 详细且带注释的解释。