Introduction
This article is intended to share some nice discoveries of writing a pool game in C#. Although my first motivation is to give readers some useful programming information, I'm also hoping you really enjoy the game itself.
Background
The game is built over three cornerstones:
- Collision detection/collision resolution: First of all, it's essential for a pool game to have a collision detection and handle it properly. When the balls are moving, they must always be confined inside the borders, and remain on the pool unless they fall into the pockets. When a ball collides with other balls or borders, you must know it, and take action to resolve the collision prior to placing the colliding ball on the screen. The collision itself is not that problematic (for example, you could just test whether two balls are closer to each other than twice their radius). The real problem is to decide where the colliding balls should be at that moment if they were real objects, and also calculate their resulting directions. I had a hard time trying to resolve collisions properly, and after giving up reinventing the wheel myself, I finally resorted to Google. Although there are many articles explaining collision resolution, I ended up using this simple and straight to the point Detection and Handling article from Matthew McDole.
- Fast and smooth graphics rendering: At first, I tried to use a timer to control the rendering process. At every tick, the positions of the balls were calculated and then the graphics were rendered. The problem is that, usually, the calculation time was different for each tick, because sometimes I had just one ball moving, while at others, I had 10 balls colliding and moving at the same time. So the calculation effort was different. This difference affected the rendering, and appeared like it was "cut" at some points. This was frustrating. If you take, for example, other snooker games, you'll notice that each shot has a fluid rendering. Then, I refactored the code by doing all calculations first, and then created a "movie" in memory containing the sequence of frames (each frame is a snapshot of the balls at some point in time, over the pool background). After all balls stopped moving, the "movie" is done and played. At first, this looks like too much effort, but for a game like this, the speed of rendering is critical. It might have been easier if I ported the game to XNA technology, but I didn't want to force CodeProject users to download additional Visual Studio packages.
- Realistic sound effects: When I finally got the graphics working, I noticed something was missing. I wanted the game to have sounds to make it more realistic and exciting. After some research, I found a few .wav files that could be useful for the cue hitting the cue ball, the balls hitting each other, and other real pool game sounds. Then, I tried playing it with the default
System.Media.SoundPlayer
object, but soon I noticed it doesn't play simultaneous sounds: whey you play a sound, all executing sounds are stopped. Fortunately, I found the wonderful IrrKlang audio engine and got the problem solved. It has a very interesting 3D audio engine, where you can define the sound and the XYZ coordinates. Just think about a first person shooter game. You are walking by a dark street, and you are hearing a soft roar coming from your right side. As you keep walking, the sound becomes louder. Walking a little more, the sound is as loud at your right ear as at your left ear. Then, the sound comes from your right side. At the end, you notice you have been followed by a treacherous monster, who was getting closer, passing from your right to your left. You can do something similar by telling the IrrKlang engine to play a "roar.wav" sound in different XYZ coordinates, considering the reference point as being the first person shooter (you). In this game, I used the 3D audio engine to play the sound according to the coordinates of the source.
The Game
Rules
The game itself is a simplified snooker game. Instead of 15 red balls, it has only 6. Each red ball grants 1 point, while the "color" balls grant from 2 to 7 points (Yellow=2, Green=3, Brown=4, Blue=5, Pink=6, Black=7).
The player must use the cue ball (white ball) to aim to pot the "ball on". The "ball on" is always alternating between a red ball and a color ball, as long as there are still red balls on the table. Once all red balls are potted, the ball on is the less valuable color ball. If the player misses the ball on, or hits another ball other than the ball on, it is a fault. If the player pots a ball other than the ball on, it is a fault. If the player fails to hit any other ball with the cue ball, it is a fault. The player will only score if there are no faults. The fault points are granted to the opponent. The game is over when all balls are potted (except for the cue ball).
int strokenBallsCount = 0;
foreach (Ball ball in strokenBalls)
{
if (strokenBallsCount == 0 && ball.Points != currentPlayer.BallOn.Points)
currentPlayer.FoulList.Add((currentPlayer.BallOn.Points < 4 ? 4 :
currentPlayer.BallOn.Points));
strokenBallsCount++;
}
if (strokenBallsCount == 0)
currentPlayer.FoulList.Add(4);
foreach (Ball ball in pottedBalls)
{
if (ball.Points == 0)
currentPlayer.FoulList.Add(4);
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)
{
wonPoints += ball.Points;
}
}
else
{
currentPlayer.FoulList.Sort();
lostPoints = currentPlayer.FoulList[currentPlayer.FoulList.Count - 1];
}
currentPlayer.Points += wonPoints;
otherPlayer.Points += lostPoints;
User Interface
There are three important areas on the screen: the pool, the score, and the cue control.
The Pool
Figure 1. Game pool displaying its many borders in yellow.
The table is a mahogany model, covered with fine blue baize. There are six pockets, one for each corner, and two more in the middle of the long sides.
When it is your turn, when you move the mouse over the table, the mouse pointer takes the form of a target (when the ball on is already selected) or a hand (when you must select a ball on). When you hit the left mouse button, the cue ball will run from its original point to the select point.
void HitBall(int x, int y)
{
ClearSequenceBackGround();
ballPositionList.Clear();
poolState = PoolState.Moving;
picTable.Cursor = Cursors.WaitCursor;
double v = 20 * (currentPlayer.Strength / 100.0);
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();
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);
double xSound = (float)(balls[0].Position.X - 300.0) / 300.0;
soundTrackList[snapShotCount] = @"Sounds\Shot01.wav" + "|" + xSound.ToString();
while (poolState == PoolState.Moving)
MoveBalls();
currentPlayer.ShotCount++;
}
The Score
The score is a vintage wooden panel that shows the two players' scores. In addition, it also shows a blinking image of the ball on.
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;
}
}
Figure 2. Me against the computer.
The Cue Control
The Cue Control is a brushed steel panel, and has two goals: to control the cue strength (the upper red line) and to control the cue ball "spin". You can use the strength bar to give a more precise shot according to the situation. And, the spin control is useful if you know how to do the "top spin" and the "back spin". The "top spin", also known as "follow", increases the cue ball velocity and gives a more open angle when the cue ball hits another ball. The "back spin", on the other hand, decreases the cue ball velocity, and moves back the cue ball the way it came after striking the object ball. This also affects the resulting angle after the hit, and usually makes the cue ball to move in a curve.
Notice: I didn't implement the "side spin", because I thought it would require too much effort and would add little to the article.
Figure 3. Strength control and spin control.
Figure 4. Spin paths.
Figure 5. Different spins in action: normal (no spin), back spin, and top spin.
public void ResolveCollision(Ball ball)
{
Vector2D delta = (position.Subtract(ball.position));
float d = delta.Lenght();
Vector2D mtd =
delta.Multiply((float)(((Ball.Radius + 1.0 + Ball.Radius + 1.0) - d) / d));
float im1 = 1f;
float im2 = 1f;
position = position.Add((mtd.Multiply(im1 / (im1 + im2))));
ball.position = ball.position.Subtract(mtd.Multiply(im2 / (im1 + im2)));
Vector2D v = (this.translateVelocity.Subtract(ball.translateVelocity));
float vn = v.Dot(mtd.Normalize());
if (vn > 0.0f)
return;
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());
this.translateVelocity = this.translateVelocity.Add(impulse.Multiply(im1));
ball.translateVelocity = ball.translateVelocity.Subtract(impulse.Multiply(im2));
}
The Movie
Figure 6. In-memory frames.
At every shot, a new "movie" is started. The application calculates all movements and makes a list of ball positions as long as there is at least one moving ball on the table. When all balls are still, the ball positions list is used to create the in-memory frames, just like the frames in a movie. When all frames are created, the movie is played, in a smooth and fast way.
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;
foreach (BallPosition ballPosition in ballPositionList)
{
if (ballPosition.SnapShot != snapShot)
{
snapShot = ballPosition.SnapShot;
whiteBitmapGraphics = whiteBitmapGraphicsList[snapShot];
}
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()
{
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]);
soundEngine.Play3D(fileName, 0, 0, (float)x);
}
currentSnapShot++;
}
Sound Engine
As I mentioned previously, the game doesn't use the System.Media.SoundPlayer
object to play sounds, because each new sound played "cuts" the current sound. This means, you can't hear the sound of a ball falling into a pocket and the sound of two balls colliding at the same time. I solved this with the IrrKlang component. In addition, I also tell the sound engine to play the sound according to the position of the source of the sound. For example, if a ball falls into the upper right pocket, you hear the sound louder at your right ear. If a ball hits another one at the lower corner of the table, you hear the sound coming from the left. There are some cool snooker sounds I found on the internet, and some of them are soft or hard depending on the velocity of the colliding balls:
Figure 7. Sound effects.
if (currentSound.Length > 0)
{
currentSound += "|0";
string fileName = currentSound.Split('|')[0];
Decimal x = -1 * Convert.ToDecimal(currentSound.Split('|')[1]);
soundEngine.Play3D(fileName, 0, 0, (float)x);
}
A.I.
The so called "Ghost balls" play an important role in the game intelligence. When the computer plays in its turn, it is instructed to look for all good "ghost balls", so that it can have more chances of success. Ghost balls are the spots close to the "ball on", that you can aim to, so that the ball should fall into a specific pocket.
private List GetGhostBalls(Ball ballOn)
{
List ghostBalls = new List();
int i = 0;
foreach (Pocket pocket in pockets)
{
double dxPocketBallOn = pocket.HotSpotX - ballOn.X;
double dyPocketBallOn = pocket.HotSpotY - ballOn.Y;
double hPocketBallOn = Math.Sqrt(dxPocketBallOn *
dxPocketBallOn + dyPocketBallOn * dyPocketBallOn);
double a = dyPocketBallOn / dxPocketBallOn;
double hBallOnGhost = (Ball.Radius - 1.0) * 2.0;
double dxBallOnGhost = hBallOnGhost * (dxPocketBallOn / hPocketBallOn);
double dyBallOnGhost = hBallOnGhost * (dyPocketBallOn / hPocketBallOn);
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);
double dxBallOnCueBall = ballOn.X - balls[0].X;
double dyBallOnCueBall = ballOn.Y - balls[0].Y;
double hBallOnCueBall = Math.Sqrt(dxBallOnCueBall *
dxBallOnCueBall + dyBallOnCueBall * dyBallOnCueBall);
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;
}
Some ghost balls may be difficult or impossible to reach, because they lie behind the object ball. These ghost balls are to be discarded by the computer:
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++;
}
The computer must then choose one among the remaining ghost balls (sometimes the computer is lucky, sometimes it is not...).
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;
}
Figure 8. Ghost Balls.
Future Releases
- Multiplayer features.
- Multi-machine features (to be defined: WCF, Remoting, Skype, etc.).
History
- 2009-11-29: First version.
- 2009-12-04: Article updated.
- 2009-12-09: Bug fixes.
- 2009-12-12: Improved A.I., bug fixes.