Contents
This is my very first XNA application, even though it started with a porting from my previous Windows Forms game.
It took me about 2 months to create this XNA game, and there are so many interesting features in it that I think would render two articles. But instead, I decided to keep the article as short as possible. Besides, many of the game concepts have been already covered in the other article, so please refer to the C# Snooker article if you feel some of the content is missing.
If you are a developer, I hope the concepts explained here can be useful someday. If you are an addicted gamer, my goal here is to make you spend much of your time having fun with it.
For a better explanation, I uploaded a video to YouTube (me against the computer), so I hope it can save you time in understanding the concepts presented here:
Some people have made my life a lot easier. Special thanks to Matthew McDole for his detection collision and collision handling article. Also, the WCF part got much easier after I understood Sacha Barber's WCF / WPF Chat Application, which looks cool and amazing even after some years since it's been published.
Many thanks to my sweet 10 year old niece Ana Beatriz, who's a big fan of games and helped me test the multiplayer feature for 2 weeks.
Special thanks to my friend Rohit Dixit, who's currently working on the Gaming 123 website to host many free games, including XNA Snooker. Rohit is now developing rooms so that logged on players could connect to each other and play games.
The system requirements depend on what you want to do:
- Maybe you are a curious person who wants to download the full source code to see the implementation, and then you need the following software:
- But it could be that you just want to download the EXE to give it a try, and then download the source code later (or not), in which case, you should have:
If you are a .NET developer, XNA should be the first option when it comes to writing a game, even if you have no previous experience with the tool. As a newbie, I had prejudiced thoughts about it. But moving to XNA was far easier than I thought. It's frustrating when a technology doesn't follow your creativity, but XNA is different. When it comes to developing a game, you don't ask "is it possible to do it with XNA?" but rather "which technique should I learn to make it work with XNA?". No kidding. I would say that any top commercial game could be made with XNA, you would just need to know how to use the right techniques and, if you are developing for PC, you might need a good video card.
Unlike Windows Forms and WPF, the XNA framework lacks the concept of "UI controls". So, it's up to you to implement your own UI controls. The good news is that I just needed two types of controls: a radio button and a command button. Since I wanted to keep it very simple, both controls just render a string on the screen:
Figure 1. XNA controls (XNARadioButton and XNAButton) in the main menu
Notice that XNARadioButton
starts with a "[ ]" string. This is so because the user can make it checked. Once it's checked, the "[ ]" becomes "[x]".
Also, the command button (XNAButton
) has nothing more than a string. When the user moves the control over it, it gets highlighted.
Simple and efficient, but maybe I'll replace them later with more appealing controls.
Figure 2. XNA controls diagram
In order to use sounds in your game, you have to insert the original .wav files using the Microsoft Cross-Platform Audio Creation Tool (XACT):
Figure 3. Microsoft Cross-Platform Audio Creation Tool (XACT)
Once the game is built, the .wav files are automatically converted into .xnb files, which can then be played by XNA.
Different sounds can be played depending on the events occurring in the game:
Figure 4. Sounds played by XNA Snooker Club
When the user enters the game, he or she is asked to provide the player name, and then is presented the Visual Keyboard. This could be done using the standard keyboard's keypress or keydown events, but instead, I made this Visual Keyboard, which after all looked nice on the screen.
Figure 5. Visual Keyboard
One important thing is that the keyboard is made of a single sprite. When the mouse moves over some key, the VisualKeyboard
class identifies the key, and the main game class highlights a yellow sprite right below that key in the keyboard sprite. Also, a soft "click" sound indicates when a key has been selected.
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)
{
}
The game allows you to replace the default "No Photo Available" image. Just copy some image to the Clipboard (go to Windows Explorer, select the file, and then press Ctrl+C, or if you prefer, select an image area through some graphics application, such as Paint.Net), and then click the player picture box while in the Sign In screen.
Figure 6. Player picture, copied from Clipboard
Notice that vertical black bars where inserted at both sides of the original picture. This is so because the size ratio (width / height) is different from the size ratio of the target picture. Otherwise, horizontal black bars would be inserted at the top and at the bottom. This is how the original aspect ratio is preserved.
The code here covers how the images are copied from Clipboard and transferred to the texture in the game:
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 (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;
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)));
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();
It would be nice if the game looked as much real as possible. Despite the fact that it's a 2D game, it was possible to take advantage of some techniques to render a real-looking (or almost!) snooker game.
The balls rendering represent a vital part in this game. If you take a look at the table below, you'll see that the rendering is not made at once, but in a few steps:
| Here we have the table fabric prior to the ball. |
| Then, we must place the shadows cast by the four lights (one at each corner of the table). |
| Now, here's the ball over the background, without smoothing. Notice that the borders are a bit "pixelated". |
| Finally, we apply an "alpha blending" technique around the ball in order to make it smoother. |
Figure 7. Rendering the ball with different layers
That being said, here comes the MoveBalls
function, which is responsible for calculating the ball positions in a given instant (snapshot).
Notice that this is the heart and soul of the game, because it has to resolve collisions (between ball and ball and ball and borders), calculate ball velocity based on the current velocity and the friction coefficient, calculate the vertical spin velocity, and decide whether the balls are still moving or not.
private List<SnookerCore.BallPosition> MoveBalls()
{
List<SnookerCore.BallPosition> ballPositionList =
new List<SnookerCore.BallPosition>();
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;
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);
}
foreach (DiagonalBorder diagonalBorder in diagonalBorders)
{
if (diagonalBorder.Colliding(ballA) && !ballA.IsBallInPocket)
{
diagonalBorder.ResolveCollision(ballA);
}
}
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);
}
}
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 (ballA.IsBallInPocket)
{
ballA.TranslateVelocity = new Vector2(0, 0);
ballA.VSpinVelocity = new Vector2(0, 0);
}
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);
}
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);
}
}
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 (poolState == PoolState.MovingBalls && totalVelocity == 0)
{
if (poolState == PoolState.MovingBalls)
{
MoveBall(true);
poolState = PoolState.AwaitingShot;
}
}
ballPositionList = GetBallPositionList();
return ballPositionList;
}
One of the cool features in the game, in my opinion, is the cue movement.
|
The cue moving around the cue ball close to it (notice the cue casting shadow over the table). |
|
The cue getting away from the cue, preparing to shoot. |
The cue moves freely around the cue ball, following the same direction as the mouse pointer moves. Once the player selects the target, the cue locks in that direction, gets away from the cue for a little while, and then strikes the ball in the desired direction.
The cue also casts a shadow over the table. Besides, it also has a smooth rendering against the table background.
Imagine that you have released your game, and that it has only the multiplayer mode. Now, imagine a poor player, always asking his/her friends to a game... And the solution to this problem is...
...to implement some kind of artificial intelligence, to challenge your player's intelligence. So, even if your game is intended to have a multiplayer mode, you should also consider having a single player mode. Players love challenges. But, what if you have gone too far with your A.I., and your game got so "intelligent" to the point that your player eventually gives up your game once and for all, completely humiliated and disappointed?
The answer to that last question is: break the challenge into levels. Just like most games, there are three difficulty levels: easy, normal, and hard. The difference between them is too simple: given a number of "ghost balls", the computer randomly picks one of them and internally simulates a shot. Then it calculates the yielded result (the won points and the lost points in that shot). It's easy to beat the computer in this mode. In the case of the Easy mode, the computer has only one chance. In Normal mode, the computer generates 10 simulations and calculates which one has the best results. But, in the Hard mode, the computer simulates up to 20 shots. So, you'll have to be a very good player to beat it (please tell me later if you have succeeded...).
I must confess I never had any training on Artificial Intelligence. In fact, I don't even know if this could be called A.I., but what the computer actually does when it's playing in its turn is to randomly simulate a number of shots and decide which one is better. The crucial point is this:
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;
}
For the complete function, see the code snippet below:
private void GenerateComputerShot()
{
cueDistance = 0;
List<Ball> auxBalls = new List<Ball>();
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);
}
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;
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 (shot == null)
{
teams[playingTeamID - 1].BestShot = null;
UpdateGameState(GameState.GameOver);
}
else
{
while (poolState == PoolState.MovingBalls)
{
MoveBalls();
}
calculatingPositions = false;
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);
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++;
}
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;
}
If all attempts of the computer result in failure (that is, in lost points), then it is given a last chance. This is what I call the final "despair mode". In this mode, the computer will shoot against reflected ("or mirrored") ghost balls.
Figure 8. Ghost balls
When the computer gives a shot, it doesn't do so aimlessly. It's not every position in the pool that can be aimed at, because that would be too inefficient. This is why the computer concentrates its efforts in the "ghost balls": they are calculated as the circle that touches the object balls in only one point, so that if you draw a straight line from the center of the ghost ball to the pocket position, that line would include the point of the center of the object ball (see the above picture).
In figure 8 above, the ghost balls are represented by the white circles near the balls. They are represented by implementations of the same Ball
class, just like any other ball in the table. The difference is that they are not rendered.
As mentioned before, in "despair" mode, only the reflected ghost balls can be aimed at. This means that the computer is given a chance to create an alternative shot that might result in a better result.
Once a shot is aimed at a reflected ghost ball, the cue ball path is "mirrored" at the pool border, so instead of reaching the reflected ghost ball, the cue ball hits the real ghost ball:
Figure 9. Reflected ghost balls
The following piece of code shows part of this implementation:
foreach (Ball ballOn in ballOnList)
{
List<ball> tempGhostBalls = GetGhostBalls(ballOn, false);
if (!despair)
{
foreach (Ball ghostBall in tempGhostBalls)
{
ghostBalls.Add(ghostBall);
}
}
else
{
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);
}
The icing on the top of the Artificial Intelligence that we've just seen, is to make the computer behave a bit like a human. Usually, a human player would look at the possibilities, calculate, move the cue, calculate again, move the cue back to the last position, and only after a little while play a shot. This is exactly what the computer does in the game. In its turn, you'll see the cue moving around and around, aiming here and there, and only then shooting.
You'll see that in the Easy mode, the computer plays with carelessness. In the hard mode, on the other hand, it usually takes longer for the computer to play, because it is "thinking" of the best possibility.
Although the Multiplayer mode in XNA Snooker Club was intended to run on different machines, it's possible to run the WCF Service and two XNA clients on the same machine, like in the picture below:
Although it could be fun to play against the computer, two (human) players can also play XNA Snooker Club against each other, thanks to Windows Communication Foundation. But the clients don't connect directly to each other. Instead, they rely on a WCF service running on a different application to broadcast the messages.
It should be said that my job in doing this WCF implementation became much easier after I finished reading Sacha Barber's great article: WCF / WPF Chat Application. So, you'll find similarities between the two implementations. In Sacha's chat application, the clients establish connection by joining the same WCF service. The conversation is done while the messages are sent by clients and broadcasted by that service to the joined chatters. In XNA Snooker Club, on the other side, the clients connect to the snooker service, which in turn broadcasts the game movements, score, sounds, etc. This exchange between snooker players is also a form of conversation.
It's a very nice thing that WCF services offer a variety of hosting options: console, Windows Forms, WPF, Windows Services, IIS, self-hosting... The WCF snooker service is hosted by a console application. The good thing about console applications is its simplicity. They are easy to create and run, and very handy when you need to write messages to the output.
static void Main(string[] args)
{
String strHostName = Dns.GetHostName();
IPHostEntry iphostentry = Dns.GetHostEntry(strHostName);
int nIP = 0;
foreach (IPAddress ipaddress in iphostentry.AddressList)
{
Console.WriteLine("Server IP: #{0}: {1}", ++nIP, ipaddress);
}
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();
}
Figure 10. WCF Service running
The XNA Snooker Club VS2008 solution is separated into two application layers: the Core project and the XNA project. The WCF client resides on the Core layer. It is responsible for establishing connection with the WCF service, as well as to send and receive messages from the service.
The WCF client knows how to communicate with the WCF Service, thanks to the proxy class. This class can be generated automatically by using the svcutil command line utility, or using the Add Service Reference menu in Visual Studio 2008.
Once the proxy is created, the client can start calling the service methods. But instead, it's a best practice to apply the Service Agent pattern. A service agent is a component on the client side that wraps the proxy methods and performs additional processing in order to further separate client code from the service. In our case, the service agent is a singleton class residing on the Core project.
The WCF service class is an implementation of the ISnooker
interface:
[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();
}
First, the ServiceContract
attribute that decorates the interface defines the operations the service makes available to be invoked as requests are sent to the service from the client. The session mode is set to Required
, which indicates to the contract that a session is to be maintained (this is how a conversation is possible).
The CallbackContract
sets up the infrastructure for a duplex MEP (message exchange pattern), and this is how it allows the service to initiate sending messages (e.g., notifications about a shot that was just played by a player) to the client.
The Join operation allows the client to join a game. If the current game on the WCF side already has two players, the join operation is refused.
As a response to the Join operation, the service returns the information about the current teams and players.
When the player shoots the cue ball, the XNA application starts recording the ball position movements in a structure stored in the Shot
class. This recording goes on as long as there are moving balls on the table.
Once the player has finished a shot (that is, when all balls become still), the Play operation is invoked, so the WCF client calls the Shot
in the proxy with a Shot
parameter containing the recorded sequence of ball movements, along with sounds and scores.
In opposition to the Join
operation, when the XNA client is exiting, it calls the Leave operation, which in turn will broadcast to the game participants a callback indicating that one of the partners has left.
As we have seen in the previous section, the CallbackContract
for ISnooker
is ISnookerCallback
. This means that, when the client calls an operation on the snooker service side, it must also expect that the service calls back the XNA client with the methods defined by ISnookerCallback
:
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);
}
Notice in the figure below how a running WCF service handles the requests and broadcasts the messages back to the game participants:
Figure 11. WCF Service processing requests / responses
As mentioned before, the ball movements are sent/received to/from the service through the Shot
data contract. Notice in the class below that besides the snapshot list, there is some more information to the Shot
, such as the scores and a GameOver
field indicating whether the player has won the game just after that shot.
[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
}
The outgoing shot data is held by the outgoingShot
object (Shot
class), and is created by the XNA client every time the players prepare to shoot.
Every time there is a movement of balls on the table, the XNA client creates a new Snapshot
, which in turn holds all the ball positions on the table at a given time, along with the sounds that occur at that instant. This new Snapshot
is then added to outgoingShot
, as seen below:
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++;
}
}
In multiplayer mode, everything happening on the XNA client must be enlisted in outgoingShot
to be sent later to the other player. But, the outgoingShot
is not sent until the shot is complete, all balls are still, and both scores have been calculated, as seen below:
someFalling = ProcessSomeFalling();
if (!someFalling)
{
if (lastPoolState == PoolState.MovingBalls)
{
if (!fallenBallsProcessed)
{
ProcessFallenBalls();
foreach (Ball b in ballSprites)
{
b.DrawPosition = b.Position;
}
CreateSnapshot(GetBallPositionList());
if (rbtMultiPlayer.Checked)
{
logList.Add(string.Format("Player {0} sent {1} snapshots",
contractPerson.Name, outgoingShot.SnapshotList.Count()));
outgoingShot.CurrentTeamScore = teams[0].Points;
outgoingShot.OtherTeamScore = teams[1].Points;
SnookerServiceAgent.GetInstance().Play(contractTeam,
contractPerson, outgoingShot);
ResetOutgoingShot();
}
}
}
}
As this outgoingShot
object arrives at the WCF service, it is simply broadcasted to other game players.
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"));
}
When receiving an incoming shot, the XNA client must reproduce exactly whatever has occurred on the other player's computer. This includes the same ball positions, the same frame rate, the same scores, the same balls potted, coherent turn shifts, and coherent game over processing.
Once the XNA client has replayed the incoming shot completely, it's crucial that both XNA clients have exactly the same state. If this consistency is not achieved, both players will have problems with disparities of ball positions, wrong turn shifts, wrong scores, and so on.
The code below illustrates the vital part of processing the incoming shot:
if (contractTeam.Id != playingTeamID)
{
if (currentIncomingShot == null)
{
if (incomingShotList.Count > 0)
{
currentIncomingShot = incomingShotList[0];
incomingShotList.RemoveAt(0);
}
}
if (currentIncomingShot != null)
{
if (currentSnapshotNumber <= currentIncomingShot.SnapshotList.Length)
{
if (movingBallDelay <== 0)
{
movingBallDelay = maxMovingBallDelay;
foreach (BallPosition bp in
currentIncomingShot.SnapshotList[currentSnapshotNumber].ballPositionList)
{
ballSprites[bp.ballIndex].Position = new Vector2(bp.x, bp.y);
ballSprites[bp.ballIndex].IsBallInPocket = bp.isBallInPocket;
}
GameSound sound =
currentIncomingShot.SnapshotList[currentSnapshotNumber].sound;
if (sound != GameSound.None)
{
soundBank.PlayCue(sound.ToString());
}
currentSnapshotNumber++;
}
}
else
{
teams[0].Points = currentIncomingShot.CurrentTeamScore;
teams[1].Points = currentIncomingShot.OtherTeamScore;
if (currentIncomingShot.GameOver)
{
UpdateGameState(GameState.GameOver);
}
else if (currentIncomingShot.HasFinishedTurn)
{
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));
teams[playingTeamID - 1].BallOn = GetRandomRedBall();
if (teams[playingTeamID - 1].BallOn == null)
teams[playingTeamID - 1].BallOn = GetMinColouredball();
}
currentSnapshotNumber = 0;
currentIncomingShot = null;
}
}
}
If you had patience to reach this line, I'd like to thank you. Please download the application and evaluate it. And, whether you like it or not, please don't forget to leave your comments at the end of the article, I'll appreciate it. It was a lot of fun to write and to test it. I wish you lots of fun too.
History
- 2010-02-05: First version.
- 2010-02-06: YouTube image added.
- 2010-02-20: Incoming/Outgoing shots - detailed and commented explanation.
- 2010-03-06: A.I. and physics - detailed and commented explanation.