Hello again! I'm back with another installment of my tutorial. This time I will be getting into the most important part of the whole game; The aiming and firing of the tank's gun.
Now this is going to involve a little bit of trigonometry and a little bit of simplified physics.
If you have not studied trigonometry mathematics yet then I recommend checking out one of these fine articles as a brief primer. However this is optional as you don't really have to understand the equations so much as know what they do.
Dave's Short Trig Course
Trigonometry (Wikipedia)
Introduction to Trigonometric Functions
Also you may notice that I've changed the way I'm going over the code in this tutorial. Before I walked you through the additions. Here instead what I'll do is simply explain each part and let you see for yourself in the source files I give you how it all fits together. You'll get just as much detailed information, just without a long set of step by step instructions.
Part 3 Recap
As I've done in Part 3, I'm going to do a bit of a recap of what we did last time. Unfortunately unlike last time I don't really have too much to add.
However in our previous lesson we learned how to place our tanks in random locations. We also learned how to keep them separate from each other so that a set distance can be imposed that would ensure a decent setup for a start of a round.
Setting The Distance
When you start playing around with the distance that you can set you tanks apart by, take care as not to enter in an impossible value.
If you cared to, a fail-safe could be created that would reduce the distance set to a reasonable value considering the amount of tanks, their sizes and of course the battlefield width it's self.
You may alternatively not want to set it too low either. Something like 0 to 3 would negate the purpose of the function. Of course this is entirely up to how you wish to proceed. You may later on want to create a 'teams mode' of play, in which it may be alright to have 2 tanks of the same team initially side-by-side. This is entirely up to you and your creativity.
A Few More New Files
Before we get started with our task here, there are a couple of new files that we are going to need.
AdvancedMathUnit.pas
This new unit will help us by creating a look-up table and includes a few functions that will take care of some basic angle calculations that we will need. We'll touch on the inner-workings of this unit later.
FontUnit.pas
This unit will allow us to draw text characters to the screen so that we can display important data such as the exact power and angle of our tank's turret.
SystemFontWhite.bmp
Our font's character set. You'll notice that each character is 8 x 12. We'll be sticking this file into the 'images' folder along with the rest.
Here are the new files:
New Units - a73_UnitFiles.zip (2.27 KB)
Font Bitmap - a73_ImageFile.zip (1.13 KB)
NOTE: Make sure you have put both unit files in the Project's main folder and the image file into the images folder!
Update TTank
We are going to revisit the TTank object to add a few more things. These additions will allow us to aim the gun and control the direction of the tank's facing based on the gun's angle.
Here is our new TTank class.
Code: [View]
TTank = class(TObject) X, Y: Real; TrackSize: Cardinal; TurretX, TurretY, TurretLength: Integer; TurretEndX, TurretEndY: Real; // New! AimAngle, AimPower: Integer; // Modified Facing: Integer; // New! Color: Cardinal; Sprite: PSDL_Surface; constructor Init(oTrackSize: Cardinal; oTurretX, oTurretY, oTurretLength: Integer; oColor: Cardinal; ImageFile: String); procedure ChangeTurret(NewAngle: Integer); // New! procedure Draw(GameScreen: PSDL_Surface); end;
Facing is just the direction of the tank represented as either -1 for left or 1 for right.
ChangeTurret() is the procedure that will update the angle of the tank's aim.
AimAngle and AimPower were changed from Real to Integer in this version as I saw no real need to keep them as floating point values. Since one is in degrees and the other These will only ever be whole numbers.
Not too much added to the class definition here, but there are more modifications to the Init() and Draw() procedures we will go over as well.
Before we head on to the functions, jump to the top of the unit and add the 'AdvancedMathUnit' unit under your uses clause. It should now look like this:
Code: [View]
uses SysUtils, // JEDI-SDL sdl, sdlutils, AdvancedMathUnit, GraphicsUnit, GameConstantsUnit;
Update TTank.Init()
Here is the new Init() for our TTank class...
Code: [View]
constructor TTank.Init(oTrackSize: Cardinal; oTurretX, oTurretY, oTurretLength: Integer; oColor: Cardinal; ImageFile: String); begin X := 0; Y := 0; TrackSize := oTrackSize; TurretX := oTurretX; TurretY := oTurretY; TurretLength := oTurretLength; AimAngle := 45; // Default Value AimPower := 500; // Default Value Color := oColor; Facing := 1; // Load Sprite File Sprite := LoadImage('images/' + ImageFile, True); end;
Update TTank.Draw()
We've added a bit more to Draw() as you'll notice here...
Code: [View]
procedure TTank.Draw(GameScreen: PSDL_Surface); var SrcRect: TSDL_Rect; DestRect: TSDL_Rect; TurretEndX, TurretEndY: Real; TempFrame: PSDL_Surface; begin // Create Temp Frame for Animation TempFrame := SDL_AllocSurface(SDL_SWSURFACE, Sprite.w, Sprite.h, GameScreen.format.BitsPerPixel, 0, 0, 0, 0); SrcRect := SDLRect(0, 0, Sprite.w, Sprite.h); DestRect := SDLRect(0, 0, Sprite.w, Sprite.h); SDL_BlitSurface(Sprite, @SrcRect, TempFrame, @DestRect); // Flip Tank Body around to match tank's facing! if (Facing < 0) then SDL_FlipRectH(TempFrame, @DestRect); SDL_SetColorKey(TempFrame, (SDL_SRCCOLORKEY or SDL_RLEACCEL), PUInt32(TempFrame.pixels)^); // Draw Turret TurretEndX := RotateXDeg(TurretLength, 0, Round(AimAngle)); TurretEndY := RotateYDeg(TurretLength, 0, Round(AimAngle)); SDL_DrawLine(GameScreen, Round(X + (TurretX * Facing)), Round(GameScreen.h - 1 - Y + TurretY), Round(X + (TurretX * Facing) + TurretEndX), Round(GameScreen.h - 1 - Y + TurretY - TurretEndY), Color); // Draw Body DestRect := SDLRect(Round(X - Sprite.w / 2), Round(GameScreen.h - 1 - Y - Sprite.h), Sprite.w, Sprite.h); SDL_BlitSurface(TempFrame, @SrcRect, GameScreen, @DestRect); // Free Temp Frame SDL_FreeSurface(TempFrame); end;
Next big thing is the drawing of the turret. Remember when I told you to add 'AdvancedMathUnit' to the uses clause? Well it saved us having to get into the Trigonometry for this part.
Both RotateXDeg() and RotateYDeg() will give you the proper new X and Y of your turret's end point. This will allow you to visually see where you are aiming.
The last part is simply drawing the body of the tank as we did before, but from the temp surface that we created at the beginning of the function.
Now when you see your tank it will be facing the right direction depending on the angle you've set. Also you'll see the correct angle of the turret to give you a better depiction of where you are aiming.
Adding Aiming Controls
Now that we've gotten our TTank object updated to hold our turret information and display it, we'll move on to changing the turret angle and adjusting the power of our shot. We've already put in some input controls in, but for the purpose of controlling our tanks we have a few modifications we'll need still.
Lets start at the top of scorch2d.lpr with the uses clause.
Code: [View]
uses SysUtils, // JEDI-SDL sdl, sdlutils, // Scorch 2D AdvancedMathUnit, // Added GraphicsUnit, FontUnit, // Added GameObjectUnit, GameConstantsUnit;
Code: [View]
type // Game Modes TGameMode = (gmMainMenu, gmAiming, gmShooting, gmMenu, gmQuit); // Game Controls TGameControls = Record Up, Down, Left, Right: Boolean; FastUp, FastDown: Boolean; // Added Fire, Select, Menu: Boolean; end; var GAME_FPS: Cardinal = 30; {30} // Video Screens GameScreen: PSDL_Surface; // Main GameScreen! // Font Data GameFont: TFont; // Added // Game Data GameMode: TGameMode = gmAiming; RunClock: Integer; WhosTurn: Integer; // Added GameInput: TGameControls; // Level Data Level: TBattlefield; // Tank Data NumberOfTanks: Integer; Tanks: Array[0 .. 3] of TTank; // Shot Data Shot: TShot; // Added
TGameControls.FastUp and TGameControls.FastDown are flags for a bit of a faster rate of increase and decrease in shot power. Power will instead increase at at a rate 10 times faster than using the Up and Down keys.
GameFont is a font object that will allow you to load a bitmap font and draw text with it.
WhosTurn stores who's turn (out of the 4 tanks) it is.
TShot is the new class we'll be adding soon to make our shots with. I'll get more into it after we've finished covering aiming and controls.
Adding Aiming Controls (Continued)
With the preliminary stuff out of the way, lets move onto the main code block where we can have a look at how our new version of the game will work. Then I'll break down the changes one at a time for a closer look.
Code: [View]
begin ProgramCreate; // Create Battlefield ... // Place Tanks ... Level.SmoothenLand(1); GameMode := gmAiming; // <-- Added! RunClock := 0; repeat SDL_Delay(Get_FPS(GAME_FPS)); if (GameMode = gmAiming) then // <-- Added! doGameInput; if (GameMode = gmShooting) then // <-- Added! GameCycle; DrawScreen; until (GameMode = gmQuit); // Exit when any key is pressed! ... ProgramClose; end.
doGameInput; will only be executed when in aiming mode.
GameCycle; will only be executed when in shooting mode.
This will allow us to take input during the aiming mode and restrict this in shooting mode. Also we will only have a shot flying through the air in shooting mode and not while another tank is aiming.
So lets take a look at the new guts of the doGameInput; procedure. Warning: The new version is a bit of a beast...
Code: [View]
procedure doGameInput; var event: TSDL_Event; TurretEndX, TurretEndY: Real; begin while (SDL_PollEvent(@event) > 0) do begin case (event.type_) of SDL_KEYDOWN : case (event.key.keysym.sym) of SDLK_UP : GameInput.Up := True; SDLK_PAGEUP : GameInput.FastUp := True; SDLK_DOWN : GameInput.Down := True; SDLK_PAGEDOWN : GameInput.FastDown := True; SDLK_LEFT : GameInput.Left := True; SDLK_RIGHT : GameInput.Right := True; SDLK_SPACE, SDLK_RETURN : GameInput.Fire := True; SDLK_TAB : GameInput.Select := True; SDLK_ESCAPE : GameInput.Menu := True; end; SDL_KEYUP : case (event.key.keysym.sym) of SDLK_UP : GameInput.Up := False; SDLK_PAGEUP : GameInput.FastUp := False; SDLK_DOWN : GameInput.Down := False; SDLK_PAGEDOWN : GameInput.FastDown := False; SDLK_LEFT : GameInput.Left := False; SDLK_RIGHT : GameInput.Right := False; SDLK_SPACE, SDLK_RETURN : GameInput.Fire := False; SDLK_TAB : GameInput.Select := False; SDLK_ESCAPE : GameInput.Menu := False; end; end; end; // -- Game Key Actions -- // if (GameInput.Menu) then GameMode := gmQuit; if (GameInput.Up) then begin Tanks[WhosTurn].AimPower := Tanks[WhosTurn].AimPower + 1; if (Tanks[WhosTurn].AimPower > ShotMaxPower) then Tanks[WhosTurn].AimPower := ShotMaxPower; end; if (GameInput.FastUp) then begin Tanks[WhosTurn].AimPower := Tanks[WhosTurn].AimPower + 10; if (Tanks[WhosTurn].AimPower > ShotMaxPower) then Tanks[WhosTurn].AimPower := ShotMaxPower; end; if (GameInput.Down) then begin Tanks[WhosTurn].AimPower := Tanks[WhosTurn].AimPower - 1; if (Tanks[WhosTurn].AimPower < ShotMinPower) then Tanks[WhosTurn].AimPower := ShotMinPower; end; if (GameInput.FastDown) then begin Tanks[WhosTurn].AimPower := Tanks[WhosTurn].AimPower - 10; if (Tanks[WhosTurn].AimPower < ShotMinPower) then Tanks[WhosTurn].AimPower := ShotMinPower; end; if (GameInput.Left) then begin Tanks[WhosTurn].ChangeTurret(Tanks[WhosTurn].AimAngle + 1); end; if (GameInput.Right) then begin Tanks[WhosTurn].ChangeTurret(Tanks[WhosTurn].AimAngle - 1); end; if (GameInput.Fire) then begin Shot := TShot.Init(Tanks[WhosTurn], 10); RunClock := 0; GameMode := gmShooting; end; // -- Reset Game Keys -- // GameInput.Fire := False; GameInput.Select := False; GameInput.Menu := False; end;
TTank.ChangeTurret()
Code: [View]
procedure TTank.ChangeTurret(NewAngle: Integer); begin AimAngle := NewAngle; // Check for Aim Wrap-around if (AimAngle > 180) then AimAngle := 0; if (AimAngle < 0) then AimAngle := 180; // Check Tank Direction if (AimAngle < 90) then Facing := 1; if (AimAngle > 90) then Facing := -1; // Calculate Turret End TurretEndX := RotateXDeg(TurretLength, 0, AimAngle); TurretEndY := RotateYDeg(TurretLength, 0, AimAngle); end;
It's a nice little function to have do all this work for you. When it comes time to fire off your shot you'll not need to worry at all about where it will need to be created or at what angle it should move at, etc...
Again you'll see RotateXDeg() and RotateYDeg() getting some more use. Like before, they will give us the new values of the TurretEndX and TurretEndY. Very handy functions!
TShot Object
Now that we know whats going on inside the aiming mode functions lets have a look at the last piece of the puzzle, the TShot object.
Code: [View]
type ... TShot = class(TObject) StartX, StartY: Real; OldX, OldY: Real; X, Y, VelX, VelY: Real; Power, Angle: Real; Damage: Integer; Remove: Boolean; constructor Init(oX, oY, oPower, oAngle: Real; oDamage: Integer); overload; constructor Init(Tank: TTank; oDamage: Integer); overload; procedure Update(Level: TBattlefield; GameClock: Cardinal); procedure Draw(GameScreen: PSDL_Surface); end;
OldX and OldY will keep track of our previous position as we move the shot in our GameCycle; function.
X, Y are the current X/Y position. VelX and VelY are the movement speeds of the shot.
Power and Angle are the shot's initial Power and Angle values from the originating TTank object that fires it.
Remove is a flag to indicate that the TShot object is finished and ready to be freed from memory.
Initializing The Shot
You'll notice that we have two different Init() constructors for our TShot object. This was done with a bit of foresight towards future applications of the TShot object. One will create the shot at a specific location, while the other will create the shot based off a chosen tank's aim and power settings. This has it's obvious advantages, but lets have a more detailed look to see whats going on.
Code: [View]
constructor TShot.Init(oX, oY, oPower, oAngle: Real; oDamage: Integer); overload; begin StartX := oX; StartY := oY; X := oX; Y := oY; Power := oPower; Angle := oAngle; VelX := Power * getCOS[Round(Angle)] * ShotPrecision; VelY := Power * getSIN[Round(Angle)] * ShotPrecision; Damage := oDamage; Remove := False; end;
Now lets look at the one that references a TTank object.
Code: [View]
constructor TShot.Init(Tank: TTank; oDamage: Integer); overload; begin StartX := Tank.X + (Tank.TurretX * Tank.Facing) + Tank.TurretEndX; StartY := Tank.Y - Tank.TurretY + Tank.TurretEndY; X := StartX; Y := StartY; Power := Tank.AimPower / 10; Angle := Tank.AimAngle; VelX := Power * getCOS[Round(Angle)] * ShotPrecision; VelY := Power * getSIN[Round(Angle)] * ShotPrecision; Damage := oDamage; Remove := False; end;
Drawing The Shot
Well we're going to need to see our shots so lets do something simple just so that we can see it. You can do other things if you have a hard time seeing it or just don't like the way that I've done it.
Code: [View]
procedure TShot.Draw(GameScreen: PSDL_Surface); begin if (Round(X) >= 0) and (Round(X) < GameScreen.w) and (Round(GameScreen.h - Y) >= 0) and (Round(GameScreen.h - Y) < GameScreen.h) then SDL_PutPixel(GameScreen, Round(X), Round(GameScreen.h - Y), $ffffff); nd(X), Round(GameScreen.h - Y), $ffffff); end;
Shot Motion Cycle
Well the last thing we need to cover about the TShot object is it's motion cycle and how it will move through the air. This is where a little bit of trigonometry comes into play. You may be surprised however to know that you've already done all that math and won't actually see it in the TShot.Update; function.
Remember these 2 lines that appeared in both of the TShot.Init(); functions?
Code: [View]
VelX := Power * getCOS[Round(Angle)] * ShotPrecision; VelY := Power * getSIN[Round(Angle)] * ShotPrecision;
VelX = Power x cos Angle x Shot Precision
VelY = Power x sin Angle x Shot Precision
VelY = Power x sin Angle x Shot Precision
If you don't understand the math then don't worry. All you have to know is that it will give us the speed along the x-axis and y-axis from the power and angle values.
Knowing this, the following will make a great deal more sense.
Code: [View]
procedure TShot.Update(Level: TBattlefield); begin OldX := X; OldY := Y; X := X + VelX; Y := Y + VelY; VelY := VelY - Gravity; // Level Boundaries if (X < 0) or (X > Level.Width - 1) or (Y <= Level.LandHeight[Round(X)]) then Remove := True; end;
This function is meant to be ran once per game cycle of your game and will allow your shot to move once 'shot' from it's origin position. X and Y are incremented by the value of your already generated values of VelX and VelY.
Also to make our shots behave more realistically we'll need some sort of gravity to pull them down towards the ground. Otherwise they'll just go flying off in a straight direction. To do this we subtract the value of Gravity from VelY. This will simulate a gravitational pull downwards.
NOTE: You can actually have some fun with this later by increasing or decreasing the value of Gravity to simulate an environment such as the surface of a moon or whatever your imagination can conceive.
Now to make sure that the shot doesn't keep going when it's time to stop we will also check for it going off either side of the battlefield, going lower than the bottom of the screen or when it simply hits the dirt. The Remove flag is then turned on and we let the main game loop know that it needs to be destroyed.
Updating The Game Constants
Before we move on, lets quickly take a look at the new set of game constants.
Code: [View]
unit GameConstantsUnit; interface const ShotPrecision = 0.07; // New ShotMaxPower = 1000; // New ShotMinPower = 10; // New Gravity = 0.6 * ShotPrecision; // New LandSmoothing = 50; LandVariation = 60; LandHighest = 500; LandLowest = 1; TankPlaceGap = 3; TankMinDistance = 50; implementation end.
ShotMaxPower is the maximum value that the aiming controls will allow you to set your shot power.
ShotMinPower is the minimum value that the aiming controls will allow you to set your shot power.
Gravity is the value at which objects will be pulled down towards the ground.
The Game Cycle
Well now we are at the last part of getting shots to work, the GameCycle; procedure which is located in the main scorch2d.lpr source file.
Before we jump right into it's code, remember that this function will only run when we are in shooting mode and not in aiming mode. This is important to note because like how the fire command in the doGameInput; procedure sets the game into shooting mode, the GameCycle; procedure will be responsible for changing which player's turn it is and putting the game mode back into aiming mode.
A look back at the firing command in the doGameInput; procedure.
Code: [View]
if (GameInput.Fire) then begin Shot := TShot.Init(Tanks[WhosTurn], 10); RunClock := 0; GameMode := gmShooting; end;
Now lets have a look at the GameCycle; procedure...
Code: [View]
procedure GameCycle; begin Shot.Update(Level); if (Shot.Remove) then begin // Destroy Shot Shot.Free; // Switch back to Aiming game mode GameMode := gmAiming; // Next Tank's Turn! inc(WhosTurn); if (WhosTurn > NumberOfTanks - 1) then WhosTurn := 0; end; inc(RunClock); end;
TShot.Free; will destroy the shot object. GameMode is put into aiming mode. Then WhosTurn is changed to the next player's value.
Seeing The Results
There is one last part that I did not show you yet and thats how we display the results on screen. Luckily it's just a few lines more.
Here is the new DrawScreen; procedure.
Code: [View]
procedure DrawScreen; var i: Integer; begin Level.DrawSky(GameScreen); Level.DrawLand(GameScreen); for i := 0 to NumberOfTanks - 1 do Tanks[i].Draw(GameScreen); if (GameMode = gmShooting) then Shot.Draw(GameScreen); GameFont.DrawText(GameScreen, 10, 10, 'Tank ' + IntToStr(WhosTurn) + ' is firing!'); GameFont.DrawText(GameScreen, 10, 22, 'Power: ' + IntToStr(Round(Tanks[WhosTurn].AimPower))); GameFont.DrawText(GameScreen, 10, 34, 'Angle: ' + IntToStr(Round(Tanks[WhosTurn].AimAngle))); GameFont.DrawText(GameScreen, 10, GameScreen.h - 23, 'RunClock = ' + IntToStr(RunClock)); GameFont.DrawText(GameScreen, 10, GameScreen.h - 36, 'Facing = ' + IntToStr(Tanks[WhosTurn].Facing)); SDL_Flip(GameScreen); end;
And with that we are done.
The Source
As with all my tutorials I provide a completed copy of the source, images and compiled binaries used in the examples. Here it is... enjoy!
Download the source files here!
a73_Source.zip
End of Part 4
Another lesson done and one more to go before we can call this an actual playable game. However that doesn't mean we are done after that. Oh no, there are several other things that I we can cover beyond simply lobbing single shots at each other. All kinds of fun an interesting things such as factoring in wind, playing with the way a shot will interact with borders, more complex weapons and weapon behavior and lots more. So look forward to a bunch of those things as I continue to release tutorial after tutorial in the near future.
In 'Death & Destruction' I will polish off the basic gameplay and show how to kill tanks and create explosions that will remove some dirt.
- Jason McMillen
Pascal Game Development
Pascal Game Development
vBulletin Message