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 wonderfulIrrKlang 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).
Hide Shrink Copy Code
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;
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.
Hide Shrink Copy Code
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++;
}
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.
Hide Copy Code
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.
Hide Shrink Copy Code
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));
}
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.
Hide Shrink Copy Code
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++;
}
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.
Hide Copy Code
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);
}
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.
Hide Shrink Copy Code
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;
}
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:
Hide Copy Code
//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++;
}
The computer must then choose one among the remaining ghost balls (sometimes the computer is lucky, sometimes it is not...).
Hide Copy Code
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.).
No comments:
Post a Comment