-
Notifications
You must be signed in to change notification settings - Fork 8
Allegro Vivace – 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.
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).
TLDR: this part of the tutorial boils down to repeatedly adding some code to the bottom of a file, then reading our discussion of that code. Skip ahead if you don't care why.
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, we're instead going to be talking through the program top-to-bottom, explaining each section as we go.
Make a new game.c
file. As we progress, copy-and-paste each block of code from the View source dropdowns to the bottom of your file. Then, read our walkthrough of that code.
Let's get started. First: the fun stuff.
Here's some assorted space-related imagery. Curiously, it's all packed into one image; more on that shortly. Download and save it in the same directory as game.c
, making sure it's named spritesheet.png
.
Next up, some sound effects that absolutely, definitely have never been used in any game before. Again, save them 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.
Time for some code. Here's our first copy-paste of the day:
View source
#include <stdio.h>
#include <stdlib.h>
#include <allegro5/allegro5.h>
#include <allegro5/allegro_font.h>
#include <allegro5/allegro_primitives.h>
#include <allegro5/allegro_audio.h>
#include <allegro5/allegro_acodec.h>
#include <allegro5/allegro_image.h>
We'll be using every addon we've come across so far:
-
font
: for displaying the player's score, and showing a humiliating 'game over' when they fail. -
primitives
: always useful to have, but we're only going to use it to draw some stars. -
audio
andacodec
: for the aforementioned pew-pew sounds. -
image
: we've already got an image to load, so we'll need this.
Visual Studio users will want to enable those addons now. For everyone else, we'll run through the gcc
command later on; there's a lot more to be done before things get compiled!
Time for some global variables and helper functions:
View source
long frames;
long score;
void must_init(bool test, const char *description)
{
if(test) return;
printf("couldn't initialize %s\n", description);
exit(1);
}
int between(int lo, int hi)
{
return lo + (rand() % (hi - lo));
}
float between_f(float lo, float hi)
{
return lo + ((float)rand() / (float)RAND_MAX) * (hi - lo);
}
bool collide(int ax1, int ay1, int ax2, int ay2, int bx1, int by1, int bx2, int by2)
{
if(ax1 > bx2) return false;
if(ax2 < bx1) return false;
if(ay1 > by2) return false;
if(ay2 < by1) return false;
return true;
}
Other than the usual must_init()
, this is where things start getting interesting.
long frames;
long score;
The player has a score, but we're also interested in the number of frames we've rendered so far. More on this later.
int between(int lo, int hi)
{
return lo + (rand() % (hi - lo));
}
float between_f(float lo, float hi)
{
return lo + ((float)rand() / (float)RAND_MAX) * (hi - lo);
}
We've added a cheeky couple of functions here to generate random numbers within a range (lo
and hi
).
As you may be aware, most games rely on a degree of randomness. Ours will be no different.
Apologies in advance for what you're about to read.
bool collide(int ax1, int ay1, int ax2, int ay2, int bx1, int by1, int bx2, int by2)
{
if(ax1 > bx2) return false;
if(ax2 < bx1) return false;
if(ay1 > by2) return false;
if(ay2 < by1) return false;
return true;
}
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.
Let's add some code to set up and use an ALLEGRO_DISPLAY
:
View source
#define BUFFER_W 320
#define BUFFER_H 240
#define DISP_SCALE 3
#define DISP_W (BUFFER_W * DISP_SCALE)
#define DISP_H (BUFFER_H * DISP_SCALE)
ALLEGRO_DISPLAY* disp;
ALLEGRO_BITMAP* buffer;
void disp_init()
{
al_set_new_display_option(ALLEGRO_SAMPLE_BUFFERS, 1, ALLEGRO_SUGGEST);
al_set_new_display_option(ALLEGRO_SAMPLES, 8, ALLEGRO_SUGGEST);
disp = al_create_display(DISP_W, DISP_H);
must_init(disp, "display");
buffer = al_create_bitmap(BUFFER_W, BUFFER_H);
must_init(buffer, "bitmap buffer");
}
void disp_deinit()
{
al_destroy_bitmap(buffer);
al_destroy_display(disp);
}
void disp_pre_draw()
{
al_set_target_bitmap(buffer);
}
void disp_post_draw()
{
al_set_target_backbuffer(disp);
al_draw_scaled_bitmap(buffer, 0, 0, BUFFER_W, BUFFER_H, 0, 0, DISP_W, DISP_H, 0);
al_flip_display();
}
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.
If you're interested, there's an in-depth discussion of this on the Resolution Independence article.
We start by defining some kooky-lookin' constants:
#define BUFFER_W 320
#define BUFFER_H 240
#define DISP_SCALE 2
#define DISP_W (BUFFER_W * DISP_SCALE)
#define DISP_H (BUFFER_H * DISP_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 DISP
lay's size as a multiple of BUFFER_W
and BUFFER_H
. We then initialize both the display and the buffer in our disp_init()
function:
ALLEGRO_DISPLAY* disp;
ALLEGRO_BITMAP* buffer;
void disp_init()
{
disp = al_create_display(DISP_W, DISP_H);
must_init(disp, "display");
buffer = al_create_bitmap(BUFFER_W, BUFFER_H);
must_init(buffer, "bitmap buffer");
}
void disp_deinit()
{
al_destroy_bitmap(buffer);
al_destroy_display(disp);
}
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 display_deinit()
('display deinitialize') function to destroy these before the program ends.
Now comes the kicker:
void disp_pre_draw()
{
al_set_target_bitmap(buffer);
}
void disp_post_draw()
{
al_set_target_backbuffer(disp);
al_draw_scaled_bitmap(buffer, 0, 0, BUFFER_W, BUFFER_H, 0, 0, DISP_W, DISP_H, 0);
al_flip_display();
}
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 ourbuffer
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 smallbuffer
to fill the screen. - We then
al_flip_display()
as per usual.
But of course. We've set our DISP_SCALE
to 3
, meaning our 320x240 buffer
will be scaled up - in all of its chunky, pixelated glory - to fill a 960x720 window:
The simple answer is: you can't draw a bitmap to itself.
If 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!
View source
#define KEY_SEEN 1
#define KEY_RELEASED 2
unsigned char key[ALLEGRO_KEY_MAX];
void keyboard_init()
{
memset(key, 0, sizeof(key));
}
void keyboard_update(ALLEGRO_EVENT* event)
{
switch(event->type)
{
case ALLEGRO_EVENT_TIMER:
for(int i = 0; i < ALLEGRO_KEY_MAX; i++)
key[i] &= KEY_SEEN;
break;
case ALLEGRO_EVENT_KEY_DOWN:
key[event->keyboard.keycode] = KEY_SEEN | KEY_RELEASED;
break;
case ALLEGRO_EVENT_KEY_UP:
key[event->keyboard.keycode] &= KEY_RELEASED;
break;
}
}
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
keyboard_update()
once per game logic loop to make sure the keys are updated.
Remember that spritesheet.png
you downloaded? Time to put it to good use. Be warned, this is a big'un:
View source
#define SHIP_W 12
#define SHIP_H 13
#define SHIP_SHOT_W 2
#define SHIP_SHOT_H 9
#define LIFE_W 6
#define LIFE_H 6
const int ALIEN_W[] = {14, 13, 45};
const int ALIEN_H[] = { 9, 10, 27};
#define ALIEN_BUG_W ALIEN_W[0]
#define ALIEN_BUG_H ALIEN_H[0]
#define ALIEN_ARROW_W ALIEN_W[1]
#define ALIEN_ARROW_H ALIEN_H[1]
#define ALIEN_THICCBOI_W ALIEN_W[2]
#define ALIEN_THICCBOI_H ALIEN_H[2]
#define ALIEN_SHOT_W 4
#define ALIEN_SHOT_H 4
#define EXPLOSION_FRAMES 4
#define SPARKS_FRAMES 3
typedef struct SPRITES
{
ALLEGRO_BITMAP* _sheet;
ALLEGRO_BITMAP* ship;
ALLEGRO_BITMAP* ship_shot[2];
ALLEGRO_BITMAP* life;
ALLEGRO_BITMAP* alien[3];
ALLEGRO_BITMAP* alien_shot;
ALLEGRO_BITMAP* explosion[EXPLOSION_FRAMES];
ALLEGRO_BITMAP* sparks[SPARKS_FRAMES];
ALLEGRO_BITMAP* powerup[4];
} SPRITES;
SPRITES sprites;
ALLEGRO_BITMAP* sprite_grab(int x, int y, int w, int h)
{
ALLEGRO_BITMAP* sprite = al_create_sub_bitmap(sprites._sheet, x, y, w, h);
must_init(sprite, "sprite grab");
return sprite;
}
void sprites_init()
{
sprites._sheet = al_load_bitmap("spritesheet.png");
must_init(sprites._sheet, "spritesheet");
sprites.ship = sprite_grab(0, 0, SHIP_W, SHIP_H);
sprites.life = sprite_grab(0, 14, LIFE_W, LIFE_H);
sprites.ship_shot[0] = sprite_grab(13, 0, SHIP_SHOT_W, SHIP_SHOT_H);
sprites.ship_shot[1] = sprite_grab(16, 0, SHIP_SHOT_W, SHIP_SHOT_H);
sprites.alien[0] = sprite_grab(19, 0, ALIEN_BUG_W, ALIEN_BUG_H);
sprites.alien[1] = sprite_grab(19, 10, ALIEN_ARROW_W, ALIEN_ARROW_H);
sprites.alien[2] = sprite_grab(0, 21, ALIEN_THICCBOI_W, ALIEN_THICCBOI_H);
sprites.alien_shot = sprite_grab(13, 10, ALIEN_SHOT_W, ALIEN_SHOT_H);
sprites.explosion[0] = sprite_grab(33, 10, 9, 9);
sprites.explosion[1] = sprite_grab(43, 9, 11, 11);
sprites.explosion[2] = sprite_grab(46, 21, 17, 18);
sprites.explosion[3] = sprite_grab(46, 40, 17, 17);
sprites.sparks[0] = sprite_grab(34, 0, 10, 8);
sprites.sparks[1] = sprite_grab(45, 0, 7, 8);
sprites.sparks[2] = sprite_grab(54, 0, 9, 8);
sprites.powerup[0] = sprite_grab(0, 49, 9, 12);
sprites.powerup[1] = sprite_grab(10, 49, 9, 12);
sprites.powerup[2] = sprite_grab(20, 49, 9, 12);
sprites.powerup[3] = sprite_grab(30, 49, 9, 12);
}
void sprites_deinit()
{
al_destroy_bitmap(sprites.ship);
al_destroy_bitmap(sprites.ship_shot[0]);
al_destroy_bitmap(sprites.ship_shot[1]);
al_destroy_bitmap(sprites.alien[0]);
al_destroy_bitmap(sprites.alien[1]);
al_destroy_bitmap(sprites.alien[2]);
al_destroy_bitmap(sprites.sparks[0]);
al_destroy_bitmap(sprites.sparks[1]);
al_destroy_bitmap(sprites.sparks[2]);
al_destroy_bitmap(sprites.explosion[0]);
al_destroy_bitmap(sprites.explosion[1]);
al_destroy_bitmap(sprites.explosion[2]);
al_destroy_bitmap(sprites.explosion[3]);
al_destroy_bitmap(sprites.powerup[0]);
al_destroy_bitmap(sprites.powerup[1]);
al_destroy_bitmap(sprites.powerup[2]);
al_destroy_bitmap(sprites.powerup[3]);
al_destroy_bitmap(sprites._sheet);
}
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. At the time of writing, there aren't any proper tutorials on this wiki regarding spritesheets - but hopefully that'll change soon.
Anyway, that explanation aside, the above isn't as complicated as you might think:
-
All of the
ALLEGRO_BITMAP
s we need to load are shoved intostruct SPRITES
. -
At the start of our program, we'll call
sprites_init()
. This first loadssprites._sheet
. -
It then repeatedly calls
sprite_grab()
, which dices upsprites._sheet
, pulling the individual sprites into their separate bitmaps. For example:sprites.explosion[0] = sprite_grab(33, 10, 9, 9);
- Here, we're grabbing
sprites.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.
- Here, we're grabbing
-
There's a
sprites_deinit()
function to destroy all of the bitmaps when the program ends too.
We don't have much to do here other than load (and later destroy) the sound effects:
View source
ALLEGRO_SAMPLE* sample_shot;
ALLEGRO_SAMPLE* sample_explode[2];
void audio_init()
{
al_install_audio();
al_init_acodec_addon();
al_reserve_samples(128);
sample_shot = al_load_sample("shot.flac");
must_init(sample_shot, "shot sample");
sample_explode[0] = al_load_sample("explode1.flac");
must_init(sample_explode[0], "explode[0] sample");
sample_explode[1] = al_load_sample("explode2.flac");
must_init(sample_explode[1], "explode[1] sample");
}
void audio_deinit()
{
al_destroy_sample(sample_shot);
al_destroy_sample(sample_explode[0]);
al_destroy_sample(sample_explode[1]);
}
You might be seeing a pattern emerge now: we define a bunch of variables, maybe add some constants, write an init
function (and sometimes a deinit
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.
View source
typedef struct FX
{
int x, y;
int frame;
bool spark;
bool used;
} FX;
#define FX_N 128
FX fx[FX_N];
void fx_init()
{
for(int i = 0; i < FX_N; i++)
fx[i].used = false;
}
void fx_add(bool spark, int x, int y)
{
if(!spark)
al_play_sample(sample_explode[between(0, 2)], 0.75, 0, 1, ALLEGRO_PLAYMODE_ONCE, NULL);
for(int i = 0; i < FX_N; i++)
{
if(fx[i].used)
continue;
fx[i].x = x;
fx[i].y = y;
fx[i].frame = 0;
fx[i].spark = spark;
fx[i].used = true;
return;
}
}
void fx_update()
{
for(int i = 0; i < FX_N; i++)
{
if(!fx[i].used)
continue;
fx[i].frame++;
if((!fx[i].spark && (fx[i].frame == (EXPLOSION_FRAMES * 2)))
|| ( fx[i].spark && (fx[i].frame == (SPARKS_FRAMES * 2)))
)
fx[i].used = false;
}
}
void fx_draw()
{
for(int i = 0; i < FX_N; i++)
{
if(!fx[i].used)
continue;
int frame_display = fx[i].frame / 2;
ALLEGRO_BITMAP* bmp =
fx[i].spark
? sprites.sparks[frame_display]
: sprites.explosion[frame_display]
;
int x = fx[i].x - (al_get_bitmap_width(bmp) / 2);
int y = fx[i].y - (al_get_bitmap_height(bmp) / 2);
al_draw_bitmap(bmp, x, y, 0);
}
}
As Adam Sandler once quipped in a poor Israeli accent, it is time to make the bang boom. We begin by defining a struct
that stores everything we could need to know about our bangs/booms:
typedef struct FX
{
int x, y;
int frame;
bool spark;
bool used;
} FX;
#define FX_N 128
FX fx[FX_N];
We've got 128 of these bad bois ready to go in our fx
array. In fx_init()
, we then mark all of them as not used
:
void fx_init()
{
for(int i = 0; i < FX_N; i++)
fx[i].used = false;
}
That's all well and good, but what if you do want explosions on screen?
void fx_add(bool spark, int x, int y)
{
if(!spark)
al_play_sample(sample_explode[between(0, 2)], 0.75, 0, 1, ALLEGRO_PLAYMODE_ONCE, NULL);
for(int i = 0; i < FX_N; i++)
{
if(fx[i].used)
continue;
fx[i].x = x;
fx[i].y = y;
fx[i].frame = 0;
fx[i].spark = spark;
fx[i].used = true;
return;
}
}
Firstly, we should talk about spark
. 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
struct, ready to be animated:
Fig. 12a: the terrible graphics you'll shortly be subjected to.
Frames for the sparks are at the top, frames for explosions are below.
So, in fx_add()
, we're looping through the fx
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
.
void fx_update()
{
for(int i = 0; i < FX_N; i++)
{
if(!fx[i].used)
continue;
fx[i].frame++;
if((!fx[i].spark && (fx[i].frame == (EXPLOSION_FRAMES * 2)))
|| ( fx[i].spark && (fx[i].frame == (SPARKS_FRAMES * 2)))
)
fx[i].used = false;
}
}
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...
void fx_draw()
{
for(int i = 0; i < FX_N; i++)
{
if(!fx[i].used)
continue;
int frame_display = fx[i].frame / 2;
ALLEGRO_BITMAP* bmp =
fx[i].spark
? sprites.sparks[frame_display]
: sprites.explosion[frame_display]
;
int x = fx[i].x - (al_get_bitmap_width(bmp) / 2);
int y = fx[i].y - (al_get_bitmap_height(bmp) / 2);
al_draw_bitmap(bmp, x, y, 0);
}
}
fx_draw()
loops through the fx
array yet again - but this time, with the intent of drawing used
effects to the screen.
Firstly - if you happened to wonder why, in fx_update()
, we doubled the number of frames of explosions and sparks - here's your answer:
int frame_display = fx[i].frame / 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 /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()
:
ALLEGRO_BITMAP* bmp =
fx[i].spark
? sprites.sparks[frame_display]
: sprites.explosion[frame_display]
;
int x = fx[i].x - (al_get_bitmap_width(bmp) / 2);
int y = fx[i].y - (al_get_bitmap_height(bmp) / 2);
al_draw_bitmap(bmp, x, y, 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
struct
, likeFX
. - Instantiate a big array - like our 128
FX
structs above. - Write an
init
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.
Time to add some code for shots, because space shooters involve shooting.
View source
typedef struct SHOT
{
int x, y, dx, dy;
int frame;
bool ship;
bool used;
} SHOT;
#define SHOTS_N 128
SHOT shots[SHOTS_N];
void shots_init()
{
for(int i = 0; i < SHOTS_N; i++)
shots[i].used = false;
}
bool shots_add(bool ship, bool straight, int x, int y)
{
al_play_sample(
sample_shot,
0.3,
0,
ship ? 1.0 : between_f(1.5, 1.6),
ALLEGRO_PLAYMODE_ONCE,
NULL
);
for(int i = 0; i < SHOTS_N; i++)
{
if(shots[i].used)
continue;
shots[i].ship = ship;
if(ship)
{
shots[i].x = x - (SHIP_SHOT_W / 2);
shots[i].y = y;
}
else // alien
{
shots[i].x = x - (ALIEN_SHOT_W / 2);
shots[i].y = y - (ALIEN_SHOT_H / 2);
if(straight)
{
shots[i].dx = 0;
shots[i].dy = 2;
}
else
{
shots[i].dx = between(-2, 2);
shots[i].dy = between(-2, 2);
}
// if the shot has no speed, don't bother
if(!shots[i].dx && !shots[i].dy)
return true;
shots[i].frame = 0;
}
shots[i].frame = 0;
shots[i].used = true;
return true;
}
return false;
}
void shots_update()
{
for(int i = 0; i < SHOTS_N; i++)
{
if(!shots[i].used)
continue;
if(shots[i].ship)
{
shots[i].y -= 5;
if(shots[i].y < -SHIP_SHOT_H)
{
shots[i].used = false;
continue;
}
}
else // alien
{
shots[i].x += shots[i].dx;
shots[i].y += shots[i].dy;
if((shots[i].x < -ALIEN_SHOT_W)
|| (shots[i].x > BUFFER_W)
|| (shots[i].y < -ALIEN_SHOT_H)
|| (shots[i].y > BUFFER_H)
) {
shots[i].used = false;
continue;
}
}
shots[i].frame++;
}
}
bool shots_collide(bool ship, int x, int y, int w, int h)
{
for(int i = 0; i < SHOTS_N; i++)
{
if(!shots[i].used)
continue;
// don't collide with one's own shots
if(shots[i].ship == ship)
continue;
int sw, sh;
if(ship)
{
sw = ALIEN_SHOT_W;
sh = ALIEN_SHOT_H;
}
else
{
sw = SHIP_SHOT_W;
sh = SHIP_SHOT_H;
}
if(collide(x, y, x+w, y+h, shots[i].x, shots[i].y, shots[i].x+sw, shots[i].y+sh))
{
fx_add(true, shots[i].x + (sw / 2), shots[i].y + (sh / 2));
shots[i].used = false;
return true;
}
}
return false;
}
void shots_draw()
{
for(int i = 0; i < SHOTS_N; i++)
{
if(!shots[i].used)
continue;
int frame_display = (shots[i].frame / 2) % 2;
if(shots[i].ship)
al_draw_bitmap(sprites.ship_shot[frame_display], shots[i].x, shots[i].y, 0);
else // alien
{
ALLEGRO_COLOR tint =
frame_display
? al_map_rgb_f(1, 1, 1)
: al_map_rgb_f(0.5, 0.5, 0.5)
;
al_draw_tinted_bitmap(sprites.alien_shot, tint, shots[i].x, shots[i].y, 0);
}
}
}
We'll start with a quick look at our struct SHOT
. It's pretty similar to struct FX
:
typedef struct SHOT
{
int x, y, dx, dy;
int frame;
bool ship;
bool used;
} SHOT;
#define SHOTS_N 128
SHOT shots[SHOTS_N];
...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 shots_add()
, which is predictably called when the player or an alien wants to shoot:
bool shots_add(bool ship, bool straight, int x, int y)
{
al_play_sample(
sample_shot,
0.3,
0,
ship ? 1.0 : between_f(1.5, 1.6),
ALLEGRO_PLAYMODE_ONCE,
NULL
);
// ...
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 straight
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(straight)
{
shots[i].dx = 0;
shots[i].dy = 2;
}
else
{
shots[i].dx = between(-2, 2);
shots[i].dy = between(-2, 2);
}
There's a small chance that - with these calls to between()
- 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(!shots[i].dx && !shots[i].dy)
return true;
...and that's it for notable stuff in shots_add()
.
Onto shots_update()
- what happens to a live shot on every frame? Well, that depends whether it's the player's or not:
if(shots[i].ship)
{
shots[i].y -= 5;
if(shots[i].y < -SHIP_SHOT_H)
{
shots[i].used = false;
continue;
}
}
Those of an eagle-eyed persuasion will have noticed that, back in shots_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 // alien
{
shots[i].x += shots[i].dx;
shots[i].y += shots[i].dy;
if((shots[i].x < -ALIEN_SHOT_W)
|| (shots[i].x > BUFFER_W)
|| (shots[i].y < -ALIEN_SHOT_H)
|| (shots[i].y > BUFFER_H)
) {
shots[i].used = false;
continue;
}
}
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 more <
and >
ing to be done, so to speak.
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:
bool shots_collide(bool ship, int x, int y, int w, int h)
{
for(int i = 0; i < SHOTS_N; i++)
{
if(!shots[i].used)
continue;
// don't collide with one's own shots
if(shots[i].ship == ship)
continue;
int sw, sh;
if(ship)
{
sw = ALIEN_SHOT_W;
sh = ALIEN_SHOT_H;
}
else
{
sw = SHIP_SHOT_W;
sh = SHIP_SHOT_H;
}
if(collide(x, y, x+w, y+h, shots[i].x, shots[i].y, shots[i].x+sw, shots[i].y+sh))
{
fx_add(true, shots[i].x + (sw / 2), shots[i].y + (sh / 2));
shots[i].used = false;
return true;
}
}
return false;
}
Despite our earlier protestations, this function isn't the most terrible of the bunch:
- The
x
,y
,w
andh
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
collide
(after figuring out the size of the box around this shot). - If
collide
returnstrue
, we've got stuff to do:- A call to our old friend
fx_add
to make some sparks. - Removal of the shot from play.
- A
return true
, so that whatever calledshots_collide()
knows some damage has been done.
- A call to our old friend
You'll see shots_collide()
called twice later on. 10 points for each correct guess as to where.
Lastly we have shots_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:
ALLEGRO_COLOR tint =
frame_display
? al_map_rgb_f(1, 1, 1)
: al_map_rgb_f(0.5, 0.5, 0.5)
;
al_draw_tinted_bitmap(sprites.alien_shot, tint, shots[i].x, shots[i].y, 0);
For the player's shots, we use the frame
variable to choose a bitmap (either sprites.ship_shot[0]
or [1]
). In the case of aliens, though, there's only one: sprites.alien_shot
.
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.
View source
#define SHIP_SPEED 3
#define SHIP_MAX_X (BUFFER_W - SHIP_W)
#define SHIP_MAX_Y (BUFFER_H - SHIP_H)
typedef struct SHIP
{
int x, y;
int shot_timer;
int lives;
int respawn_timer;
int invincible_timer;
} SHIP;
SHIP ship;
void ship_init()
{
ship.x = (BUFFER_W / 2) - (SHIP_W / 2);
ship.y = (BUFFER_H / 2) - (SHIP_H / 2);
ship.shot_timer = 0;
ship.lives = 3;
ship.respawn_timer = 0;
ship.invincible_timer = 120;
}
void ship_update()
{
if(ship.lives < 0)
return;
if(ship.respawn_timer)
{
ship.respawn_timer--;
return;
}
if(key[ALLEGRO_KEY_LEFT])
ship.x -= SHIP_SPEED;
if(key[ALLEGRO_KEY_RIGHT])
ship.x += SHIP_SPEED;
if(key[ALLEGRO_KEY_UP])
ship.y -= SHIP_SPEED;
if(key[ALLEGRO_KEY_DOWN])
ship.y += SHIP_SPEED;
if(ship.x < 0)
ship.x = 0;
if(ship.y < 0)
ship.y = 0;
if(ship.x > SHIP_MAX_X)
ship.x = SHIP_MAX_X;
if(ship.y > SHIP_MAX_Y)
ship.y = SHIP_MAX_Y;
if(ship.invincible_timer)
ship.invincible_timer--;
else
{
if(shots_collide(true, ship.x, ship.y, SHIP_W, SHIP_H))
{
int x = ship.x + (SHIP_W / 2);
int y = ship.y + (SHIP_H / 2);
fx_add(false, x, y);
fx_add(false, x+4, y+2);
fx_add(false, x-2, y-4);
fx_add(false, x+1, y-5);
ship.lives--;
ship.respawn_timer = 90;
ship.invincible_timer = 180;
}
}
if(ship.shot_timer)
ship.shot_timer--;
else if(key[ALLEGRO_KEY_X])
{
int x = ship.x + (SHIP_W / 2);
if(shots_add(true, false, x, ship.y))
ship.shot_timer = 5;
}
}
void ship_draw()
{
if(ship.lives < 0)
return;
if(ship.respawn_timer)
return;
if(((ship.invincible_timer / 2) % 3) == 1)
return;
al_draw_bitmap(sprites.ship, ship.x, ship.y, 0);
}
The player is different from our FX
and SHOT
s, because there's only one SHIP
- so no looping needed. Let's have a look at how things start out:
typedef struct SHIP
{
int x, y;
int shot_timer;
int lives;
int respawn_timer;
int invincible_timer;
} SHIP;
SHIP ship;
void ship_init()
{
ship.x = (BUFFER_W / 2) - (SHIP_W / 2);
ship.y = (BUFFER_H / 2) - (SHIP_H / 2);
ship.shot_timer = 0;
ship.lives = 3;
ship.respawn_timer = 0;
ship.invincible_timer = 120;
}
So, they get 3 lives, are centred on the screen, and are invincible - at least for a couple of seconds. These variables might be relatively self-explanatory at this point, but we'll see how they work shortly.
Now let's head onto ship_update()
:
void ship_update()
{
if(ship.lives < 0)
return;
// ...
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 ship_update()
to do. Likewise, if they've just been blown up, they might be waiting to respawn, constituting another early return
:
if(ship.respawn_timer)
{
ship.respawn_timer--;
return;
}
Next, we move the ship around according to the keys pressed:
if(key[ALLEGRO_KEY_LEFT])
ship.x -= SHIP_SPEED;
if(key[ALLEGRO_KEY_RIGHT])
ship.x += SHIP_SPEED;
if(key[ALLEGRO_KEY_UP])
ship.y -= SHIP_SPEED;
if(key[ALLEGRO_KEY_DOWN])
ship.y += SHIP_SPEED;
if(ship.x < 0)
ship.x = 0;
if(ship.y < 0)
ship.y = 0;
if(ship.x > SHIP_MAX_X)
ship.x = SHIP_MAX_X;
if(ship.y > SHIP_MAX_Y)
ship.y = SHIP_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:
if(ship.invincible_timer)
ship.invincible_timer--;
else
{
if(shots_collide(true, ship.x, ship.y, SHIP_W, SHIP_H))
{
int x = ship.x + (SHIP_W / 2);
int y = ship.y + (SHIP_H / 2);
fx_add(false, x, y);
fx_add(false, x+4, y+2);
fx_add(false, x-2, y-4);
fx_add(false, x+1, y-5);
ship.lives--;
ship.respawn_timer = 90;
ship.invincible_timer = 180;
}
}
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:
-
fx_add()
gets spammed with new explosions. -
ship.lives--
probably speaks for itself. -
respawn_timer
andinvincible_timer
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:
if(ship.shot_timer)
ship.shot_timer--;
else if(key[ALLEGRO_KEY_X])
{
int x = ship.x + (SHIP_W / 2);
if(shots_add(true, false, x, ship.y))
ship.shot_timer = 5;
}
We use shot_timer
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:
void ship_draw()
{
if(ship.lives < 0)
return;
if(ship.respawn_timer)
return;
if(((ship.invincible_timer / 2) % 3) == 1)
return;
al_draw_bitmap(sprites.ship, ship.x, ship.y, 0);
}
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:
if(((ship.invincible_timer / 2) % 3) == 1)
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.
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.
View source
typedef enum ALIEN_TYPE
{
ALIEN_TYPE_BUG = 0,
ALIEN_TYPE_ARROW,
ALIEN_TYPE_THICCBOI,
ALIEN_TYPE_N
} ALIEN_TYPE;
typedef struct ALIEN
{
int x, y;
ALIEN_TYPE type;
int shot_timer;
int blink;
int life;
bool used;
} ALIEN;
#define ALIENS_N 16
ALIEN aliens[ALIENS_N];
void aliens_init()
{
for(int i = 0; i < ALIENS_N; i++)
aliens[i].used = false;
}
void aliens_update()
{
int new_quota =
(frames % 120)
? 0
: between(2, 4)
;
int new_x = between(10, BUFFER_W-50);
for(int i = 0; i < ALIENS_N; i++)
{
if(!aliens[i].used)
{
// if this alien is unused, should it spawn?
if(new_quota > 0)
{
new_x += between(40, 80);
if(new_x > (BUFFER_W - 60))
new_x -= (BUFFER_W - 60);
aliens[i].x = new_x;
aliens[i].y = between(-40, -30);
aliens[i].type = ALIEN_TYPE (between(0, ALIEN_TYPE_N));
aliens[i].shot_timer = between(1, 99);
aliens[i].blink = 0;
aliens[i].used = true;
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
aliens[i].life = 4;
break;
case ALIEN_TYPE_ARROW:
aliens[i].life = 2;
break;
case ALIEN_TYPE_THICCBOI:
aliens[i].life = 12;
break;
}
new_quota--;
}
continue;
}
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
if(frames % 2)
aliens[i].y++;
break;
case ALIEN_TYPE_ARROW:
aliens[i].y++;
break;
case ALIEN_TYPE_THICCBOI:
if(!(frames % 4))
aliens[i].y++;
break;
}
if(aliens[i].y >= BUFFER_H)
{
aliens[i].used = false;
continue;
}
if(aliens[i].blink)
aliens[i].blink--;
if(shots_collide(false, aliens[i].x, aliens[i].y, ALIEN_W[aliens[i].type], ALIEN_H[aliens[i].type]))
{
aliens[i].life--;
aliens[i].blink = 4;
}
int cx = aliens[i].x + (ALIEN_W[aliens[i].type] / 2);
int cy = aliens[i].y + (ALIEN_H[aliens[i].type] / 2);
if(aliens[i].life <= 0)
{
fx_add(false, cx, cy);
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
score += 200;
break;
case ALIEN_TYPE_ARROW:
score += 150;
break;
case ALIEN_TYPE_THICCBOI:
score += 800;
fx_add(false, cx-10, cy-4);
fx_add(false, cx+4, cy+10);
fx_add(false, cx+8, cy+8);
break;
}
aliens[i].used = false;
continue;
}
aliens[i].shot_timer--;
if(aliens[i].shot_timer == 0)
{
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
shots_add(false, false, cx, cy);
aliens[i].shot_timer = 150;
break;
case ALIEN_TYPE_ARROW:
shots_add(false, true, cx, aliens[i].y);
aliens[i].shot_timer = 80;
break;
case ALIEN_TYPE_THICCBOI:
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);
aliens[i].shot_timer = 200;
break;
}
}
}
}
void aliens_draw()
{
for(int i = 0; i < ALIENS_N; i++)
{
if(!aliens[i].used)
continue;
if(aliens[i].blink > 2)
continue;
al_draw_bitmap(sprites.alien[aliens[i].type], aliens[i].x, aliens[i].y, 0);
}
}
This time, we've had something else to define before our struct
: the three types we mentioned.
typedef enum ALIEN_TYPE
{
ALIEN_TYPE_BUG = 0,
ALIEN_TYPE_ARROW,
ALIEN_TYPE_THICCBOI,
ALIEN_TYPE_N
} ALIEN_TYPE;
typedef struct ALIEN
{
int x, y;
ALIEN_TYPE type;
int shot_timer;
int blink;
int life;
bool used;
} ALIEN;
#define ALIENS_N 16
ALIEN aliens[ALIENS_N];
Lots of familiar variables here, but with some fun additions: type
, 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.
In aliens_update()
, there's some odd stuff going on right off the bat:
void aliens_update()
{
int new_quota =
(frames % 120)
? 0
: between(2, 4)
;
int new_x = between(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, new_quota
will be set, along with that mysterious new_x
attribute. Either way, we now set off looping through the aliens
array.
for(int i = 0; i < ALIENS_N; i++)
{
if(!aliens[i].used)
{
// if this alien is unused, should it spawn?
if(new_quota > 0)
{
new_x += between(40, 80);
if(new_x > (BUFFER_W - 60))
new_x -= (BUFFER_W - 60);
aliens[i].x = new_x;
aliens[i].y = between(-40, -30);
aliens[i].type = ALIEN_TYPE (between(0, ALIEN_TYPE_N));
aliens[i].shot_timer = between(1, 99);
aliens[i].blink = 0;
aliens[i].used = true;
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
aliens[i].life = 4;
break;
case ALIEN_TYPE_ARROW:
aliens[i].life = 2;
break;
case ALIEN_TYPE_THICCBOI:
aliens[i].life = 12;
break;
}
new_quota--;
}
continue;
}
// ...
A bit more happening than the usual if(used) continue
then eh? 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 new_x
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 struct ALIEN
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 new_quota
drops by 1, and we continue looping.
As for the aliens that are already alive at this point, it's time for an update. Firstly, how fast should they move?
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
if(frames % 2)
aliens[i].y++;
break;
case ALIEN_TYPE_ARROW:
aliens[i].y++;
break;
case ALIEN_TYPE_THICCBOI:
if(!(frames % 4))
aliens[i].y++;
break;
}
We're using frames
here to dictate how fast each type of alien moves. ARROW
s are fastest and move down a pixel every frame; BUG
s and THICCBOI
s move every second and fourth frame respectively.
if(shots_collide(false, aliens[i].x, aliens[i].y, ALIEN_W[aliens[i].type], ALIEN_H[aliens[i].type]))
{
aliens[i].life--;
aliens[i].blink = 4;
}
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:
if(aliens[i].life <= 0)
{
fx_add(false, cx, cy);
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
score += 200;
break;
case ALIEN_TYPE_ARROW:
score += 150;
break;
case ALIEN_TYPE_THICCBOI:
score += 800;
fx_add(false, cx-10, cy-4);
fx_add(false, cx+4, cy+10);
fx_add(false, cx+8, cy+8);
break;
}
aliens[i].used = false;
continue;
}
If this is indeed the case, type
is called upon once again; it's finally time to add to the 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 THICCBOI
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:
aliens[i].shot_timer--;
if(aliens[i].shot_timer == 0)
{
switch(aliens[i].type)
{
case ALIEN_TYPE_BUG:
shots_add(false, false, cx, cy);
aliens[i].shot_timer = 150;
break;
case ALIEN_TYPE_ARROW:
shots_add(false, true, cx, aliens[i].y);
aliens[i].shot_timer = 80;
break;
case ALIEN_TYPE_THICCBOI:
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);
aliens[i].shot_timer = 200;
break;
}
}
More type
-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, aliens_draw()
is pretty minimal:
void aliens_draw()
{
for(int i = 0; i < ALIENS_N; i++)
{
if(!aliens[i].used)
continue;
if(aliens[i].blink > 2)
continue;
al_draw_bitmap(sprites.alien[aliens[i].type], aliens[i].x, aliens[i].y, 0);
}
}
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?
View source
typedef struct STAR
{
float y;
float speed;
} STAR;
#define STARS_N ((BUFFER_W / 2) - 1)
STAR stars[STARS_N];
void stars_init()
{
for(int i = 0; i < STARS_N; i++)
{
stars[i].y = between_f(0, BUFFER_H);
stars[i].speed = between_f(0.1, 1);
}
}
void stars_update()
{
for(int i = 0; i < STARS_N; i++)
{
stars[i].y += stars[i].speed;
if(stars[i].y >= BUFFER_H)
{
stars[i].y = 0;
stars[i].speed = between_f(0.1, 1);
}
}
}
void stars_draw()
{
float star_x = 1.5;
for(int i = 0; i < STARS_N; i++)
{
float l = stars[i].speed * 0.8;
al_draw_pixel(star_x, stars[i].y, al_map_rgb_f(l,l,l));
star_x += 2;
}
}
Here's our first bit of polish. Compared with the behemothic struct
s we've seen so far, stars are simple:
typedef struct STAR
{
float y;
float speed;
} STAR;
#define STARS_N ((BUFFER_W / 2) - 1)
STAR stars[STARS_N];
There's no used
this time, because all of the stars are permanently displayed. What a treat.
Note the odd definition of STARS_N
here: this means there's one star for almost every other horizontal pixel on the playfield.
So, in stars_init()
, we scatter the stars across the Y-axis randomly:
void stars_init()
{
for(int i = 0; i < STARS_N; i++)
{
stars[i].y = between_f(0, 240);
stars[i].speed = between_f(0.1, 1);
}
}
stars_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:
void stars_update()
{
for(int i = 0; i < STARS_N; i++)
{
stars[i].y += stars[i].speed;
if(stars[i].y >= BUFFER_H)
{
stars[i].y = 0;
stars[i].speed = between_f(0.1, 1);
}
}
}
And, to finish:
void stars_draw()
{
float star_x = 1;
for(int i = 0; i < STARS_N; i++)
{
float l = stars[i].speed * 0.8;
al_draw_pixel(star_x, stars[i].y, al_map_rgb_f(l,l,l));
star_x += 2;
}
}
This is why we defined STARS_N
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.
Y'all heard of a HUD? This is where we display info to the player on their progress - like their score and remaining lives.
View source
ALLEGRO_FONT* font;
long score_display;
void hud_init()
{
font = al_create_builtin_font();
must_init(font, "font");
score_display = 0;
}
void hud_deinit()
{
al_destroy_font(font);
}
void hud_update()
{
if(frames % 2)
return;
for(long i = 5; i > 0; i--)
{
long diff = 1 << i;
if(score_display <= (score - diff))
score_display += diff;
}
}
void hud_draw()
{
al_draw_textf(
font,
al_map_rgb_f(1,1,1),
1, 1,
0,
"%06ld",
score_display
);
int spacing = LIFE_W + 1;
for(int i = 0; i < ship.lives; i++)
al_draw_bitmap(sprites.life, 1 + (i * spacing), 10, 0);
if(ship.lives < 0)
al_draw_text(
font,
al_map_rgb_f(1,1,1),
BUFFER_W / 2, BUFFER_H / 2,
ALLEGRO_ALIGN_CENTER,
"G A M E O V E R"
);
}
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?
ALLEGRO_FONT* font;
long score_display;
void hud_init()
{
font = al_create_builtin_font();
must_init(font, "font");
score_display = 0;
}
void hud_deinit()
{
al_destroy_font(font);
}
No surprises here. Next, let's see how that score_display
works:
void hud_update()
{
if(frames % 2)
return;
for(long i = 5; i > 0; i--)
{
long diff = 1 << i;
if(score_display <= (score - diff))
score_display += diff;
}
}
What's happening here is that we're gradually increasing score_display
until it matches 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 score_display
is a long way behind 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.
The C neckbeards amount you may know that
1 << i
is a computationally cheap way of calculating 2i. In thefor
loop above, we are checking whetherscore_display
is at least 2i behindscore
(wherei
starts at 5 and decreases to 1), and - if this is true - we add 2i toscore_display
. 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 hud_draw
in turn:
void hud_draw()
{
al_draw_textf(
font,
al_map_rgb_f(1,1,1),
1, 1,
0,
"%06ld",
score_display
);
// ...
First, we draw the score in the upper-left corner. The %06ld
format ensures we have lots of leading zeroes - 6 seems to be a good number.
int spacing = LIFE_W + 1;
for(int i = 0; i < ship.lives; i++)
al_draw_bitmap(sprites.life, 1 + (i * spacing), 10, 0);
Next, the player's remaining lives - one tiny ship sprite for each.
if(ship.lives < 0)
al_draw_text(
font,
al_map_rgb_f(1,1,1),
BUFFER_W / 2, BUFFER_H / 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...
View source
int main()
{
must_init(al_init(), "allegro");
must_init(al_install_keyboard(), "keyboard");
ALLEGRO_TIMER* timer = al_create_timer(1.0 / 60.0);
must_init(timer, "timer");
ALLEGRO_EVENT_QUEUE* queue = al_create_event_queue();
must_init(queue, "queue");
disp_init();
audio_init();
must_init(al_init_image_addon(), "image");
sprites_init();
hud_init();
must_init(al_init_primitives_addon(), "primitives");
must_init(al_install_audio(), "audio");
must_init(al_init_acodec_addon(), "audio codecs");
must_init(al_reserve_samples(16), "reserve samples");
al_register_event_source(queue, al_get_keyboard_event_source());
al_register_event_source(queue, al_get_display_event_source(disp));
al_register_event_source(queue, al_get_timer_event_source(timer));
keyboard_init();
fx_init();
shots_init();
ship_init();
aliens_init();
stars_init();
frames = 0;
score = 0;
bool done = false;
bool redraw = true;
ALLEGRO_EVENT event;
al_start_timer(timer);
while(1)
{
al_wait_for_event(queue, &event);
switch(event.type)
{
case ALLEGRO_EVENT_TIMER:
fx_update();
shots_update();
stars_update();
ship_update();
aliens_update();
hud_update();
if(key[ALLEGRO_KEY_ESCAPE])
done = true;
redraw = true;
frames++;
break;
case ALLEGRO_EVENT_DISPLAY_CLOSE:
done = true;
break;
}
if(done)
break;
keyboard_update(&event);
if(redraw && al_is_event_queue_empty(queue))
{
disp_pre_draw();
al_clear_to_color(al_map_rgb(0,0,0));
stars_draw();
aliens_draw();
shots_draw();
fx_draw();
ship_draw();
hud_draw();
disp_post_draw();
redraw = false;
}
}
sprites_deinit();
hud_deinit();
audio_deinit();
disp_deinit();
al_destroy_timer(timer);
al_destroy_event_queue(queue);
return 0;
}
The crazy thing about our main
function 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.
Visual Studio users should already be good to go (if they were paying attention earlier).
For everyone else, here's your final compile-and-run command:
gcc game.c -o game $(pkg-config allegro-5 allegro_font-5 allegro_primitives-5 allegro_audio-5 allegro_acodec-5 allegro_image-5 --libs --cflags)
./game
At long last - providing everything's in place (or should we say 'in paste') - you should be greeted with a functional game.
If it doesn't compile, try grabbing a complete copy of the source.
For those who've forgotten the controls amidst the sea of code:
- Arrow keys to move.
- X to shoot.
- Esc to quit.
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
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
THICCBOI
aliens) explode,fx_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 when
THICCBOI
aliens shoot, as they spawn 4 shots at a time. Try applying a similar fix there. - Once you've done that, our call to
al_reserve_samples()
can be given a significantly smaller number.
- The same problem occurs when
- By following the instructions on Resolution Independence article, make the game fullscreen, and adaptively scale-up the graphics to fill the entire display (rather than having a window with
DISP_SCALE
locked at 3). - Split up that absolutely huge
game.c
file. If you've not dabbled in the art of using multiple.c
files, this Stack Overflow question is a good place to start. - 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...