Gameplay
The hour is late. The skies grow dark; the people grow restless. Brother pitted against brother. A nation at war. The rise of a hero. Also, you'll be programming a game now.
It's pew pew time
To be precise, you'll be making a space shooter: this is good for a first game, because the movement of the objects - spaceships, aliens, bullets et al - can be pretty simple to program.
We'll be making a game that's relatively easy to build on. So - if you want to make it ludicrously complex once we've finished - you can go right ahead, provided you've not flipped your desk by then.
Pong and Tetris are also good newbie-programmer games for the same reasons (provided you're not planning on a battle royale).
Achtung!
TLDR: this part of the tutorial boils down to repeatedly adding units to a program, then reading our discussion of that unit code. Skip ahead if you don't care why and you know what and how
UNIT
s are used.
Just so you're aware, things are about to kick up a gear. We're going to be using everything we've learnt so far, in addition to a fair amount of new stuff.
Previously, we've talked through the process of building up our programs, examining the obstacles as we go. However, this time, we'll be ending up with just shy of 1,000 lines of code.
With that much code, asking you to add a line here and there would be a long, difficult-to-debug process. So, to keep things simple, I've divided the program in several UNIT
s (as in any complex program), explaining each one as we go.
Whait, what's a UNIT
?
Until now all example sources were in a single file, the PROGRAM
, but Pascal allows you to divide your program in smaller pieces called UNIT
so you can organize your code more easy. Also this allows you to reuse the same UNIT
in different programs.
Let see one simple example:
UNIT MyUnit;
(* A simple example UNIT. *)
INTERFACE
PROCEDURE SayHello;
IMPLEMENTATION
FUNCTION AskName: STRING;
VAR
Name: STRING;
BEGIN
Write ('What''s your name?');
ReadLn (Name);
EXIT (Name)
END;
PROCEDURE SayHello;
VAR
UserName: STRING;
BEGIN
UserName := AskName;
WriteLn ('Hello, ', UserName, '!')
END;
END.
It is very simple.
UNIT MyUnit;
That tells the compiler the unit name. To make things easy, save the unit with the name myunit.pas.
INTERFACE
PROCEDURE SayHello;
The interface tells the compiler what parts of the unit can be used (called) from other parts of the program; this is the public part of the unit.
IMPLEMENTATION
FUNCTION AskName: STRING;
VAR
Name: STRING;
BEGIN
Write ('What''s your name?');
ReadLn (Name);
EXIT (Name)
END;
PROCEDURE SayHello;
VAR
UserName: STRING;
BEGIN
UserName := AskName;
WriteLn ('Hello, ', UserName, '!')
END;
As name indicates, this is what the unit actually does. Here we have a FUNCTION
and a PROCEDURE
.
And how we can use this? Simple:
PROGRAM UsingUnit;
(* How to use a unit. *)
USES
MyUnit;
BEGIN
SayHello
END.
First, the USES
section says that we want to use MyUnit
; see that it doesn't need the .pas extension. Then we call SayHello
.
If you save the previous program in the same directory than the unit as usingunit.pas you can compile with Free Pascal just executing:
fpc usingunit.pas
The compiler will be smart enough to find the units in the same directory!
Also note that we can't call AskName
from the PROGRAM
; try and see what happens. That's because it wasn't declared in the INTERFACE
section. This allows to keep parts of the units private.
How we'll do this
So, to make things easy download this ZIP that contains the whole project. As we progress, open appropriate file and read our walkthrough of that code.
Let's get started. First: the fun stuff.
Assets
Here's some assorted space-related imagery. Curiously, it's all packed into one image; more on that shortly. It should be in the same directory than the executable, making sure it's named spritesheet.png.
Next up, some sound effects that absolutely, definitely have never been used in any game before. Again, they should be in the same directory, making sure they're named as shown:
You'll notice that these are FLAC files. Generally, FLAC is a good format for short samples of the pew-pew or kaboom persuasion, because they're smaller (but of the same quality) as WAVs. There's info aplenty on their website if you're interested in why.
If you want to listen to those sound effects now, VLC Player will do the trick.
General stuff
Time for some global variables and helper functions. Open the general.pas unit. Other than the usual MustInit
, this is where things start getting interesting.
Ubiquitous variables
INTERFACE
VAR
(* Frame count. *)
Frames: LONGINT;
We're interested in the number of frames we've rendered so far. More on this later.
Randomness
(* Random generator. *)
FUNCTION IntegerBetween (CONST Lower, Higher: LONGINT): LONGINT;
BEGIN
IntegerBetween := Lower + Random (Higher - Lower)
END;
FUNCTION FloatBetween (CONST Lower, Higher: REAL): REAL;
BEGIN
FloatBetween :=
IntegerBetween (TRUNC (Lower * 10000), TRUNC (Higher * 10000)) / 10000
END;
We've added a cheeky couple of functions here to generate random numbers within a range (Lower and Higher).
As you may be aware, most games rely on a degree of randomness. Ours will be no different.
Display stuff
Let's add some code to set up and use an ALLEGRO_DISPLAY
. Open display.pas.
Back near the beginning of this tutorial, we mentioned that our program's window is pretty small - especially if you're using a retina display - and that we'd sort this out later.
We've finally dealt with this problem in the above code. It's now possible to scale up the display - giving us a nice pixelated look.
How it works
We start by defining some kooky-lookin' constants:
CONST
(* Graphics buffer size. *)
BUFFER_W = 320; BUFFER_H = 240;
(* Display size. *)
DISPLAY_SCALE = 3;
DISPLAY_W = BUFFER_W * DISPLAY_SCALE; DISPLAY_H = BUFFER_H * DISPLAY_SCALE;
The BUFFER
we're referring to here has a relatively small size: 320x240. Regardless of the size of the player's screen, this is the size of the playfield we want to present to them:
Next, we define our DISPLAY's size as a multiple of BUFFER_W and BUFFER_H. We then initialize both the display and the buffer in our Initialize
function:
VAR
fDisplay: ALLEGRO_DISPLAYptr;
fBuffer: ALLEGRO_BITMAPptr;
(* Initializes display. *)
PROCEDURE Initialize;
BEGIN
{ Create window. }
al_set_new_display_option(ALLEGRO_SAMPLE_BUFFERS, 1, ALLEGRO_SUGGEST);
al_set_new_display_option(ALLEGRO_SAMPLES, 8, ALLEGRO_SUGGEST);
fDisplay := al_create_display (DISPLAY_W, DISPLAY_H);
MustInit (Assigned (fDisplay), 'Display');
fBuffer := al_create_bitmap (BUFFER_W, BUFFER_H);
MustInit (Assigned (fBuffer), 'bitmap buffer')
END;
...
(* Release resources. *)
PROCEDURE Deinitialize;
BEGIN
al_destroy_bitmap (fBuffer);
al_destroy_display (fDisplay)
END;
See that both variables are insid the IMPLEMENTATION
section, so they can't be used outside the unit. This way we can protect the internals of a unit. That's also why we added GetEventSource
function as we'll need it later.
The last time we created an ALLEGRO_BITMAP
, we used the image addon. This time, not so! We've just gone ahead and created one with nothing in particular on it. Yet.
And, like the lawful neutrals we are, we've also added a Deinitialize
function to destroy these before the program ends.
Now comes the kicker:
(* Prepares the output. *)
PROCEDURE PreDraw;
BEGIN
al_set_target_bitmap (fBuffer);
al_clear_to_color (al_map_rgb (0,0,0))
END;
(* Finalizes the output and swap buffers. *)
PROCEDURE PostDraw;
BEGIN
{ Scales backbuffer. }
al_set_target_backbuffer (fDisplay);
al_draw_scaled_bitmap (
fBuffer,
0, 0, BUFFER_W, BUFFER_H,
0, 0, DISPLAY_W, DISPLAY_H,
0
);
{ Show frame. }
al_flip_display
END;
We're going to call these functions before and after we draw all of our game's graphics. Here's what's going on:
- al_set_target_bitmap tells Allegro that we want to draw to our buffer rather than to the screen. Yes, this is something you can do.
- Later on, al_set_target_backbuffer tells Allegro that we want to draw to the screen again.
- We then use al_draw_scaled_bitmap to scale up our small buffer to fill the screen.
- We then al_flip_display as per usual.
Show me!
But of course. We've set our DISPLAY_SCALE to 3, meaning our 320x240 buffer will be scaled up - in all of its chunky, pixelated glory - to fill a 960x720 window:
Why do we need buffer?
The simple answer is: you can't draw a bitmap to itself.
In we could do that, we could create an ALLEGRO_DISPLAY
of any size we liked, draw our playfield in the top-left corner, then scale-up the display's backbuffer onto itself.
Unfortunately, this isn't possible, and as a result, you've had to sit through this explanation. Sad!
Keyboard stuff
Take a look to the keyboard.pas unit:
CONST
(* Key statuses. *)
KEY_SEEN = 1;
KEY_RELEASED = 2;
VAR
Key: ARRAY [0..ALLEGRO_KEY_MAX] OF BYTE;
...
(* Initialization. *)
PROCEDURE Initialize;
VAR
Ndx: INTEGER;
BEGIN
MustInit (al_install_keyboard, 'keyboard');
FOR Ndx := LOW (Key) TO HIGH (Key) DO Key[Ndx] := 0
END;
(* Handles keyboard events. *)
PROCEDURE HandleEvent (CONST aEvent: ALLEGRO_EVENT);
VAR
Ndx: INTEGER;
BEGIN
CASE aEvent.ftype OF
ALLEGRO_EVENT_TIMER:
FOR Ndx := LOW (Key) TO HIGH (Key) DO Key[Ndx] := Key[Ndx] AND KEY_SEEN;
ALLEGRO_EVENT_KEY_DOWN:
Key[aEvent.keyboard.keycode] := KEY_SEEN OR KEY_RELEASED;
ALLEGRO_EVENT_KEY_UP:
Key[aEvent.keyboard.keycode] :=
Key[aEvent.keyboard.keycode] AND KEY_RELEASED;
END
END;
A relief for us both, as there's basically nothing new here:
- We've used the 'right way' of doing keyboard input, as discussed earlier.
- We'll be calling
HandleEvent
once per game logic loop to make sure the keys are updated.
Come out, ye sprites!
Remember that spritesheet.png above? Time to put it to good use. Be warned, this is a big'un:
INTERFACE
USES
allegro5;
CONST
(* Sprite sizes. *)
SHIP_W = 12; SHIP_H = 13;
SHIP_SHOT_W = 2; SHIP_SHOT_H = 9;
LIFE_W = 6; LIFE_H = 6;
ALIEN_W: ARRAY [0..2] OF INTEGER = (14, 13, 45);
ALIEN_H: ARRAY [0..2] OF INTEGER = (9, 10, 27);
ALIEN_BUG_W = 14; ALIEN_BUG_H = 9; { ALIEN[0] }
ALIEN_ARROW_W = 13; ALIEN_ARROW_H = 10; { ALIEN[1] }
ALIEN_THICCBOI_W = 45; ALIEN_THICCBOI_H = 27; { ALIEN[2] }
ALIEN_SHOT_W = 4; ALIEN_SHOT_H = 4;
(* Animation lengths. *)
EXPLOSION_FRAMES = 4;
SPARK_FRAMES = 3;
VAR
(* Reference to sprite graphics. Use them as if they were read-only. *)
Ship: ALLEGRO_BITMAPptr;
ShipShot: ARRAY [0..1] OF ALLEGRO_BITMAPptr;
Life: ALLEGRO_BITMAPptr;
Alien: ARRAY [0..2] OF ALLEGRO_BITMAPptr;
AlienShot: ALLEGRO_BITMAPptr;
Explosion: ARRAY [0..EXPLOSION_FRAMES - 1] OF ALLEGRO_BITMAPptr;
Sparks: ARRAY [0..SPARK_FRAMES - 1] OF ALLEGRO_BITMAPptr;
PowerUp: ARRAY [0..3] OF ALLEGRO_BITMAPptr;
...
IMPLEMENTATION
VAR
fSheet: ALLEGRO_BITMAPptr;
(* Loads and initializes sprite graphics. *)
PROCEDURE Initialize;
FUNCTION SpriteGrab (CONST x, y, w, h: INTEGER): ALLEGRO_BITMAPptr;
BEGIN
SpriteGrab := al_create_sub_bitmap (fSheet, x, y, w, h);
MustInit (Assigned (SpriteGrab), 'sprite grab')
END;
BEGIN
MustInit (al_init_image_addon, 'image');
fSheet := al_load_bitmap ('spritesheet.png');
MustInit (Assigned (fSheet), 'spritesheet');
Ship := SpriteGrab (0, 0, SHIP_W, SHIP_H);
Life := SpriteGrab (0, 14, LIFE_W, LIFE_H);
ShipShot[0] := SpriteGrab (13, 0, SHIP_SHOT_W, SHIP_SHOT_H);
ShipShot[1] := SpriteGrab (16, 0, SHIP_SHOT_W, SHIP_SHOT_H);
Alien[0] := SpriteGrab (19, 0, ALIEN_BUG_W, ALIEN_BUG_H);
Alien[1] := SpriteGrab (19, 10, ALIEN_ARROW_W, ALIEN_ARROW_H);
Alien[2] := SpriteGrab ( 0, 21, ALIEN_THICCBOI_W, ALIEN_THICCBOI_H);
AlienShot := SpriteGrab (13, 10, ALIEN_SHOT_W, ALIEN_SHOT_H);
Explosion[0] := SpriteGrab (33, 10, 9, 9);
Explosion[1] := SpriteGrab (43, 9, 11, 11);
Explosion[2] := SpriteGrab (46, 21, 17, 18);
Explosion[3] := SpriteGrab (46, 40, 17, 17);
Sparks[0] := SpriteGrab (34, 0, 10, 8);
Sparks[1] := SpriteGrab (45, 0, 7, 8);
Sparks[2] := SpriteGrab (54, 0, 9, 8);
Powerup[0] := SpriteGrab (0, 49, 9, 12);
Powerup[1] := SpriteGrab (10, 49, 9, 12);
Powerup[2] := SpriteGrab (20, 49, 9, 12);
powerup[3] := SpriteGrab (30, 49, 9, 12)
END;
(* Releases resources. *)
PROCEDURE Deinitialize;
PROCEDURE ReleaseArray (SprArray: ARRAY OF ALLEGRO_BITMAPptr);
VAR
Ndx: INTEGER;
BEGIN
FOR Ndx := LOW (SprArray) TO HIGH (SprArray) DO
al_destroy_bitmap (SprArray[Ndx])
END;
BEGIN
al_destroy_bitmap (Ship);
ReleaseArray (ShipShot);
ReleaseArray (Alien);
al_destroy_bitmap (AlienShot);
ReleaseArray (Explosion);
ReleaseArray (Sparks);
ReleaseArray (PowerUp);
al_destroy_bitmap (fSheet)
END;
For those unaware, sprites are small bitmaps that are often loaded from a spritesheet - a larger bitmap that has lots of smaller bitmaps - sub-bitmaps, if you will - packed into it. Hence, our spritesheet.png
.
The vast majority of graphics drawn by older consoles - not least the NES and Sega Genesis - are sprites. And, believe it or not, using spritesheets is *still* far more efficient than loading in graphics from separate image files - even in the Fortnite age.
Again, we could go into a huge amount of discussion about this - but we're going to leave it there.
Anyway, that explanation aside, the above isn't as complicated as you might think:
- All of the
ALLEGRO_BITMAP
s are in theINTERFACE
section so everyone can use them. - At the start of our program, we'll call
Initialization
. This first loads fSheet, wich is in theIMPLEMENTATION
section, so only theSprites
unit can use it. - It then uses the local function
SpriteGrab
, which dices up fSheet, pulling the individual sprites into their separate bitmaps. For example:Explosion[0] := SpriteGrab (33, 10, 9, 9);
Here, we're grabbing Explosion[0] from our spritesheet. 33, 10 are the coordinates of the explosion sprite's top-left pixel, and its size is 9x9. You can check this by opening spritesheet.png in an image editor and drawing a box from 33, 10 to 42, 19.
- There's a
Deinitialize
procedure to destroy all of the bitmaps when the program ends too.
Collision detection
Apologies in advance for what you're about to read.
(* Tests if two rectangles collide. *)
FUNCTION Collide (CONST ax1, ay1, ax2, ay2, bx1, by1, bx2, by2: INTEGER)
: BOOLEAN;
BEGIN
IF (ax1 > bx2) OR (ax2 < bx1) OR (ay1 > by2) OR (ay2 < by1) THEN
EXIT (FALSE)
ELSE
EXIT (TRUE)
END;
If you've gone anywhere near games development before, you'll likely know well of the nightmare that is collision detection.
In simple terms, this is how we know whether the things in our game - the player, the enemies, the bullets - have hit each other. If we didn't do this, nobody would be able to kill anybody else. How awful!
There are plenty of ways to do collision detection, but in the interests of not driving you completely mad, we're going with the simplest method: bounding boxes.
How does this work, you ask? Well, imagine we want to check whether two objects - A and B - are hitting each other. We give each object a box, and then - in Collide
- we do a nifty bit of math to check whether the boxes overlap:
In the example on the left, Collide
would return FALSE
; on the right, it'd return TRUE
.
For the moment, we'll leave it at that. Bottom line: Collide
returns TRUE
when things overlap.
Audio stuff
We don't have much to do here other than load (and later destroy) the sound effects (audio.pas):
VAR
(* Audio samples. Use them as if they were read-only. *)
SampleShot: ALLEGRO_SAMPLEptr;
SampleExplode: ARRAY [0..1] OF ALLEGRO_SAMPLEptr;
...
(* Initializes. *)
PROCEDURE Initialize;
BEGIN
MustInit (al_install_audio, 'audio');
MustInit (al_init_acodec_addon, 'audio codecs');
MustInit (al_reserve_samples (128), 'reserve samples');
SampleShot := al_load_sample ('shot.flac');
MustInit (Assigned (SampleShot), 'shot sample');
SampleExplode[0] := al_load_sample ('explode1.flac');
MustInit (Assigned (SampleExplode[0]), 'explode[0] sample');
SampleExplode[1] := al_load_sample ('explode2.flac');
MustInit (Assigned (SampleExplode[1]), 'explode[1] sample')
END;
PROCEDURE Deinitialize;
BEGIN
al_destroy_sample (SampleShot);
al_destroy_sample (SampleExplode[0]);
al_destroy_sample (SampleExplode[1])
END;
You might be seeing a pattern emerge now: we define a bunch of variables, maybe add some constants, write an Initialization
function (and sometimes a Deinitialize
too), and then anything else we might need to call while we're playing the game.
That said, none of the code we've added so far has actually drawn anything to the screen! This is frankly ridiculous and must be remedied.
Effects, FX, and explosion-related stuff
Let's see the particles.pas unit now.
As Adam Sandler once quipped in a poor Israeli accent, it is time to make the bang boom. We begin by defining a RECORD
that stores everything we could need to know about our bangs/booms:
CONST
(* How many particles. *)
MAX_PARTICLES = 128;
...
TYPE
(* Stores particle. *)
TParticle = RECORD
(* Position. *)
X, Y: INTEGER;
(* Image. *)
Frame: INTEGER;
(* State. *)
Spark, Used: BOOLEAN;
END;
VAR
{ Particles. }
fParticle: ARRAY [1..MAX_PARTICLES] OF TParticle;
We've got 128 of these bad bois ready to go in our fParticle array. In Initialize
, we then mark all of them as not Used:
(* Initializes. *)
PROCEDURE Initialize;
VAR
Cnt: INTEGER;
BEGIN
FOR Cnt := 1 TO MAX_PARTICLES DO fParticle[Cnt].Used := FALSE
END;
That's all well and good, but what if you do want explosions on screen?
Spawning the effects
(* Adds a particle. *)
PROCEDURE Add (CONST aSpark: BOOLEAN; CONST aX, aY: INTEGER);
VAR
Cnt: INTEGER;
BEGIN
{ Sound effect, if needed. }
IF NOT aSpark THEN
al_play_sample (
SampleExplode[IntegerBetween (0, 2)],
0.75, 0, 1, ALLEGRO_PLAYMODE_ONCE,
NIL
);
{ Add particle. }
FOR Cnt := 1 TO MAX_PARTICLES DO
IF NOT fParticle[Cnt].Used THEN
BEGIN
fParticle[Cnt].x := aX;
fParticle[Cnt].y := aY;
fParticle[Cnt].Frame := 0;
fParticle[Cnt].Spark := aSpark;
fParticle[Cnt].Used := TRUE;
EXIT
END
END;
Firstly, we should talk about aSpark. We want to show two types of effects: sparks and explosions. Sparks are smaller and appear when shots hit their target; explosions are bigger and indicate that something has actually blown up.
We've already got sparks and explosions loaded into our Sprites unit, ready to be animated:
So, in Add
, we're looping through the fParticle array until we find one that isn't Used. Once we do, we set it up with the given coordinates, and whether it's to be a Spark or not.
In the case of explosions, we also take the opportunity to play either explode1.flac or explode2.flac.
Cycling through our frames
(* Updates particles. *)
PROCEDURE Update;
VAR
Ndx: INTEGER;
BEGIN
FOR Ndx := 1 TO MAX_PARTICLES DO
IF fParticle[Ndx].Used THEN
BEGIN
INC (fParticle[Ndx].Frame);
IF (NOT fParticle[Ndx].Spark
AND (fParticle[Ndx].Frame = EXPLOSION_FRAMES * 2))
OR ( fParticle[Ndx].Spark
AND (fParticle[Ndx].Frame = SPARK_FRAMES * 2))
THEN
fParticle[Ndx].Used := FALSE
END
END;
fx_update
is to run every frame, and makes sure that the Frame variables of Used effects are incremented. Once the effect has run its course, it is returned to being un-used.
Note that we defined EXPLOSION_FRAMES and SPARKS_FRAMES back when we dealt with our sprites - and on that subject...
Drawing them
(* Draw particles. *)
PROCEDURE Draw;
VAR
Ndx, lFrame, lX, lY: INTEGER;
Bmp: ALLEGRO_BITMAPptr;
BEGIN
FOR Ndx := 1 TO MAX_PARTICLES DO
IF fParticle[Ndx].Used THEN
BEGIN
Draw
loops through the fParticle
array yet again - but this time, with the intent of drawing Used effects to the screen.
Firstly - if you happened to wonder why, in Update
, we doubled the number of frames of explosions and sparks - here's your answer:
lFrame := fParticle[Ndx].Frame DIV 2;
We're actually cutting Frame in half. Why? Well, this is purely out of laziness; otherwise, we'd have to have twice as many frames in our spritesheet for our effects. I'm afraid that's all there is to it: your wiki authors are lazy.
We could draw the 3 or 4 frames we have without the DIV 2
, but the effect would disappear pretty quickly. So, there you have it: this is the programming equivalent of polyfiller.
Next, we pick the ALLEGRO_BITMAP
to draw (depending on Spark and Frame), and finally get to the business of calling al_draw_bitmap
:
IF fParticle[Ndx].Spark THEN
Bmp := Sprites.Sparks[lFrame]
ELSE
Bmp := Sprites.Explosion[lFrame];
{ Calculate position. }
lX := fParticle[Ndx].x - (al_get_bitmap_width (Bmp) DIV 2);
lY := fParticle[Ndx].y - (al_get_bitmap_height (Bmp) DIV 2);
{ Draw. }
al_draw_bitmap (Bmp, lX, lY, 0)
You'll notice that we're centering the bitmap on x and y here; this is because the size of the effect's bitmap varies depending on the frame, so it's easier to just say x and y are the center of the effect.
We mentioned earlier that a pattern was emerging with our code. From this point on, that pattern is going to get even more rigid.
For the rest of the objects in the game, we're going to be doing very similar things to what we've just done:
- Define a
RECORD
, likeTParticle
. - Instantiate a big private array - like our 128
TParticle
structs above. - Write an
Initialize
function, which sets everything up for the start of the game. - Write an
Add
function, which finds a space in the array for a new object. - Write an
Update
function to be called once per frame, updating every object we've added. - Write a
Draw
function to draw all of the objects we've added.
Confused? Pretend you're not! Let's continue.
Pew-pew actualization
Time to add some code for shots, because space shooters involve shooting. Load unit shots.pas
We'll start with a quick look at our TShot = RECORD
. It's pretty similar to TParticle
:
TYPE
(* Defines a shot. *)
TShot = RECORD
(* Position. *)
X, Y: INTEGER;
(* Motion. *)
dX, dY: INTEGER;
(* Current frame. *)
Frame: INTEGER;
(* Who shooted. *)
Ship: BOOLEAN;
(* Is used. *)
Used: BOOLEAN;
END;
VAR
(* Shots. *)
fShot: ARRAY [1..MAX_SHOTS] OF TShot;
...although there are a few additions, and no spark
:
- You might remember the dX / dY idiom from earlier in the tutorial, when we bounced assorted things around the screen; these variables hold the velocity of the shot.
- If Ship is true, the player's ship fired this shot. Otherwise, it belonged to an alien.
Next up, some choice cuts from Add
, which is predictably called when the player or an alien wants to shoot:
FUNCTION Add (CONST aShip, aStraight: BOOLEAN; CONST aX, aY: INTEGER)
: BOOLEAN;
...
BEGIN
{ Sound effect. }
IF aShip THEN SndEffect := 1 ELSE SndEffect := FloatBetween (1.5, 1.6);
al_play_sample (
SampleShot,
0.3, 0, SndEffect, ALLEGRO_PLAYMODE_ONCE,
NIL
);
shot.flac is played in all its bloopy glory here, but is tweaked depending on the context: alien shots crank up the speed of the sample, making them sound higher-pitched.
What's that aStraight function argument, I hear you cry? Well, as you'll see later on, we want some of our aliens to shoot straight ahead, while others are capable of sending shots anywhere. Its value makes no difference to player shots, which always fly straight ahead.
While positioning the shot, we therefore use this to set its dX and dY:
IF aStraight THEN
BEGIN
fShot[Cnt].dX := 0;
fShot[Cnt].dY := 2
END
ELSE BEGIN
fShot[Cnt].dX := IntegerBetween (-2, 2);
fShot[Cnt].dY := IntegerBetween (-2, 2)
END;
There's a small chance that - with these calls to IntegerBetween
- we'll create a shot with dx := 0
and dy := 0
. In this case, we just give up:
{ If the shot has no speed, don't bother. }
IF (fShot[Cnt].dX OR fShot[Cnt].dY) = 0 THEN EXIT (TRUE);
...and that's it for notable stuff in Add
.
Onto Update
- what happens to a live shot on every frame? Well, that depends whether it's the player's or not:
IF fShot[Ndx].Ship THEN
BEGIN
DEC (fShot[Ndx].y, 5);
IF fShot[Ndx].y < -SHIP_SHOT_H THEN
fShot[Ndx].Used := FALSE
END
Those of an eagle-eyed persuasion will have noticed that, back in Add
, we didn't bother setting dX and dY for shots from the player's ship; this is because they always move at the same speed (upwards at 5 pixels per frame).
Once shots are completely out-of-view, they're removed from play. As player shots only travel upwards, this is a bit easier to detect than with alien shots, which we'll look at next:
ELSE BEGIN { Alien. }
INC (fShot[Ndx].x, fShot[Ndx].dX);
INC (fShot[Ndx].y, fShot[Ndx].dY);
IF (-ALIEN_SHOT_W > fShot[Ndx].x) OR (fShot[Ndx].x > BUFFER_W)
OR (-ALIEN_SHOT_H > fShot[Ndx].y) OR (fShot[Ndx].y > BUFFER_H)
THEN
fShot[Ndx].Used := FALSE
END;
Nothing surprising in how these are moved, then: we add the shot's velocity to its position. Likewise, because they can move in any direction, there's >
ing to be done, so to speak.
Clash of the pew-pew titans
Next, we need a way of checking whether any shot has hit something. You'll recall the fun we had with `collide()` earlier on - time to give it a go:
(* Check collisions with shots. *)
FUNCTION Collide (CONST aShip: BOOLEAN; CONST x, y, w, h: INTEGER): BOOLEAN;
VAR
Ndx, sw, sh: INTEGER;
BEGIN
{ Shoot size. }
IF aShip THEN
BEGIN
sw := ALIEN_SHOT_W; sh := ALIEN_SHOT_H
END
ELSE BEGIN
sw := SHIP_SHOT_W; sh := SHIP_SHOT_H
END;
{ Check collisions. }
FOR Ndx := 1 TO MAX_SHOTS DO
IF fShot[Ndx].Used
{ Don't collide with one's own shots. }
AND (fShot[Ndx].Ship <> aShip)
THEN BEGIN
IF Sprites.Collide (
x, y, x + w, y + h,
fShot[Ndx].x, fShot[Ndx].y, fShot[Ndx].x + sw, fShot[Ndx].y + sh
) THEN
BEGIN
Particles.Add (
TRUE,
fShot[Ndx].x + (sw DIV 2), fShot[Ndx].y + (sh DIV 2)
);
fShot[Ndx].Used := FALSE;
EXIT (TRUE)
END
END;
EXIT (FALSE)
END;
Despite our earlier protestations, this function isn't the most terrible of the bunch:
- The x, y, w and h arguments refer to the box around the object we're checking for collision with any shots.
- First, we make sure the player's shots don't collide with their own ship (and vice versa for aliens).
- Then we run
Sprites.Collide
(after figuring out the size of the box around this shot). Note how we preceded the function name withSprites.
, this is to tell the compiler we want theCollide
function from theSprites
unit, not theCollide
function from theShots
unit. This is similar to the namespace used by other languages. - If
Sprites.Collide
returnsTRUE
, we've got stuff to do: A call to our old friendParticles.Add
to make some sparks; Removal of the shot from play; AnEXIT (TRUE)
, so that whatever calledShots.Collide
knows some damage has been done.
You'll see Shots.Collide
called twice later on. 10 points for each correct guess as to where.
A quick draw
Lastly we have Draw
, which is mostly trivial at this point: it just draws the bitmap for every active shot. There is one thing to note when drawing alien shots, though:
IF lFrame <> 0 THEN
Tint := al_map_rgb_f (1, 1, 1)
ELSE
Tint := al_map_rgb_f (0.5, 0.5, 0.5);
al_draw_tinted_bitmap (
Sprites.AlienShot,
Tint, fShot[Ndx].x, fShot[Ndx].y,
0
)
For the player's shots, we use the lFrame variable to choose a bitmap (either ShipShot[0] or [1]). In the case of aliens, though, there's only one: AlienShot.
To give this some pizzazz, then, we flash it in and out with al_draw_tinted_bitmap.
As we cover more and more of the same patterns here, we hope you'll forgive the increased pace; we anticipate that readers' desks are beginning to be flipped at this point. Remember to grab a coffee / beer at some point. Deodorant is good too.
Otherwise, let's push on. Next up, we give the protagonist so well-needed attention.
Ready Player 1
It's time for player.pas. It is different from our Particles
and Shots
, because there's only one ship - so no looping needed. Let's have a look at how things start out:
TYPE
(* Defines the player ship. *)
TShip = RECORD
(* Position. *)
x, y: INTEGER;
(* Lives. *)
Lives: INTEGER;
(* Statuses. *)
ShotTimer, RespawnTimer, InvincibleTimer: INTEGER;
END;
VAR
fShip: TShip;
(* Initializes. *)
PROCEDURE Initialize;
BEGIN
fShip.x := (BUFFER_W DIV 2) - (SHIP_W DIV 2);
fShip.y := (BUFFER_H DIV 2) - (SHIP_H DIV 2);
fShip.ShotTimer := 0;
fShip.Lives := 3;
fShip.RespawnTimer := 0;
fShip.InvincibleTimer := 120
END;
So, they get 3 lives, are centred on the screen, and are invincible - at least for a couple of seconds. These variables (and the Score in the INTERFACE
section) might be relatively self-explanatory at this point, but we'll see how they work shortly.
Now let's head onto Update
:
(* Updates ship. *)
PROCEDURE Update;
VAR
lX, lY: INTEGER;
BEGIN
{ Check ship is ready. }
IF fShip.Lives < 0 THEN EXIT;
A frosty reception. Necessary, though, unless we're planning on the player living forever.
Life ain't fair, though - so if the player has totally run out of lives, there's no Update
to do. a Likewise, if they've just been blown up, they might be waiting to respawn, constituting another early `return`:
IF fShip.RespawnTimer > 0 THEN
BEGIN
DEC (fShip.RespawnTimer);
EXIT
END;
Next, we move the ship around according to the keys pressed:
{ Move ship. }
IF Key[ALLEGRO_KEY_LEFT] <> 0 THEN DEC (fShip.x, SPEED);
IF Key[ALLEGRO_KEY_RIGHT] <> 0 THEN INC (fShip.x, SPEED);
IF Key[ALLEGRO_KEY_UP] <> 0 THEN DEC (fShip.y, SPEED);
IF Key[ALLEGRO_KEY_DOWN] <> 0 THEN INC (fShip.y, SPEED);
IF fShip.x < 0 THEN fShip.x := 0;
IF fShip.y < 0 THEN fShip.y := 0;
IF fShip.x > MAX_X THEN fShip.x := MAX_X;
IF fShip.y > MAX_Y THEN fShip.y := MAX_Y;
There's some hemming-in of the player's movements going on here, because we don't want them flying out of the playfield. That's because it'd make it too easy for them to avoid what comes next:
{ Collisions. }
IF fShip.InvincibleTimer > 0 THEN
DEC (fShip.InvincibleTimer)
ELSE IF Shots.Collide (TRUE, fShip.x, fShip.y, SHIP_W, SHIP_H) THEN
BEGIN
lX := fShip.x + (SHIP_W DIV 2);
lY := fShip.y + (SHIP_H DIV 2);
Particles.Add (FALSE, lx , ly );
Particles.Add (FALSE, lx + 4, ly + 2);
Particles.Add (FALSE, lx - 2, ly - 4);
Particles.Add (FALSE, lx + 1, ly - 5);
DEC (fShip.Lives);
fShip.RespawnTimer := 90;
fShip.InvincibleTimer := 180
END;
At this point - if they've not got some invincibility to spare - we're checking whether the player is dodging the aliens' barrage successfully by making our first call to Shots.Collide
. If that comes back positive, it's bad news:
Particles.Add
gets spammed with new explosions.DEC (fShip.Lives)
probably speaks for itself.- RespawnTimer and InvincibleTimer are set, to give the player some breathing space.
However, if they were lucky enough to dodge every alien shot currently on the field, they'll be able to fire some shots of their own:
{ Shooting. }
IF fShip.ShotTimer > 0 THEN
DEC (fShip.ShotTimer)
ELSE IF Key[ALLEGRO_KEY_X] <> 0 THEN
BEGIN
lX := fShip.x + (SHIP_W DIV 2);
IF Shots.Add (TRUE, FALSE, lX, fShip.y) THEN fShip.ShotTimer := 5
END
We use ShotTimer to space out the shots a bit; having one appear on every single frame would be a tad excessive. On the other hand, if we're ready to shoot, the X key will trigger it, with Shots.Add
doing most of the heavy lifting.
Believe it or not, that's almost all there is to say here. All that's left is to draw the ship itself:
(* Draws ship. *)
PROCEDURE Draw;
BEGIN
{ Check if draw or not. }
IF (fShip.Lives < 0) OR (fShip.RespawnTimer > 0)
OR (((fShip.InvincibleTimer DIV 2) MOD 3) = 1) { Blinking. }
THEN
EXIT;
{ Draw. }
al_draw_bitmap (Sprites.Ship, fShip.x, fShip.y, 0)
END;
No real feat of engineering, other than to say that the ship obviously isn't drawn when there's a game over (or it's just been blown up), and that it characteristically blinks when invincible:
OR (((fShip.InvincibleTimer DIV 2) MOD 3) = 1) { Blinking. }
This unwieldy equation evaluates to "don't show the ship on the 2nd and 3rd frame in every 6 frames", which happens to be a good balance for a blinking effect.
The villains appear
We're going to implement those aliens now. If you looked at spritesheet.png under a magnifying glass, you'll have seen the 3 distinct types above - and while they all behave pretty similarly, it's the differences between them that bulk out the code here. So, be warned, this is the largest of the sections.
The unit file is aliens.pas.
This time, we've had something else to define before our RECORD
: the three types we mentioned.
TYPE
(* Alien types. *)
TAlienTypes = (
atBug = 0,
atArrow,
atThiccboi,
atN
);
(* Defines an alien. *)
TAlien = RECORD
(* Position. *)
x, y: INTEGER;
(* Type. *)
AlienType: TAlienTypes;
(* Statuses. *)
ShotTimer, Blink, Life: INTEGER;
Used: BOOLEAN;
END;
VAR
(* The aliens. *)
fAlien: ARRAY [1..NUM_ALIENS] OF TAlien;
Lots of familiar variables here, but with some fun additions: AlienType, blink and life.
Life is the number of hits the alien can take before it explodes, and Blink's purpose will become clear shortly - though you can probably make an educated guess.
Filling the quota
In Update
, there's some odd stuff going on right off the bat:
VAR
Ndx, NewQuota, NewX, cX, cY: INTEGER;
BEGIN
{ Values for new enemies. }
IF Frames MOD 120 <> 0 THEN
NewQuota := 0
ELSE
NewQuota := IntegerBetween (2, 4);
NewX := IntegerBetween (10, BUFFER_W - 50);
This is part of the mechanism for adding aliens to the playfield. Remember that global Frames variable we declared years ago? Well, this is one of its uses: every 120 frames, we're saying we want a fresh batch of aliens to appear.
So, if it's time, NewQuota will be set, along with that mysterious NewX attribute. Either way, we now set off looping through the fAlien array.
{ For each enemy... }
FOR Ndx := 1 TO NUM_ALIENS DO
BEGIN
IF NOT fAlien[Ndx].Used THEN
BEGIN
{ If this alien is unused, should it spawn? }
IF NewQuota > 0 THEN
BEGIN
INC (NewX, IntegerBetween (40, 80));
IF NewX > BUFFER_W - 60 THEN DEC (NewX, BUFFER_W - 60);
fAlien[Ndx].x := NewX;
fAlien[Ndx].y := IntegerBetween (-40, -30);
fAlien[Ndx].AlienType := TAlienTypes (IntegerBetween (0, ORD (atN)));
fAlien[Ndx].ShotTimer := IntegerBetween (1, 99);
fAlien[Ndx].Blink := 0;
fAlien[Ndx].Used := TRUE;
CASE fAlien[Ndx].AlienType OF
atBug:
fAlien[Ndx].Life := 4;
atArrow:
fAlien[Ndx].Life := 2;
atThiccboi:
fAlien[Ndx].Life := 12;
END;
DEC (NewQuota)
END
END
Here, if we come across an unused alien - one that normally wouldn't be updated - we're taking the opportunity to spawn it onto the playfield.
The spawned alien's x is then set, and NewX is nudged by a random amount towards the edge of the screen. Once it goes past a certain point, it's pushed back towards the left, like some kind of ephemeral typewriter.
All this does is ensure an even spread of aliens across the top of the screen. We'd prefer that two aliens didn't arrive on top of each other, because this could look a bit silly (or confusing) to the player.
All other fields of TAlien
are then set - other than Life, whose initial value depends on the alien's type; we want it such that bigger, slower-moving aliens can take more of a walloping from the player.
With that, our NewQuota drops by 1, and we continue looping.
They live!
As for the aliens that are already alive at this point, it's time for an update. Firstly, how fast should they move?
{ Updates enemy. }
CASE fAlien[Ndx].AlienType OF
atBug:
IF Frames MOD 2 <> 0 THEN INC (fAlien[Ndx].y);
atArrow:
INC (fAlien[Ndx].y);
atThiccboi:
IF Frames MOD 4 = 0 THEN INC (fAlien[Ndx].y);
END;
We're using Frames here to dictate how fast each type of alien moves. atArrow
`s are fastest and move down a pixel every frame; atBug
s and atThiccboi
s move every second and fourth frame respectively.
{ Collisions with shots. }
IF Shots.Collide (
FALSE,
fAlien[Ndx].x, fAlien[Ndx].y,
ALIEN_W[ORD (fAlien[Ndx].AlienType)], ALIEN_H[ORD (fAlien[Ndx].AlienType)]
) THEN
BEGIN
DEC (fAlien[Ndx].Life);
fAlien[Ndx].Blink := 4
END;
Later, we've got our obligatory call to Shots.Collide
. If the alien is hit, it (perhaps sadly) doesn't explode immediately, but its Life does decrease. Blink is also set (which, again, we'll explain later).
We then need to consider whether said alien might be out of luck:
{ Test enemy life. }
IF fAlien[Ndx].Life <= 0 THEN
BEGIN
Particles.Add (FALSE, cX, cY);
CASE fAlien[Ndx].AlienType OF
atBug:
INC (Player.Score, 200);
atArrow:
INC (Player.Score, 150);
atThiccboi:
BEGIN
INC (Player.Score, 800);
Particles.Add (FALSE, cX - 10, cY - 4);
Particles.Add (FALSE, cX + 4, cY + 10);
Particles.Add (FALSE, cX + 8, cY + 8)
END;
END;
fAlien[Ndx].Used := FALSE;
CONTINUE
END;
If this is indeed the case, AlienType is called upon once again; it's finally time to add to the Player.Score! The number of points added depends on the alien's type, as does the number of explosions produced: most aliens produce a meager single explosion, while atThiccboi
s add an extra 3.
On the other hand - if this alien is to live to fight another day - it may well want to fight now. Time to add some shots:
{ Check if shot. }
DEC (fAlien[Ndx].ShotTimer);
IF fAlien[Ndx].ShotTimer <= 0 THEN
CASE fAlien[Ndx].AlienType OF
atBug:
BEGIN
Shots.Add (FALSE, FALSE, cX, cY);
fAlien[Ndx].ShotTimer := 150
END;
atArrow:
BEGIN
Shots.Add (FALSE, FALSE, cX, fAlien[Ndx].y);
fAlien[Ndx].ShotTimer := 80
END;
atThiccboi:
BEGIN
Shots.Add (FALSE, TRUE, cX - 5, cY);
Shots.Add (FALSE, TRUE, cX + 5, cY);
Shots.Add (FALSE, TRUE, cX - 5, cY + 8);
Shots.Add (FALSE, TRUE, cX + 5, cY + 8);
fAlien[Ndx].ShotTimer := 200
END;
END
More AlienType-dependent stuff here, both for the number of shots fired, and the amount of time before the next batch.
With that, the aliens are up-to-speed. Compared to that, Draw
is pretty minimal:
(* Draw enemies. *)
PROCEDURE Draw;
VAR
Ndx: INTEGER;
BEGIN
FOR Ndx := 1 TO NUM_ALIENS DO
IF (fAlien[Ndx].Used) AND (fAlien[Ndx].Blink <= 2) THEN
al_draw_bitmap (
Sprites.Alien[ORD (fAlien[Ndx].AlienType)],
fAlien[Ndx].x, fAlien[Ndx].y,
0
)
END;
However, we finally see Blink come into its own here. Because it's set to 4 when an alien is hit, it flickers the alien out for a couple of frames, which should do the trick as a further indicator to the player that they were on target.
Most of the difficult stuff is out of the way now. If you're still reading, you deserve a medal. Or how about some stars?
Shiny stuff
Load unit starfield.pas. Here's our first bit of polish. Compared with the behemothic RECORD
s we've seen so far, stars are simple:
CONST
NUM_STARS = (BUFFER_W DIV 2) - 1;
TYPE
(* Star definition. *)
TStar = RECORD
y, Speed: REAL;
END;
VAR
fStar: ARRAY [1..NUM_STARS] OF TStar;
There's no used this time, because all of the stars are permanently displayed. What a treat.
Note the odd definition of NUM_STARS here: this means there's one star for almost every other horizontal pixel on the playfield.
So, in Initialize
, we scatter the stars across the Y-axis randomly:
(* Initializes the starfield. *)
PROCEDURE Initialize;
VAR
Cnt: INTEGER;
BEGIN
FOR Cnt := 1 TO NUM_STARS DO
BEGIN
fStar[Cnt].y := FloatBetween (0, BUFFER_H);
fStar[Cnt].Speed := FloatBetween (0.1, 1)
END
END;
Update
then (perhaps predictably) adds Speed to y on every frame. If a star goes out of view, it's nudged back to the top and given a different (but still random) speed:
(* Updates starfield. *)
PROCEDURE Update;
VAR
Ndx: INTEGER;
BEGIN
FOR Ndx := 1 TO NUM_STARS DO
BEGIN
fStar[Ndx].y := fStar[Ndx].y + fStar[Ndx].Speed;
IF fStar[Ndx].y >= BUFFER_H THEN
BEGIN
fStar[Ndx].y := 0;
fStar[Ndx].Speed := FloatBetween (0.1, 1)
END
END
END;
And, to finish:
(* Draws starfield. *)
PROCEDURE Draw;
VAR
StarX, Clr: REAL;
Ndx: INTEGER;
BEGIN
StarX := 1.5;
FOR Ndx := 1 TO NUM_STARS DO
BEGIN
Clr := fStar[Ndx].Speed * 0.8;
al_draw_pixel (StarX, fStar[Ndx].y, al_map_rgb_f (Clr, Clr, Clr));
StarX := StarX + 2;
END
END;
This is why we defined NUM_STARS as we did: in order to get a nice, evenly-distributed set of stars on the screen, we draw one on every other pixel on the X-axis. Cool? No? Okay.
Heads up
Y'all heard of a HUD? This is where we display info to the player on their progress - like their score and remaining lives. The one useb by our game is in the appropriately named hud.pas file.
Before we display anything of this variety, we've got some setup to do. Firstly, we haven't created a font yet; secondly, how about animating the score a bit?
VAR
fFont: ALLEGRO_FONTptr;
fScore: LONGINT;
(* Initializes HUD. *)
PROCEDURE Initialize;
BEGIN
fFont := al_create_builtin_font;
MustInit (Assigned (fFont), 'font');
fScore := 0
END;
...
(* Releases resources. *)
PROCEDURE Deinitialize;
BEGIN
al_destroy_font (fFont)
END;
No surprises here. Next, let's see how that fScore works:
(* Updates HUD. *)
PROCEDURE Update;
VAR
i, Diff: LONGINT;
BEGIN
IF Frames MOD 2 = 0 THEN
FOR i := 5 DOWNTO 1 DO
BEGIN
Diff := 1 SHL i;
IF fScore <= Player.Score - Diff THEN
INC (fScore, Diff)
END
END;
What's happening here is that we're gradually increasing fScore until it matches Player.Score. However, as with many other things in our game, we're only going to do this every other frame - lest it look frantically fast.
As for how it's increased: there's some bit-shifting trickery at work here. In short, if fScore is a long way behind Player.Score, it increases faster. As for how, read the following if you're interested - but, if we're being honest, this is more code-flagellation than important knowledge.
You may know that
1 SHL i
is a computationally cheap way of calculating 2i. In theFOR
loop above, we are checking whether fScore is at least 2i behind Player.Score (where i starts at 5 and decreases to 1), and - if this is true - we add 2i to fScore. This happens to give us a nicely adaptive 'rolling' score animation for the numbers we're dealing with.
At long last, we've got everything in place to draw the HUD. Let's examine each piece of Draw
in turn:
CONST
Spacing = LIFE_W + 1;
VAR
Cnt: INTEGER;
BEGIN
al_draw_textf (
fFont, al_map_rgb_f (1,1,1),
1, 1, 0,
'%.06d', [fScore]
);
First, we draw the score in the upper-left corner. The '%.06d' format ensures we have lots of leading zeroes - 6 seems to be a good number).
FOR Cnt := Player.NumLives - 1 DOWNTO 0 DO
al_draw_bitmap (Sprites.Life, 1 + (Cnt * Spacing), 10, 0);
Next, the player's remaining lives - one tiny ship sprite for each.
IF Player.NumLives < 0 THEN
al_draw_text (
fFont, al_map_rgb_f (1, 1, 1),
BUFFER_W DIV 2, BUFFER_H DIV 2, ALLEGRO_ALIGN_CENTER,
'G A M E O V E R'
)
Finishing as we started (on a grave note), we let the player know if they're out of lives.
We've now got everything in place. All that remains is to put it all together...
Finale
The crazy thing about our PROGRAM Game
(file game.pas) is that there's barely anything to say about it. It's just a standard Allegro event loop - of which you've seen plenty in this tutorial - but with all of our hard work from above slotted into it.
Compile & run
The Zip file (back here) contains two scripts to compile it with Free Pascal.
On Delphi rename game.pas to game.dpr and open it with Delphi; this will create a new project. Set up the output directories and that's all.
If you use Lazarus it is a bit harder: Create a simple program and save it in the src/ directory with name game. Then remove the game.lpr and rename game.pas to game.lpr. Open Lazarus again, load the project and set up the directories.
fp IDE users should know what to do... Ok, I almost never used fp IDE so I'm not sure how to do it.
At long last - providing everything's in place (or should we say 'in paste') - you should be greeted with a functional game.
For those who've forgotten the controls amidst the sea of code:
- Arrow keys to move.
- X to shoot.
- Esc to quit.
You want more?
Seriously!? Okay, well while we imagine you'll have already staggered off to the bar by this point, there's a ton of stuff that can be improved with what we've already done. To name a few:
- First off, everything's black & white! Add some color; edit the spritesheet, tint what's already there, or make the stars random colors.
- Add some music. As we mentioned back in the sound section, the Mod Archive has tons of ol'skool music that Allegro will happily play.
- Add high score functionality; find a way for the program to remember the best score it's seen after it restarts.
- At the moment, the player can fly into aliens and nothing happens. Use
Sprites.Collide
to check for this, and blow up both the player and the alien in question. - Make more aliens (and perhaps stronger aliens) appear as time goes on, so the game gets progressively harder.
- When the player's ship (and
atThiccboi
aliens) explode,Particles.Add
plays the same sounds multiple times. This is inefficient; figure out how we can avoid this. There are a few ways to do it. The same problem occurs whenatThiccboi
aliens shoot, as they spawn 4 shots at a time. Try applying a similar fix there. Once you've done that, our call toal_reserve_samples
can be given a significantly smaller number. - By following the instructions on Resolution Independence (C language) article, make the game fullscreen, and adaptively scale-up the graphics to fill the entire display (rather than having a window with DISPLAY_SCALE locked at 3).
- Add an optional second player with separate controls and score.
And with yet another barrage of information, we draw the a close. We're honoured that you've stuck with us until now. We're not quite done yet though...